nixamp 0.68.2 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +15 -5
- package/dist/ai-label.js +84 -34
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +12 -2
- package/dist/school-cli.d.ts +2 -0
- package/dist/school-cli.js +151 -0
- package/dist/school-client.d.ts +61 -0
- package/dist/school-client.js +148 -0
- package/dist/school-discovery.d.ts +32 -0
- package/dist/school-discovery.js +204 -0
- package/dist/school-mcp.d.ts +180 -0
- package/dist/school-mcp.js +143 -0
- package/dist/server.js +10 -0
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +6 -6
- package/dist/veo-studio.d.ts +1 -1
- package/dist/veo-studio.js +2 -2
- package/package.json +1 -1
- package/src/ai-label.ts +86 -35
- package/src/main.ts +8 -0
- package/src/mcp.ts +14 -2
- package/src/school-cli.ts +131 -0
- package/src/school-client.ts +180 -0
- package/src/school-discovery.ts +212 -0
- package/src/school-mcp.ts +153 -0
- package/src/server.ts +9 -0
- package/src/station-cli.ts +1 -0
- package/src/veo-studio.ts +2 -2
- package/web/dist/assets/{ar-BUn23r3N.js → ar-CoJztWFs.js} +1 -1
- package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-1Avz2N93.js} +1 -1
- package/web/dist/assets/{da-C8OtQ3BT.js → da-CiNddZp3.js} +1 -1
- package/web/dist/assets/{de-CldRtYgJ.js → de-D8Rljw7l.js} +1 -1
- package/web/dist/assets/{es-DSPjVfW5.js → es-CIxJsfiv.js} +1 -1
- package/web/dist/assets/{fi-XgwcnMwU.js → fi-C5MieJtE.js} +1 -1
- package/web/dist/assets/{fr-BH9o9Stx.js → fr-DBMzgQul.js} +1 -1
- package/web/dist/assets/{hi-DuMMcoiX.js → hi-CUNsIGCW.js} +1 -1
- package/web/dist/assets/{hu-B2Hiutdz.js → hu-CRkS43qM.js} +1 -1
- package/web/dist/assets/{id-BNctMtkh.js → id-DtXzZzr5.js} +1 -1
- package/web/dist/assets/{it-C_ADjBqX.js → it-CleEG3yJ.js} +1 -1
- package/web/dist/assets/{main-BVDC-f0Q.js → main-DZtT4u1U.js} +2 -2
- package/web/dist/assets/{nl-BPTAZ6gI.js → nl-HDKYTlnz.js} +1 -1
- package/web/dist/assets/{ru-CMSzyodc.js → ru-CX6u3qpl.js} +1 -1
- package/web/dist/assets/{sv-BqdBoD8p.js → sv-BRCqB1fE.js} +1 -1
- package/web/dist/assets/{uk-C3lM_pHS.js → uk-BMLA9Xl6.js} +1 -1
- package/web/dist/assets/{vi-CkVfVKSH.js → vi-DzPvzeIq.js} +1 -1
- package/web/dist/assets/{zh-BDiROZo0.js → zh-Cbb7TICl.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help as a client: its classes are nixamp's live events, served
|
|
3
|
+
* by the same process on another host, so one nixamp token signs in to both.
|
|
4
|
+
*
|
|
5
|
+
* The CLI (school-cli.ts) and the MCP tools (school-mcp.ts) are both this
|
|
6
|
+
* file; neither talks HTTP itself.
|
|
7
|
+
*
|
|
8
|
+
* `broadcastHealth` is the reason this exists as much as the CRUD: a class
|
|
9
|
+
* stays "live" until somebody ends it, long after the channel it points at
|
|
10
|
+
* was stopped, and the page then shows a player that never plays. It asks the
|
|
11
|
+
* broadcast itself, with only what the public link already carries.
|
|
12
|
+
*/
|
|
13
|
+
import { readSession } from "./session.js";
|
|
14
|
+
export const SCHOOL_SITE = "https://backtoschool.help";
|
|
15
|
+
export class SchoolError extends Error {
|
|
16
|
+
status;
|
|
17
|
+
constructor(message, status = 0) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function schoolSite(options = {}) {
|
|
23
|
+
return (options.site ?? process.env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE).replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
export function classUrl(item, options = {}) {
|
|
26
|
+
return `${schoolSite(options)}/live/${item.slug}`;
|
|
27
|
+
}
|
|
28
|
+
function tokenOf(options) {
|
|
29
|
+
if (options.token !== undefined)
|
|
30
|
+
return options.token;
|
|
31
|
+
return readSession()?.token ?? null;
|
|
32
|
+
}
|
|
33
|
+
async function call(path, init = {}, options = {}) {
|
|
34
|
+
const token = tokenOf(options);
|
|
35
|
+
if (init.auth && !token)
|
|
36
|
+
throw new SchoolError("Not signed in. Run `nixamp login`, or set NIXAMP_TOKEN.", 401);
|
|
37
|
+
const headers = { accept: "application/json" };
|
|
38
|
+
if (token)
|
|
39
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
40
|
+
if (init.body !== undefined)
|
|
41
|
+
headers["content-type"] = "application/json";
|
|
42
|
+
const response = await (options.fetcher ?? fetch)(`${schoolSite(options)}${path}`, {
|
|
43
|
+
method: init.method ?? "GET",
|
|
44
|
+
headers,
|
|
45
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
46
|
+
});
|
|
47
|
+
const body = (await response.json().catch(() => ({})));
|
|
48
|
+
if (!response.ok)
|
|
49
|
+
throw new SchoolError(String(body["error"] ?? `backtoschool.help answered ${response.status}`), response.status);
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
const eventOf = (body) => (body["event"] ?? body);
|
|
53
|
+
const ref = (value) => encodeURIComponent(value.replace(/^.*\/live\//, "").replace(/[?#].*$/, ""));
|
|
54
|
+
export async function listClasses(query = {}, options = {}) {
|
|
55
|
+
const params = new URLSearchParams();
|
|
56
|
+
if (query.status)
|
|
57
|
+
params.set("status", query.status);
|
|
58
|
+
if (query.kind)
|
|
59
|
+
params.set("kind", query.kind);
|
|
60
|
+
if (query.mine)
|
|
61
|
+
params.set("mine", "true");
|
|
62
|
+
params.set("limit", String(query.limit ?? 60));
|
|
63
|
+
const body = await call(`/api/v1/events?${params}`, { auth: query.mine === true }, options);
|
|
64
|
+
return body["events"] ?? [];
|
|
65
|
+
}
|
|
66
|
+
/** A class by its slug, id, or the /live/<slug> link. */
|
|
67
|
+
export async function getClass(slug, options = {}) {
|
|
68
|
+
return eventOf(await call(`/api/v1/events/${ref(slug)}`, {}, options));
|
|
69
|
+
}
|
|
70
|
+
export async function createClass(input, options = {}) {
|
|
71
|
+
return eventOf(await call("/api/v1/events", { method: "POST", body: { kind: "class", visibility: "public", ...input }, auth: true }, options));
|
|
72
|
+
}
|
|
73
|
+
export async function updateClass(slug, patch, options = {}) {
|
|
74
|
+
const current = await getClass(slug, options);
|
|
75
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}`, { method: "PATCH", body: { ...patch, version: current.version }, auth: true }, options));
|
|
76
|
+
}
|
|
77
|
+
/** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
|
|
78
|
+
export async function moveClass(slug, action, options = {}) {
|
|
79
|
+
const current = await getClass(slug, options);
|
|
80
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}/${action}`, { method: "POST", body: { version: current.version }, auth: true }, options));
|
|
81
|
+
}
|
|
82
|
+
export async function deleteClass(slug, options = {}) {
|
|
83
|
+
await call(`/api/v1/events/${ref(slug)}`, { method: "DELETE", auth: true }, options);
|
|
84
|
+
}
|
|
85
|
+
/** Make a class of a broadcast and put it on the air in one go. */
|
|
86
|
+
export async function teach(input, options = {}) {
|
|
87
|
+
const made = await createClass(input, options);
|
|
88
|
+
return moveClass(made.slug, "start", options);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Does this broadcast play? true and false are answers; null is "cannot tell
|
|
92
|
+
* from here" (a Pairux room), which a prune must leave alone.
|
|
93
|
+
*/
|
|
94
|
+
export async function broadcastHealth(link, fetcher = fetch, timeoutMs = 8000) {
|
|
95
|
+
if (!link)
|
|
96
|
+
return { ok: false, reason: "no broadcast link" };
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
url = new URL(link);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { ok: false, reason: "not a link" };
|
|
103
|
+
}
|
|
104
|
+
const get = (target, headers = {}) => fetcher(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
105
|
+
try {
|
|
106
|
+
// A station page: its own token opens its state.
|
|
107
|
+
const station = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
|
|
108
|
+
if (station && url.hostname.endsWith(".nixamp.com")) {
|
|
109
|
+
const response = await get(`${url.origin}/api/v1/stations/${station}/state?t=${encodeURIComponent(url.searchParams.get("t") ?? "")}`);
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
return { ok: false, reason: `station ${station} answered ${response.status}` };
|
|
112
|
+
const state = (await response.json());
|
|
113
|
+
return state.state === "live" ? { ok: true, reason: `station ${station} is live` } : { ok: false, reason: `station ${station} is ${state.state ?? "not live"}` };
|
|
114
|
+
}
|
|
115
|
+
// nixamp.com/?url=<server>/view/<listen key>&play=channel:<id>: the listen key reads the server's channels.
|
|
116
|
+
if ((url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com") && url.searchParams.get("url")) {
|
|
117
|
+
const server = new URL(url.searchParams.get("url"));
|
|
118
|
+
const key = /\/view\/([^/?#]+)/.exec(server.pathname)?.[1] ?? "";
|
|
119
|
+
const play = url.searchParams.get("play") ?? "live";
|
|
120
|
+
const response = await get(`${server.origin}/api/streams?k=${encodeURIComponent(key)}`);
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
return { ok: false, reason: `${server.host} answered ${response.status}` };
|
|
123
|
+
const streams = (await response.json());
|
|
124
|
+
if (play.startsWith("channel:")) {
|
|
125
|
+
const id = play.slice("channel:".length);
|
|
126
|
+
return (streams.channels ?? []).some((one) => one.id === id)
|
|
127
|
+
? { ok: true, reason: `channel ${id} is on ${server.hostname}` }
|
|
128
|
+
: { ok: false, reason: `channel ${id} is no longer on ${server.hostname}` };
|
|
129
|
+
}
|
|
130
|
+
if (play === "live")
|
|
131
|
+
return streams.server?.live ? { ok: true, reason: `${server.hostname} is live` } : { ok: false, reason: `${server.hostname} is not live` };
|
|
132
|
+
return { ok: true, reason: `${server.hostname} is up` };
|
|
133
|
+
}
|
|
134
|
+
if (url.hostname === "pairux.com" || url.hostname === "www.pairux.com")
|
|
135
|
+
return { ok: null, reason: "a Pairux room; cannot tell from here" };
|
|
136
|
+
// Direct media: one byte is enough to know it is there.
|
|
137
|
+
const response = await get(url.href, { range: "bytes=0-0" });
|
|
138
|
+
return response.ok ? { ok: true, reason: `${url.hostname} serves it` } : { ok: false, reason: `${url.hostname} answered ${response.status}` };
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return { ok: false, reason: `unreachable: ${error.message}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Your live classes, each with whether its broadcast plays. */
|
|
145
|
+
export async function checkClasses(options = {}, status = "live") {
|
|
146
|
+
const items = await listClasses({ mine: true, status }, options);
|
|
147
|
+
return Promise.all(items.map(async (item) => ({ item, health: await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch) })));
|
|
148
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help's own front door for agents: llms.txt, an OpenAPI
|
|
3
|
+
* document of the class API, and MCP at /mcp/public (reading) and /mcp
|
|
4
|
+
* (teaching, as the bearer).
|
|
5
|
+
*
|
|
6
|
+
* The school is served by the nixamp process under another Host, so before
|
|
7
|
+
* this its /llms.txt and /mcp answered as nixamp.com: an agent landing on
|
|
8
|
+
* the school was told about radio stations and handed no class tools. The
|
|
9
|
+
* routes here run only for the school's host, ahead of nixamp's.
|
|
10
|
+
*
|
|
11
|
+
* The tools are school-mcp.ts, called over HTTP against the school's own
|
|
12
|
+
* origin with the caller's token, exactly as from a CLI. Sign-in is nixamp's:
|
|
13
|
+
* one account, one token, and nixamp.com is the authorization server.
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
+
export interface SchoolDiscoveryContext {
|
|
17
|
+
/** This school's origin, e.g. https://backtoschool.help. */
|
|
18
|
+
site: string;
|
|
19
|
+
/** nixamp.com: where tokens come from and where OAuth is. */
|
|
20
|
+
accountSite: string;
|
|
21
|
+
version: string;
|
|
22
|
+
whoIs: (token: string) => Promise<unknown>;
|
|
23
|
+
token: string;
|
|
24
|
+
readBody: (request: IncomingMessage, limit: number) => Promise<string>;
|
|
25
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
26
|
+
fetcher?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
/** Is this host the school? backtoschool.help (or NIXAMP_SCHOOL_URL), with or without www. */
|
|
29
|
+
export declare function isSchoolSite(site: string | undefined, env?: NodeJS.ProcessEnv): boolean;
|
|
30
|
+
export declare function schoolLlmsTxt(site: string, accountSite: string): string;
|
|
31
|
+
export declare function schoolOpenapi(site: string, accountSite: string, version: string): Record<string, unknown>;
|
|
32
|
+
export declare function handleSchoolDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: SchoolDiscoveryContext): Promise<boolean>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { SCHOOL_PUBLIC_TOOLS, SCHOOL_TOOLS, callSchoolTool, isSchoolPublicTool, isSchoolTool } from "./school-mcp.js";
|
|
2
|
+
import { SCHOOL_SITE } from "./school-client.js";
|
|
3
|
+
const CORS = {
|
|
4
|
+
"access-control-allow-origin": "*",
|
|
5
|
+
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
|
6
|
+
"access-control-allow-headers": "authorization, content-type, mcp-session-id, mcp-protocol-version",
|
|
7
|
+
};
|
|
8
|
+
const PATHS = new Set(["/llms.txt", "/openapi.json", "/mcp", "/mcp/public", "/.well-known/oauth-protected-resource"]);
|
|
9
|
+
/** Is this host the school? backtoschool.help (or NIXAMP_SCHOOL_URL), with or without www. */
|
|
10
|
+
export function isSchoolSite(site, env = process.env) {
|
|
11
|
+
if (!site)
|
|
12
|
+
return false;
|
|
13
|
+
const bare = (value) => new URL(value).hostname.replace(/^www\./, "");
|
|
14
|
+
try {
|
|
15
|
+
return bare(site) === bare(env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function schoolLlmsTxt(site, accountSite) {
|
|
22
|
+
return `# BackToSchool.help
|
|
23
|
+
|
|
24
|
+
> Live classes anybody can teach and anybody can join: a broadcast (a nixamp station, a screen share, a video link) with chat and raised hands around it. Free to watch. Accounts are nixamp accounts.
|
|
25
|
+
|
|
26
|
+
## Web
|
|
27
|
+
- ${site}/ : classes live now and coming up
|
|
28
|
+
- ${site}/live/<slug> : one class
|
|
29
|
+
|
|
30
|
+
## MCP
|
|
31
|
+
- Public, no account (what is on, one class): ${site}/mcp/public
|
|
32
|
+
- Signed in (teach, start, end, delete, check your classes): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth via ${accountSite}
|
|
33
|
+
- Local over stdio: npx -y nixamp mcp (the class tools are among nixamp's)
|
|
34
|
+
|
|
35
|
+
Tools: classes_list, class_get, class_teach (a broadcast link in, a live class out), class_create, class_update, class_start, class_end, class_delete, classes_check (which of your live classes no longer play).
|
|
36
|
+
|
|
37
|
+
## CLI
|
|
38
|
+
- Install: curl -fsSL ${accountSite}/install.sh | sh (or npx -y nixamp)
|
|
39
|
+
- Sign in: nixamp login
|
|
40
|
+
- nixamp school list --status live
|
|
41
|
+
- nixamp school teach <station link> --title "..." make a class and start it
|
|
42
|
+
- nixamp school check | prune --end find and end classes whose broadcast stopped
|
|
43
|
+
- nixamp school help
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
- OpenAPI: ${site}/openapi.json
|
|
47
|
+
- GET ${site}/api/v1/events?status=live : classes (public ones; ?mine=true with a token)
|
|
48
|
+
- GET ${site}/api/v1/events/<slug> : one class
|
|
49
|
+
- POST ${site}/api/v1/events : make one {title, broadcastUrl, description, topic, visibility, startsAt}
|
|
50
|
+
- POST ${site}/api/v1/events/<slug>/start|end {version} : the version you last read
|
|
51
|
+
- PATCH ${site}/api/v1/events/<slug> {..., version}, DELETE ${site}/api/v1/events/<slug>
|
|
52
|
+
- Auth: Authorization: Bearer <nixamp token>
|
|
53
|
+
|
|
54
|
+
## What a broadcast can be
|
|
55
|
+
A nixamp station link (https://<server>.nixamp.com:4321/station/<id>?t=<token>), a nixamp.com share link, a Pairux room, or a direct https .m3u8/.mp4/.mp3. Only broadcast what you have the right to: your own teaching, or openly licensed courses (MIT OpenCourseWare is CC BY-NC-SA: free, credited, no ads).
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
export function schoolOpenapi(site, accountSite, version) {
|
|
59
|
+
const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
|
|
60
|
+
const auth = { security: [{ bearer: [] }] };
|
|
61
|
+
const slug = [{ name: "slug", in: "path", required: true, schema: { type: "string" }, description: "The class's slug or id." }];
|
|
62
|
+
const versioned = { content: { "application/json": { schema: { type: "object", properties: { version: { type: "integer" } }, required: ["version"] } } } };
|
|
63
|
+
const fields = {
|
|
64
|
+
title: { type: "string", maxLength: 160 }, description: { type: "string", maxLength: 5000 }, topic: { type: "string", maxLength: 100 },
|
|
65
|
+
broadcastUrl: { type: "string", format: "uri" }, visibility: { type: "string", enum: ["public", "unlisted", "private"] },
|
|
66
|
+
startsAt: { type: "string", format: "date-time" }, chatEnabled: { type: "boolean" }, handRaiseEnabled: { type: "boolean" },
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
openapi: "3.1.0",
|
|
70
|
+
info: { title: "BackToSchool.help", version, description: "Live classes. Reading is open; teaching wants a nixamp token as the bearer.", license: { name: "MIT", identifier: "MIT" } },
|
|
71
|
+
servers: [{ url: site }],
|
|
72
|
+
externalDocs: { description: "llms.txt: MCP, CLI and API in one place", url: `${site}/llms.txt` },
|
|
73
|
+
components: { securitySchemes: { bearer: { type: "http", scheme: "bearer", description: `A nixamp token (\`nixamp token create\`) or an OAuth access token from ${accountSite}.` } } },
|
|
74
|
+
paths: {
|
|
75
|
+
"/api/v1/events": {
|
|
76
|
+
get: {
|
|
77
|
+
summary: "Classes", tags: ["classes"], responses: { 200: ok },
|
|
78
|
+
parameters: [
|
|
79
|
+
{ name: "status", in: "query", schema: { type: "string", enum: ["draft", "scheduled", "starting", "live", "encore", "ended", "cancelled", "archived"] } },
|
|
80
|
+
{ name: "mine", in: "query", schema: { type: "boolean" }, description: "Only yours (needs the bearer)." },
|
|
81
|
+
{ name: "limit", in: "query", schema: { type: "integer", maximum: 100 } },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
post: { summary: "Make a class (a draft; start it to go live)", tags: ["classes"], ...auth, requestBody: { content: { "application/json": { schema: { type: "object", properties: { kind: { type: "string", enum: ["class", "talk", "concert"] }, ...fields }, required: ["title"] } } } }, responses: { 200: ok } },
|
|
85
|
+
},
|
|
86
|
+
"/api/v1/events/{slug}": {
|
|
87
|
+
parameters: slug,
|
|
88
|
+
get: { summary: "One class", tags: ["classes"], responses: { 200: ok } },
|
|
89
|
+
patch: { summary: "Change a class you host", tags: ["classes"], ...auth, requestBody: { content: { "application/json": { schema: { type: "object", properties: { ...fields, version: { type: "integer" } }, required: ["version"] } } } }, responses: { 200: ok } },
|
|
90
|
+
delete: { summary: "Delete a class you host", tags: ["classes"], ...auth, responses: { 200: ok } },
|
|
91
|
+
},
|
|
92
|
+
"/api/v1/events/{slug}/start": { parameters: slug, post: { summary: "Go live", tags: ["classes"], ...auth, requestBody: versioned, responses: { 200: ok } } },
|
|
93
|
+
"/api/v1/events/{slug}/end": { parameters: slug, post: { summary: "End it", tags: ["classes"], ...auth, requestBody: versioned, responses: { 200: ok } } },
|
|
94
|
+
"/api/v1/events/{slug}/chat": { parameters: slug, get: { summary: "The class chat", tags: ["chat"], responses: { 200: ok } }, post: { summary: "Say something", tags: ["chat"], ...auth, responses: { 200: ok } } },
|
|
95
|
+
"/api/v1/auth/me": { get: { summary: "Who the bearer is", tags: ["auth"], ...auth, responses: { 200: ok } } },
|
|
96
|
+
"/mcp/public": { post: { summary: "MCP (streamable HTTP, JSON-RPC): classes_list, class_get", tags: ["mcp"], responses: { 200: ok } } },
|
|
97
|
+
"/mcp": { post: { summary: "MCP (streamable HTTP, JSON-RPC): every class tool, as the bearer", tags: ["mcp"], ...auth, responses: { 200: ok } } },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function handleSchoolDiscovery(request, response, path, ctx) {
|
|
102
|
+
if (!PATHS.has(path))
|
|
103
|
+
return false;
|
|
104
|
+
const method = request.method ?? "GET";
|
|
105
|
+
const site = ctx.site.replace(/\/+$/, "");
|
|
106
|
+
if (method === "OPTIONS") {
|
|
107
|
+
response.writeHead(204, CORS);
|
|
108
|
+
response.end();
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (path === "/llms.txt" || path === "/openapi.json" || path === "/.well-known/oauth-protected-resource") {
|
|
112
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
113
|
+
ctx.json(response, 405, { error: "GET only" });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const [type, body] = path === "/llms.txt"
|
|
117
|
+
? ["text/plain; charset=utf-8", schoolLlmsTxt(site, ctx.accountSite)]
|
|
118
|
+
: ["application/json; charset=utf-8", JSON.stringify(path === "/openapi.json"
|
|
119
|
+
? schoolOpenapi(site, ctx.accountSite, ctx.version)
|
|
120
|
+
: { resource: site, authorization_servers: [ctx.accountSite], bearer_methods_supported: ["header"], resource_name: "BackToSchool.help" }, null, 2)];
|
|
121
|
+
response.writeHead(200, { ...CORS, "content-type": type, "cache-control": "public, max-age=300" });
|
|
122
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
// MCP. GET has nothing to push; DELETE ends a session nobody keeps.
|
|
126
|
+
if (method === "GET") {
|
|
127
|
+
response.writeHead(405, { ...CORS, allow: "POST, DELETE" });
|
|
128
|
+
response.end();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (method === "DELETE") {
|
|
132
|
+
response.writeHead(204, CORS);
|
|
133
|
+
response.end();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (method !== "POST") {
|
|
137
|
+
ctx.json(response, 405, { error: "POST a JSON-RPC message" });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const signed = path === "/mcp";
|
|
141
|
+
if (signed && (await ctx.whoIs(ctx.token)) === null) {
|
|
142
|
+
const error = JSON.stringify({ error: "sign in: a nixamp token as the bearer (`nixamp token create`), or OAuth" });
|
|
143
|
+
response.writeHead(401, {
|
|
144
|
+
...CORS,
|
|
145
|
+
"content-type": "application/json; charset=utf-8",
|
|
146
|
+
"www-authenticate": `Bearer resource_metadata="${site}/.well-known/oauth-protected-resource"`,
|
|
147
|
+
});
|
|
148
|
+
response.end(error);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(await ctx.readBody(request, 256 * 1024));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
ctx.json(response, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const tools = signed ? SCHOOL_TOOLS : SCHOOL_PUBLIC_TOOLS;
|
|
160
|
+
const allowed = signed ? isSchoolTool : isSchoolPublicTool;
|
|
161
|
+
const answers = [];
|
|
162
|
+
for (const message of (Array.isArray(parsed) ? parsed : [parsed])) {
|
|
163
|
+
const id = message?.id ?? null;
|
|
164
|
+
const name = typeof message?.method === "string" ? message.method : "";
|
|
165
|
+
if (name.startsWith("notifications/"))
|
|
166
|
+
continue;
|
|
167
|
+
if (name === "initialize") {
|
|
168
|
+
answers.push({ jsonrpc: "2.0", id, result: {
|
|
169
|
+
protocolVersion: message.params?.["protocolVersion"] ?? "2025-06-18",
|
|
170
|
+
capabilities: { tools: { listChanged: false } },
|
|
171
|
+
serverInfo: { name: "backtoschool", title: "BackToSchool.help: live classes", version: "1" },
|
|
172
|
+
instructions: signed
|
|
173
|
+
? "Live classes on BackToSchool.help. classes_list / class_get to see what is on; class_teach puts a broadcast (a nixamp station link, a Pairux room, an https video) on the air as a class in one call; classes_check finds your live classes whose broadcast stopped, and class_end / class_delete clear them. Only broadcast what you have the right to."
|
|
174
|
+
: "Live classes on BackToSchool.help: classes_list and class_get. To teach, use /mcp with a nixamp token as the bearer.",
|
|
175
|
+
} });
|
|
176
|
+
}
|
|
177
|
+
else if (name === "ping") {
|
|
178
|
+
answers.push({ jsonrpc: "2.0", id, result: {} });
|
|
179
|
+
}
|
|
180
|
+
else if (name === "tools/list") {
|
|
181
|
+
answers.push({ jsonrpc: "2.0", id, result: { tools } });
|
|
182
|
+
}
|
|
183
|
+
else if (name === "tools/call" && allowed(String(message.params?.["name"] ?? ""))) {
|
|
184
|
+
const result = await callSchoolTool(String(message.params["name"]), (message.params?.["arguments"] ?? {}), {
|
|
185
|
+
site, token: ctx.token || null, ...(ctx.fetcher ? { fetcher: ctx.fetcher } : {}),
|
|
186
|
+
});
|
|
187
|
+
answers.push({ jsonrpc: "2.0", id, result });
|
|
188
|
+
}
|
|
189
|
+
else if (name === "tools/call") {
|
|
190
|
+
answers.push({ jsonrpc: "2.0", id, error: { code: -32001, message: "only classes_list and class_get are public; for the rest use /mcp with a nixamp token as the bearer (`nixamp token create`), or OAuth" } });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
answers.push({ jsonrpc: "2.0", id, error: { code: -32601, message: `no method ${name}` } });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (answers.length === 0) {
|
|
197
|
+
response.writeHead(202, CORS);
|
|
198
|
+
response.end();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
response.writeHead(200, { ...CORS, "content-type": "application/json; charset=utf-8" });
|
|
202
|
+
response.end(JSON.stringify(Array.isArray(parsed) ? answers : answers[0]));
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help classes as MCP tools: what `nixamp school` does, for an agent.
|
|
3
|
+
*
|
|
4
|
+
* Reading what is on needs nobody; making, starting, ending and deleting a
|
|
5
|
+
* class needs the token the rest of nixamp uses, because a class is a live
|
|
6
|
+
* event on the same accounts. `class_teach` is the one-call path from a
|
|
7
|
+
* station link (station_list / station_create) to a class on the air.
|
|
8
|
+
*/
|
|
9
|
+
import { type SchoolOptions } from "./school-client.ts";
|
|
10
|
+
export declare const SCHOOL_PUBLIC_TOOLS: ({
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: string;
|
|
15
|
+
properties: {
|
|
16
|
+
status: {
|
|
17
|
+
type: string;
|
|
18
|
+
enum: string[];
|
|
19
|
+
};
|
|
20
|
+
mine: {
|
|
21
|
+
type: string;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
limit: {
|
|
25
|
+
type: string;
|
|
26
|
+
};
|
|
27
|
+
slug?: undefined;
|
|
28
|
+
};
|
|
29
|
+
required?: undefined;
|
|
30
|
+
};
|
|
31
|
+
} | {
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: string;
|
|
36
|
+
properties: {
|
|
37
|
+
status?: undefined;
|
|
38
|
+
mine?: undefined;
|
|
39
|
+
limit?: undefined;
|
|
40
|
+
slug: {
|
|
41
|
+
type: "string";
|
|
42
|
+
description: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
required: string[];
|
|
46
|
+
};
|
|
47
|
+
})[];
|
|
48
|
+
export declare const SCHOOL_TOOLS: ({
|
|
49
|
+
name: string;
|
|
50
|
+
description: string;
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: string;
|
|
53
|
+
properties: {
|
|
54
|
+
status: {
|
|
55
|
+
type: string;
|
|
56
|
+
enum: string[];
|
|
57
|
+
};
|
|
58
|
+
mine: {
|
|
59
|
+
type: string;
|
|
60
|
+
description: string;
|
|
61
|
+
};
|
|
62
|
+
limit: {
|
|
63
|
+
type: string;
|
|
64
|
+
};
|
|
65
|
+
slug?: undefined;
|
|
66
|
+
};
|
|
67
|
+
required?: undefined;
|
|
68
|
+
};
|
|
69
|
+
} | {
|
|
70
|
+
name: string;
|
|
71
|
+
description: string;
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: string;
|
|
74
|
+
properties: {
|
|
75
|
+
status?: undefined;
|
|
76
|
+
mine?: undefined;
|
|
77
|
+
limit?: undefined;
|
|
78
|
+
slug: {
|
|
79
|
+
type: "string";
|
|
80
|
+
description: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
required: string[];
|
|
84
|
+
};
|
|
85
|
+
} | {
|
|
86
|
+
name: string;
|
|
87
|
+
description: string;
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: string;
|
|
90
|
+
properties: {
|
|
91
|
+
title: {
|
|
92
|
+
readonly type: "string";
|
|
93
|
+
};
|
|
94
|
+
description: {
|
|
95
|
+
readonly type: "string";
|
|
96
|
+
};
|
|
97
|
+
topic: {
|
|
98
|
+
readonly type: "string";
|
|
99
|
+
};
|
|
100
|
+
visibility: {
|
|
101
|
+
readonly type: "string";
|
|
102
|
+
readonly enum: readonly ["public", "unlisted", "private"];
|
|
103
|
+
};
|
|
104
|
+
startsAt: {
|
|
105
|
+
readonly type: "string";
|
|
106
|
+
readonly description: "ISO 8601; leave out to start now (class_teach) or keep it a draft (class_create).";
|
|
107
|
+
};
|
|
108
|
+
chatEnabled: {
|
|
109
|
+
readonly type: "boolean";
|
|
110
|
+
};
|
|
111
|
+
handRaiseEnabled: {
|
|
112
|
+
readonly type: "boolean";
|
|
113
|
+
};
|
|
114
|
+
broadcastUrl: {
|
|
115
|
+
type: "string";
|
|
116
|
+
description: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
required: string[];
|
|
120
|
+
};
|
|
121
|
+
} | {
|
|
122
|
+
name: string;
|
|
123
|
+
description: string;
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: string;
|
|
126
|
+
properties: {
|
|
127
|
+
title: {
|
|
128
|
+
readonly type: "string";
|
|
129
|
+
};
|
|
130
|
+
description: {
|
|
131
|
+
readonly type: "string";
|
|
132
|
+
};
|
|
133
|
+
topic: {
|
|
134
|
+
readonly type: "string";
|
|
135
|
+
};
|
|
136
|
+
visibility: {
|
|
137
|
+
readonly type: "string";
|
|
138
|
+
readonly enum: readonly ["public", "unlisted", "private"];
|
|
139
|
+
};
|
|
140
|
+
startsAt: {
|
|
141
|
+
readonly type: "string";
|
|
142
|
+
readonly description: "ISO 8601; leave out to start now (class_teach) or keep it a draft (class_create).";
|
|
143
|
+
};
|
|
144
|
+
chatEnabled: {
|
|
145
|
+
readonly type: "boolean";
|
|
146
|
+
};
|
|
147
|
+
handRaiseEnabled: {
|
|
148
|
+
readonly type: "boolean";
|
|
149
|
+
};
|
|
150
|
+
slug: {
|
|
151
|
+
type: "string";
|
|
152
|
+
description: string;
|
|
153
|
+
};
|
|
154
|
+
broadcastUrl: {
|
|
155
|
+
type: "string";
|
|
156
|
+
description: string;
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
required: string[];
|
|
160
|
+
};
|
|
161
|
+
} | {
|
|
162
|
+
name: string;
|
|
163
|
+
description: string;
|
|
164
|
+
inputSchema: {
|
|
165
|
+
required?: undefined;
|
|
166
|
+
type: string;
|
|
167
|
+
properties: {};
|
|
168
|
+
};
|
|
169
|
+
})[];
|
|
170
|
+
export declare function isSchoolTool(name: string): boolean;
|
|
171
|
+
export declare function isSchoolPublicTool(name: string): boolean;
|
|
172
|
+
type Result = {
|
|
173
|
+
content: {
|
|
174
|
+
type: "text";
|
|
175
|
+
text: string;
|
|
176
|
+
}[];
|
|
177
|
+
isError?: boolean;
|
|
178
|
+
};
|
|
179
|
+
export declare function callSchoolTool(name: string, args: Record<string, unknown>, options?: SchoolOptions): Promise<Result>;
|
|
180
|
+
export {};
|