nixamp 0.68.2 → 0.69.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/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-mcp.d.ts +6 -6
- package/package.json +1 -1
- 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/web/dist/assets/{ar-BUn23r3N.js → ar-BAUYJ7CG.js} +1 -1
- package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-D5Njj-lD.js} +1 -1
- package/web/dist/assets/{da-C8OtQ3BT.js → da-CQ52TUpt.js} +1 -1
- package/web/dist/assets/{de-CldRtYgJ.js → de-slj7FWP_.js} +1 -1
- package/web/dist/assets/{es-DSPjVfW5.js → es-wy-duArI.js} +1 -1
- package/web/dist/assets/{fi-XgwcnMwU.js → fi-D8tuiwIa.js} +1 -1
- package/web/dist/assets/{fr-BH9o9Stx.js → fr-AlVe-2YY.js} +1 -1
- package/web/dist/assets/{hi-DuMMcoiX.js → hi-BkrehL5x.js} +1 -1
- package/web/dist/assets/{hu-B2Hiutdz.js → hu-_3tyj6FI.js} +1 -1
- package/web/dist/assets/{id-BNctMtkh.js → id-O2_s9_ZD.js} +1 -1
- package/web/dist/assets/{it-C_ADjBqX.js → it-D6kuTye8.js} +1 -1
- package/web/dist/assets/{main-BVDC-f0Q.js → main-Bh0dWH3R.js} +2 -2
- package/web/dist/assets/{nl-BPTAZ6gI.js → nl-DOdu_ltu.js} +1 -1
- package/web/dist/assets/{ru-CMSzyodc.js → ru-B39HLLYh.js} +1 -1
- package/web/dist/assets/{sv-BqdBoD8p.js → sv-DTDAE9DG.js} +1 -1
- package/web/dist/assets/{uk-C3lM_pHS.js → uk-DmM6P2_W.js} +1 -1
- package/web/dist/assets/{vi-CkVfVKSH.js → vi-8EoMeh4v.js} +1 -1
- package/web/dist/assets/{zh-BDiROZo0.js → zh-DkGyIChT.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
|
@@ -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 {};
|
|
@@ -0,0 +1,143 @@
|
|
|
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 { broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass, } from "./school-client.js";
|
|
10
|
+
const STRING = { type: "string" };
|
|
11
|
+
const SLUG = { ...STRING, description: "The class's slug, id, or its backtoschool.help/live/<slug> link." };
|
|
12
|
+
const BROADCAST = { ...STRING, description: "What plays: a nixamp station link (https://<server>.nixamp.com:4321/station/<id>?t=<token>), a nixamp.com share link, a Pairux room, or an https .m3u8/.mp4/.mp3 URL." };
|
|
13
|
+
const FIELDS = {
|
|
14
|
+
title: STRING,
|
|
15
|
+
description: STRING,
|
|
16
|
+
topic: STRING,
|
|
17
|
+
visibility: { type: "string", enum: ["public", "unlisted", "private"] },
|
|
18
|
+
startsAt: { ...STRING, description: "ISO 8601; leave out to start now (class_teach) or keep it a draft (class_create)." },
|
|
19
|
+
chatEnabled: { type: "boolean" },
|
|
20
|
+
handRaiseEnabled: { type: "boolean" },
|
|
21
|
+
};
|
|
22
|
+
export const SCHOOL_PUBLIC_TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
name: "classes_list",
|
|
25
|
+
description: "Classes on backtoschool.help: live now, scheduled, drafts or ended. Each with its link, status and what it broadcasts. No sign-in needed (mine=true needs one).",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
status: { type: "string", enum: ["draft", "scheduled", "starting", "live", "encore", "ended", "cancelled", "archived"] },
|
|
30
|
+
mine: { type: "boolean", description: "Only the classes this account hosts." },
|
|
31
|
+
limit: { type: "integer" },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "class_get",
|
|
37
|
+
description: "One class in full, and whether its broadcast is playing right now.",
|
|
38
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
export const SCHOOL_TOOLS = [
|
|
42
|
+
...SCHOOL_PUBLIC_TOOLS,
|
|
43
|
+
{
|
|
44
|
+
name: "class_teach",
|
|
45
|
+
description: "Make a class of a broadcast and put it on the air now: the one call from a station link to a live class. Returns the class link.",
|
|
46
|
+
inputSchema: { type: "object", properties: { broadcastUrl: BROADCAST, ...FIELDS }, required: ["title", "broadcastUrl"] },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "class_create",
|
|
50
|
+
description: "Make a class without starting it: a draft, or scheduled when startsAt is given.",
|
|
51
|
+
inputSchema: { type: "object", properties: { broadcastUrl: BROADCAST, ...FIELDS }, required: ["title"] },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "class_update",
|
|
55
|
+
description: "Change a class you host: title, description, topic, broadcast, visibility, time, chat or raised hands.",
|
|
56
|
+
inputSchema: { type: "object", properties: { slug: SLUG, broadcastUrl: BROADCAST, ...FIELDS }, required: ["slug"] },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "class_start",
|
|
60
|
+
description: "Put a class you host on the air.",
|
|
61
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "class_end",
|
|
65
|
+
description: "End a class you host. It stays listed as ended; a repeating class schedules its next session.",
|
|
66
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "class_delete",
|
|
70
|
+
description: "Delete a class you host, for good.",
|
|
71
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "classes_check",
|
|
75
|
+
description: "Your live classes, each with whether its broadcast still plays. A class stays live until ended, so this finds the ones showing a dead player; end or delete those.",
|
|
76
|
+
inputSchema: { type: "object", properties: {} },
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
export function isSchoolTool(name) {
|
|
80
|
+
return SCHOOL_TOOLS.some((tool) => tool.name === name);
|
|
81
|
+
}
|
|
82
|
+
export function isSchoolPublicTool(name) {
|
|
83
|
+
return SCHOOL_PUBLIC_TOOLS.some((tool) => tool.name === name);
|
|
84
|
+
}
|
|
85
|
+
const say = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
|
|
86
|
+
function line(item, options) {
|
|
87
|
+
return `${item.status.padEnd(9)} ${item.title}\n ${classUrl(item, options)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`;
|
|
88
|
+
}
|
|
89
|
+
function fieldsOf(args) {
|
|
90
|
+
const keys = ["title", "description", "topic", "visibility", "startsAt", "chatEnabled", "handRaiseEnabled", "broadcastUrl"];
|
|
91
|
+
return Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
|
|
92
|
+
}
|
|
93
|
+
export async function callSchoolTool(name, args, options = {}) {
|
|
94
|
+
const slug = typeof args["slug"] === "string" ? args["slug"] : "";
|
|
95
|
+
try {
|
|
96
|
+
if (name === "classes_list") {
|
|
97
|
+
const items = await listClasses({
|
|
98
|
+
...(typeof args["status"] === "string" ? { status: args["status"] } : {}),
|
|
99
|
+
...(args["mine"] === true ? { mine: true } : {}),
|
|
100
|
+
...(typeof args["limit"] === "number" ? { limit: args["limit"] } : {}),
|
|
101
|
+
}, options);
|
|
102
|
+
return say(items.length ? items.map((one) => line(one, options)).join("\n\n") : "No classes match.");
|
|
103
|
+
}
|
|
104
|
+
if (name === "classes_check") {
|
|
105
|
+
const checked = await checkClasses(options);
|
|
106
|
+
if (checked.length === 0)
|
|
107
|
+
return say("You have no live classes.");
|
|
108
|
+
return say(checked.map(({ item, health }) => `${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : "unknown"} ${item.slug}: ${health.reason}`).join("\n"));
|
|
109
|
+
}
|
|
110
|
+
if (!slug && name !== "class_teach" && name !== "class_create")
|
|
111
|
+
return say("Which class? Pass its slug (classes_list).", true);
|
|
112
|
+
if (name === "class_get") {
|
|
113
|
+
const item = await getClass(slug, options);
|
|
114
|
+
const health = await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch);
|
|
115
|
+
return say(`${JSON.stringify(item, null, 2)}\n\nbroadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`);
|
|
116
|
+
}
|
|
117
|
+
if (name === "class_teach") {
|
|
118
|
+
const item = await teach(fieldsOf(args), options);
|
|
119
|
+
return say(`On the air: ${classUrl(item, options)}`);
|
|
120
|
+
}
|
|
121
|
+
if (name === "class_create") {
|
|
122
|
+
const item = await createClass(fieldsOf(args), options);
|
|
123
|
+
return say(`Made (${item.status}): ${classUrl(item, options)}\nclass_start ${item.slug} puts it on the air.`);
|
|
124
|
+
}
|
|
125
|
+
if (name === "class_update") {
|
|
126
|
+
const { slug: _slug, ...rest } = args;
|
|
127
|
+
const item = await updateClass(slug, fieldsOf(rest), options);
|
|
128
|
+
return say(line(item, options));
|
|
129
|
+
}
|
|
130
|
+
if (name === "class_start" || name === "class_end") {
|
|
131
|
+
const item = await moveClass(slug, name === "class_start" ? "start" : "end", options);
|
|
132
|
+
return say(line(item, options));
|
|
133
|
+
}
|
|
134
|
+
if (name === "class_delete") {
|
|
135
|
+
await deleteClass(slug, options);
|
|
136
|
+
return say(`Deleted ${slug}.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return say(error.message, true);
|
|
141
|
+
}
|
|
142
|
+
return say(`No such tool: ${name}`, true);
|
|
143
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { Ingest, normaliseFormat } from "./ingest.js";
|
|
|
30
30
|
import { BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow, } from "./channels.js";
|
|
31
31
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
32
32
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
33
|
+
import { handleSchoolDiscovery, isSchoolSite } from "./school-discovery.js";
|
|
33
34
|
import { RESET_INVALID, RESET_MESSAGE, resendPasswordReset } from "./password-reset.js";
|
|
34
35
|
import { passwordResetPage } from "./password-reset-page.js";
|
|
35
36
|
import { anonymousHandle, Handles } from "./handles.js";
|
|
@@ -3113,6 +3114,15 @@ export function createHandler(engine, options) {
|
|
|
3113
3114
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
3114
3115
|
return;
|
|
3115
3116
|
}
|
|
3117
|
+
// backtoschool.help's own llms.txt, OpenAPI and class MCP, ahead of nixamp's (school-discovery.ts).
|
|
3118
|
+
if (options.accounts && webSite && isSchoolSite(webSite.site)) {
|
|
3119
|
+
const accounts = options.accounts;
|
|
3120
|
+
if (await handleSchoolDiscovery(request, response, path, {
|
|
3121
|
+
site: webSite.site, accountSite: options.site ?? "https://nixamp.com", version: options.version,
|
|
3122
|
+
whoIs: (token) => accounts.whoIs(token), token: tokenFrom(request.headers), readBody, json,
|
|
3123
|
+
}))
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3116
3126
|
// Every way into nixamp, for agents and stores: llms.txt, OpenAPI, health, desktop downloads (discovery.ts).
|
|
3117
3127
|
if (options.accounts && options.site && await handleDiscovery(request, response, path, { site: options.site, version: options.version, release, json }))
|
|
3118
3128
|
return;
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const STATION_TOOLS: ({
|
|
|
2
2
|
name: string;
|
|
3
3
|
description: string;
|
|
4
4
|
inputSchema: {
|
|
5
|
+
required?: undefined;
|
|
5
6
|
type: string;
|
|
6
7
|
properties: {
|
|
7
8
|
server: {
|
|
@@ -72,7 +73,6 @@ export declare const STATION_TOOLS: ({
|
|
|
72
73
|
id?: undefined;
|
|
73
74
|
private?: undefined;
|
|
74
75
|
};
|
|
75
|
-
required?: undefined;
|
|
76
76
|
};
|
|
77
77
|
} | {
|
|
78
78
|
name: string;
|
|
@@ -531,6 +531,7 @@ export declare const STATION_TOOLS: ({
|
|
|
531
531
|
name: string;
|
|
532
532
|
description: string;
|
|
533
533
|
inputSchema: {
|
|
534
|
+
required?: undefined;
|
|
534
535
|
type: string;
|
|
535
536
|
properties: {
|
|
536
537
|
playlist?: undefined;
|
|
@@ -615,12 +616,12 @@ export declare const STATION_TOOLS: ({
|
|
|
615
616
|
id?: undefined;
|
|
616
617
|
private?: undefined;
|
|
617
618
|
};
|
|
618
|
-
required?: undefined;
|
|
619
619
|
};
|
|
620
620
|
} | {
|
|
621
621
|
name: string;
|
|
622
622
|
description: string;
|
|
623
623
|
inputSchema: {
|
|
624
|
+
required?: undefined;
|
|
624
625
|
type: string;
|
|
625
626
|
properties: {
|
|
626
627
|
playlist?: undefined;
|
|
@@ -718,12 +719,12 @@ export declare const STATION_TOOLS: ({
|
|
|
718
719
|
id?: undefined;
|
|
719
720
|
private?: undefined;
|
|
720
721
|
};
|
|
721
|
-
required?: undefined;
|
|
722
722
|
};
|
|
723
723
|
} | {
|
|
724
724
|
name: string;
|
|
725
725
|
description: string;
|
|
726
726
|
inputSchema: {
|
|
727
|
+
required?: undefined;
|
|
727
728
|
type: string;
|
|
728
729
|
properties: {
|
|
729
730
|
playlist?: undefined;
|
|
@@ -862,12 +863,12 @@ export declare const STATION_TOOLS: ({
|
|
|
862
863
|
id?: undefined;
|
|
863
864
|
private?: undefined;
|
|
864
865
|
};
|
|
865
|
-
required?: undefined;
|
|
866
866
|
};
|
|
867
867
|
} | {
|
|
868
868
|
name: string;
|
|
869
869
|
description: string;
|
|
870
870
|
inputSchema: {
|
|
871
|
+
required?: undefined;
|
|
871
872
|
type: string;
|
|
872
873
|
properties: {
|
|
873
874
|
playlist?: undefined;
|
|
@@ -960,12 +961,12 @@ export declare const STATION_TOOLS: ({
|
|
|
960
961
|
license?: undefined;
|
|
961
962
|
private?: undefined;
|
|
962
963
|
};
|
|
963
|
-
required?: undefined;
|
|
964
964
|
};
|
|
965
965
|
} | {
|
|
966
966
|
name: string;
|
|
967
967
|
description: string;
|
|
968
968
|
inputSchema: {
|
|
969
|
+
required?: undefined;
|
|
969
970
|
type: string;
|
|
970
971
|
properties: {
|
|
971
972
|
playlist?: undefined;
|
|
@@ -1060,7 +1061,6 @@ export declare const STATION_TOOLS: ({
|
|
|
1060
1061
|
};
|
|
1061
1062
|
private?: undefined;
|
|
1062
1063
|
};
|
|
1063
|
-
required?: undefined;
|
|
1064
1064
|
};
|
|
1065
1065
|
} | {
|
|
1066
1066
|
name: string;
|