Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/mcp-annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Titles and behaviour hints for every MCP tool nixamp and backtoschool.help
* serve (MCP 2025-06-18 `title` + `annotations`).
*
* The Claude connectors directory and ChatGPT apps both refuse a server whose
* tools do not say, each, whether they only read, whether they can destroy
* something, and whether they reach outside nixamp. Kept in one table rather
* than on each definition so the five tool files stay as they are and a test
* can check that no tool is left out.
*
* r readOnlyHint nothing changes
* d destructiveHint something can be deleted, ended or taken down
* o openWorldHint talks to something outside nixamp's own data
* (another site, a payment, the public, an AI provider)
*/
interface Hints {
title: string;
r: boolean;
d: boolean;
o: boolean;
}

const HINTS: Record<string, Hints> = {
// watch parties, rooms, transcripts (mcp.ts)
watch_parties_list: { title: "List watch parties", r: true, d: false, o: false },
watch_party_get: { title: "Get a watch party", r: true, d: false, o: false },
watch_party_host: { title: "Host a watch party", r: false, d: false, o: true },
watch_party_sync: { title: "Sync watch party playback", r: false, d: false, o: false },
watch_party_end: { title: "End a watch party", r: false, d: true, o: false },
transcribe_audio: { title: "Transcribe audio", r: false, d: false, o: true },
transcript_get: { title: "Get a transcript", r: true, d: false, o: false },
media_hash: { title: "Hash a media file", r: true, d: false, o: true },
media_get: { title: "Look up a media file", r: true, d: false, o: false },
transcripts_list: { title: "List my transcripts", r: true, d: false, o: false },
translate_text: { title: "Translate text", r: false, d: false, o: true },
trollbox_say: { title: "Say something in a room", r: false, d: false, o: true },
transcript_read: { title: "Read a transcript", r: true, d: false, o: false },
profile_get: { title: "Get my room profile", r: true, d: false, o: false },
profile_set: { title: "Set my room profile", r: false, d: false, o: false },
voices_list: { title: "List trollbox voices", r: true, d: false, o: false },
trollbox_read: { title: "Read a room's trollbox", r: true, d: false, o: false },
music_catalog: { title: "Browse the AI label catalogue", r: true, d: false, o: true },
music_buy: { title: "Buy AI label music", r: false, d: false, o: true },
music_purchases: { title: "List my music purchases", r: true, d: false, o: false },
// stations on a server you run (station-mcp.ts)
station_list: { title: "List my stations", r: true, d: false, o: false },
station_get: { title: "Get a station", r: true, d: false, o: false },
station_create: { title: "Create a station", r: false, d: false, o: false },
station_update: { title: "Update a station", r: false, d: false, o: false },
station_control: { title: "Start, stop or skip a station", r: false, d: false, o: false },
station_publish: { title: "Publish a station", r: false, d: false, o: true },
station_auto: { title: "Manage genre stations", r: false, d: false, o: false },
station_ai: { title: "Manage AI radio", r: false, d: false, o: true },
station_label: { title: "Manage the AI label", r: false, d: true, o: true },
station_shares: { title: "Review listener shares", r: false, d: true, o: false },
station_holders: { title: "Manage copyright holders", r: false, d: true, o: false },
station_unpublish: { title: "Unpublish a station", r: false, d: true, o: true },
// the public directory (directory-mcp.ts)
stations_list: { title: "List public stations", r: true, d: false, o: true },
station_find: { title: "Find public stations", r: true, d: false, o: true },
station_share: { title: "Share a track with a station", r: false, d: false, o: true },
// the library on a server you run (library-mcp.ts)
library_status: { title: "Library status", r: true, d: false, o: false },
library_search: { title: "Search the library", r: true, d: false, o: false },
library_browse: { title: "Browse the library", r: true, d: false, o: false },
playlist_list: { title: "List playlists", r: true, d: false, o: false },
playlist_create: { title: "Create a playlist", r: false, d: false, o: false },
playlist_add: { title: "Add to a playlist", r: false, d: false, o: false },
library_scan: { title: "Rescan a library source", r: false, d: false, o: false },
// backtoschool.help classes (school-mcp.ts)
classes_list: { title: "List classes", r: true, d: false, o: false },
class_get: { title: "Get a class", r: true, d: false, o: false },
class_teach: { title: "Teach a class now", r: false, d: false, o: true },
class_create: { title: "Create a class", r: false, d: false, o: false },
class_update: { title: "Update a class", r: false, d: false, o: false },
class_start: { title: "Start a class", r: false, d: false, o: true },
class_end: { title: "End a class", r: false, d: true, o: false },
class_delete: { title: "Delete a class", r: false, d: true, o: false },
classes_check: { title: "Check my live classes", r: true, d: false, o: false },
};

/** Tool names with no entry, for the test that keeps the table complete. */
export function unannotated(tools: ReadonlyArray<{ name: string }>): string[] {
return tools.map((tool) => tool.name).filter((name) => !(name in HINTS));
}

/** The tools as tools/list answers them: each with a title and its hints. */
export function annotate<T extends { name: string }>(tools: ReadonlyArray<T>): Array<T & { title: string; annotations: Record<string, unknown> }> {
return tools.map((tool) => {
const hints = HINTS[tool.name];
const title = hints?.title ?? tool.name;
return {
...tool,
title,
annotations: {
title,
readOnlyHint: hints?.r ?? false,
destructiveHint: hints?.d ?? true,
idempotentHint: false,
openWorldHint: hints?.o ?? true,
},
};
});
}

/**
* The cache fields Claude Code's MCP client requires on every list result
* (all three; one missing rejects the whole list as "tools fetch failed").
*/
export const LIST_CACHE = { resultType: "complete", ttlMs: 300_000, cacheScope: "private" } as const;

/** A tools/list result: annotated tools plus the cache fields. */
export function toolsListResult(tools: ReadonlyArray<{ name: string }>, scope: "public" | "private" = "private"): Record<string, unknown> {
return { tools: annotate(tools), ...LIST_CACHE, cacheScope: scope };
}
3 changes: 2 additions & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { STATION_TOOLS, callStationTool, isStationTool, type StationToolOptions
import { PUBLIC_TOOLS, callPublicTool, isPublicTool, type PublicToolOptions } from "./directory-mcp.ts";
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.ts";
import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.ts";
import { toolsListResult } from "./mcp-annotations.ts";

export const PROTOCOL_VERSION = "2025-06-18";

Expand Down Expand Up @@ -676,7 +677,7 @@ export async function handleMessage(message: Request, options: McpOptions = {}):
if (message.id === undefined || message.id === null) {
if (message.method.startsWith("notifications/")) return null;
}
if (message.method === "tools/list") return reply({ tools: TOOLS });
if (message.method === "tools/list") return reply(toolsListResult(TOOLS));
if (message.method === "ping") return reply({});
if (message.method === "tools/call") {
const name = String(message.params?.["name"] ?? "");
Expand Down
96 changes: 84 additions & 12 deletions src/oauth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*
* /.well-known/oauth-authorization-server what and where everything is
* /api/v1/oauth/authorize the consent page, and the code
* /api/v1/oauth/register RFC 7591: an MCP connector registers
* /api/v1/oauth/token code or refresh -> tokens
* /api/v1/oauth/revoke hand a token back
* /api/v1/oauth/userinfo who the token belongs to
Expand All @@ -23,6 +24,7 @@
* answer the web app, the CLI, the desktop app, the MCP server and the site
* on the other side of the link, and none of them is a special case.
*/
import { randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { tokenFrom, type Account, type Accounts } from "./accounts.ts";
import type { Handles } from "./handles.ts";
Expand Down Expand Up @@ -66,8 +68,13 @@ function json(response: ServerResponse, code: number, body: unknown, headers: Re
response.end(value);
}

function html(response: ServerResponse, code: number, body: string, headers: Record<string, string> = {}): void {
function html(response: ServerResponse, code: number, body: string, headers: Record<string, string> = {}, nonce = ""): void {
response.writeHead(code, {
// The consent page must never be framed: an Allow button under someone
// else's page is a grant they did not mean (clickjacking).
"x-frame-options": "DENY",
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' https: http://localhost:* http://127.0.0.1:*; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'${nonce ? `; script-src 'nonce-${nonce}'` : ""}`,
"referrer-policy": "no-referrer",
...headers,
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(body),
Expand Down Expand Up @@ -146,14 +153,39 @@ export function consentPage(request: AuthorizeRequest, account: Account, handle:
);
}

function signInFirst(url: URL): string {
/**
* Not signed in on this browser yet. The form signs in right here (the same
* /api/v1/auth/login the web app uses, which sets the session cookie) and
* reloads, so an MCP connector's sign-in window comes back to the consent
* page instead of telling the person to find the link again.
*/
export function signInFirst(url: URL, nonce: string, client = ""): string {
const back = `${url.pathname}${url.search}`;
return page(
"Sign in to nixamp",
`<h1>Sign in first</h1>
<p>Something wants to connect to your nixamp account, and nixamp does not
know who you are on this device yet.</p>
<p><a href="/?next=${encodeURIComponent(back)}">Sign in at nixamp.com</a>, then open this link again.</p>`,
<p>${client ? `<b>${escape(client)}</b> wants` : "Something wants"} to connect to your nixamp account.
Sign in, and you will be asked whether to allow it.</p>
<form id="signin">
<input name="email" type="email" autocomplete="username" placeholder="email" required style="font:inherit;width:100%;padding:.5rem;background:#111;color:#cfcfcf;border:1px solid #2a2a2a">
<input name="password" type="password" autocomplete="current-password" placeholder="password" required style="font:inherit;width:100%;padding:.5rem;background:#111;color:#cfcfcf;border:1px solid #2a2a2a">
<button type="submit" class="primary">Sign in</button>
</form>
<p id="said" role="status"></p>
<p>No account, or you use another way in? <a href="/" target="_blank" rel="noopener">Sign in at nixamp.com</a>, then <a href="${escape(back)}">reload this page</a>.</p>
<script nonce="${nonce}">
document.getElementById("signin").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
const said = document.getElementById("said");
said.textContent = "Signing in...";
const answer = await fetch("/api/v1/auth/login", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
body: JSON.stringify({ email: form.get("email"), password: form.get("password") }) }).catch(() => null);
if (answer && answer.ok) { location.reload(); return; }
const body = answer ? await answer.json().catch(() => ({})) : {};
said.textContent = body.error || "Could not sign in.";
});
</script>`,
);
}

Expand Down Expand Up @@ -204,6 +236,9 @@ export function oauthApiPath(path: string): boolean {
return (
path === "/.well-known/oauth-authorization-server" ||
path === "/.well-known/openid-configuration" ||
// The RFC 7591 default an MCP client falls back to when it does not read
// the metadata: it must get JSON, never the web app's HTML.
path === "/register" ||
path.startsWith("/api/v1/oauth/") ||
path === "/api/v1/watch-parties" ||
path.startsWith("/api/v1/watch-parties/")
Expand All @@ -216,8 +251,11 @@ export async function handleOAuthApi(
url: URL,
options: OAuthApiOptions,
): Promise<boolean> {
const path = url.pathname;
if (!oauthApiPath(path)) return false;
if (!oauthApiPath(url.pathname)) return false;
// POST /register is dynamic registration under its default name; a GET is a
// person, and goes on to the web app.
if (url.pathname === "/register" && request.method !== "POST" && request.method !== "OPTIONS") return false;
const path = url.pathname === "/register" ? "/api/v1/oauth/register" : url.pathname;
const server = options.server;

try {
Expand All @@ -236,7 +274,7 @@ export async function handleOAuthApi(
}
const params =
method === "POST" ? new URLSearchParams(await readBody(request)) : new URLSearchParams(url.search);
const checked = server.check(params);
const checked = await server.check(params);
if ("error" in checked) {
// Redirect the refusal only where the redirect URI itself checked out.
if (checked.redirectUri) {
Expand All @@ -245,6 +283,7 @@ export async function handleOAuthApi(
error: checked.error,
error_description: checked.description,
state: checked.state ?? "",
iss: server.issuer,
}),
});
response.end();
Expand All @@ -256,7 +295,8 @@ export async function handleOAuthApi(

const account = await options.accounts.whoIs(tokenFrom(request.headers));
if (account === null) {
html(response, 401, signInFirst(url));
const nonce = randomBytes(16).toString("base64");
html(response, 401, signInFirst(url, nonce, checked.client.name), {}, nonce);
return true;
}

Expand All @@ -272,6 +312,7 @@ export async function handleOAuthApi(
error: "access_denied",
error_description: "the person said not now",
state: checked.state,
iss: server.issuer,
}),
});
response.end();
Expand All @@ -280,13 +321,44 @@ export async function handleOAuthApi(

const code = await server.issueCode(checked, account);
response.writeHead(302, {
location: AuthorizationServer.redirect(checked.redirectUri, { code, state: checked.state }),
// RFC 9207: the issuer rides along, so a client talking to several
// servers can tell which one answered (ChatGPT checks it).
location: AuthorizationServer.redirect(checked.redirectUri, { code, state: checked.state, iss: server.issuer }),
});
response.end();
return true;
}

// --- RFC 7591: an MCP connector registers itself ----------------------
if (path === "/api/v1/oauth/register") {
if (request.method === "OPTIONS") {
response.writeHead(204, CORS);
response.end();
return true;
}
if (request.method !== "POST") {
json(response, 405, { error: "invalid_request", error_description: "POST the client metadata as JSON" });
return true;
}
let body: unknown;
try {
body = JSON.parse(await readBody(request));
} catch {
body = null;
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new OAuthError("invalid_client_metadata", "send the client metadata as a JSON object");
}
json(response, 201, await server.register(body as Record<string, unknown>));
return true;
}

// --- the token endpoint ----------------------------------------------
if ((path === "/api/v1/oauth/token" || path === "/api/v1/oauth/revoke") && request.method === "OPTIONS") {
response.writeHead(204, CORS);
response.end();
return true;
}
if (path === "/api/v1/oauth/token") {
if (request.method !== "POST") {
json(response, 405, { error: "invalid_request", error_description: "POST only" });
Expand All @@ -296,7 +368,7 @@ export async function handleOAuthApi(
const authorization = Array.isArray(request.headers["authorization"])
? request.headers["authorization"][0]
: request.headers["authorization"];
const client = server.authenticateClient(form, authorization);
const client = await server.authenticateClient(form, authorization);
const grantType = form.get("grant_type") ?? "";
if (grantType === "authorization_code") {
json(response, 200, await server.exchangeCode(client, form));
Expand Down Expand Up @@ -324,7 +396,7 @@ export async function handleOAuthApi(
const authorization = Array.isArray(request.headers["authorization"])
? request.headers["authorization"][0]
: request.headers["authorization"];
const client = server.authenticateClient(form, authorization);
const client = await server.authenticateClient(form, authorization);
await server.revoke(client, form.get("token") ?? "");
// RFC 7009: a token that was never valid is the same answer as one that
// just stopped being. Saying which would be a way to test tokens.
Expand Down
Loading
Loading