From 438b3dd4b286190cdf5835c6d99feaa60e633862 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 11 Nov 2026 21:47:13 +0000 Subject: [PATCH] feat(oauth): dynamic client registration so claude.ai and ChatGPT can connect to /mcp nixamp.com/mcp and backtoschool.help/mcp already used OAuth 2.1 + PKCE S256 with nixamp.com as the authorization server, but only built-in clients could sign in, so neither server could be added as a Claude or ChatGPT connector. - RFC 7591 registration at /api/v1/oauth/register (and POST /register, the default path, which now answers JSON instead of falling through to the web app): public clients only (token_endpoint_auth_method none), https redirects or http on localhost/127.0.0.1 with any port. Stored in nixamp_oauth_clients (created on first use, like the other OAuth tables). - AS metadata advertises registration_endpoint and authorization_response_iss_parameter_supported; every authorization response (code, error, access_denied) carries iss (RFC 9207). - A registered connector asking for no scope gets profile parties streams stations offline_access, and always a rotating refresh token (claude.ai and ChatGPT never ask for offline_access). Access tokens stay 1 h. - Protected-resource metadata: `resource` is now exactly the MCP URL (https://nixamp.com/mcp, https://backtoschool.help/mcp), also served at /.well-known/oauth-protected-resource/mcp. - Consent: not signed in now signs in on the page itself and reloads to the consent screen, which names the client; the pages refuse framing. - Every tool (nixamp, public directory, school) has a title and readOnly/destructive/openWorld hints; tools/list carries resultType, ttlMs and cacheScope. Existing nixamp tokens (CLI, session) and the built-in bittorrented and backtoschool clients work as before. Co-Authored-By: Claude Opus 5.5 --- src/mcp-annotations.ts | 115 +++++++++++++++++++ src/mcp.ts | 3 +- src/oauth-api.ts | 96 ++++++++++++++-- src/oauth-server.ts | 153 ++++++++++++++++++++++++-- src/school-discovery.ts | 10 +- src/server.ts | 9 +- test/mcp-annotations.test.ts | 19 ++++ test/oauth-server.test.ts | 195 ++++++++++++++++++++++++++++++++- test/school.test.ts | 11 +- test/transcript-routes.test.ts | 2 +- 10 files changed, 581 insertions(+), 32 deletions(-) create mode 100644 src/mcp-annotations.ts create mode 100644 test/mcp-annotations.test.ts diff --git a/src/mcp-annotations.ts b/src/mcp-annotations.ts new file mode 100644 index 0000000..eae404b --- /dev/null +++ b/src/mcp-annotations.ts @@ -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 = { + // 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(tools: ReadonlyArray): Array }> { + 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 { + return { tools: annotate(tools), ...LIST_CACHE, cacheScope: scope }; +} diff --git a/src/mcp.ts b/src/mcp.ts index 511b98d..ba64d3f 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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"; @@ -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"] ?? ""); diff --git a/src/oauth-api.ts b/src/oauth-api.ts index 255b260..afc3e8a 100644 --- a/src/oauth-api.ts +++ b/src/oauth-api.ts @@ -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 @@ -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"; @@ -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 = {}): void { +function html(response: ServerResponse, code: number, body: string, headers: Record = {}, 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), @@ -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", `

Sign in first

-

Something wants to connect to your nixamp account, and nixamp does not - know who you are on this device yet.

-

Sign in at nixamp.com, then open this link again.

`, +

${client ? `${escape(client)} wants` : "Something wants"} to connect to your nixamp account. + Sign in, and you will be asked whether to allow it.

+
+ + + +
+

+

No account, or you use another way in? Sign in at nixamp.com, then reload this page.

+ `, ); } @@ -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/") @@ -216,8 +251,11 @@ export async function handleOAuthApi( url: URL, options: OAuthApiOptions, ): Promise { - 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 { @@ -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) { @@ -245,6 +283,7 @@ export async function handleOAuthApi( error: checked.error, error_description: checked.description, state: checked.state ?? "", + iss: server.issuer, }), }); response.end(); @@ -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; } @@ -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(); @@ -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)); + 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" }); @@ -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)); @@ -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. diff --git a/src/oauth-server.ts b/src/oauth-server.ts index d9e2111..5885bdd 100644 --- a/src/oauth-server.ts +++ b/src/oauth-server.ts @@ -45,6 +45,13 @@ export interface OAuthClient { secretHash?: string; /** Where the client lives, shown as a link on the consent page. */ homepage?: string; + /** + * Registered over RFC 7591 (an MCP connector: claude.ai, ChatGPT, Claude + * Code) rather than built in. Such a client is public, gets a refresh token + * without having to know to ask for offline_access, and is never trusted + * for anything a built-in client is (watch links on its hosts, say). + */ + dynamic?: boolean; } /** What a client may ask for, and what each word means to the rest of nixamp. */ @@ -168,6 +175,23 @@ export function redirectAllowed(client: OAuthClient, candidate: string): boolean }); } +/** What a registered client id looks like: `mcp_` and 32 hex digits. */ +const DYNAMIC_ID = /^mcp_[0-9a-f]{32}$/; + +/** May a connector register this redirect? https anywhere, or http on loopback. */ +export function registrableRedirect(value: unknown): value is string { + if (typeof value !== "string" || value.length > 2000) return false; + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.hash || url.username || url.password) return false; + if (url.protocol === "https:") return true; + return loopback(url); +} + // --- PKCE ---------------------------------------------------------------------- /** RFC 7636 §4.1: 43 to 128 of the unreserved characters. */ @@ -209,8 +233,15 @@ export interface AuthorizeRefusal { state?: string; } -export function parseScope(value: unknown): Scope[] | null { - const words = typeof value === "string" && value.trim() !== "" ? value.trim().split(/\s+/) : ["profile"]; +/** + * What a registered MCP connector gets when it asks for no scope, which is + * what claude.ai and ChatGPT do: everything the MCP tools need, and a refresh + * token. Not `email`: an address is a credential and a connector must ask. + */ +export const CONNECTOR_SCOPE: Scope[] = ["profile", "parties", "streams", "stations", "offline_access"]; + +export function parseScope(value: unknown, fallback: readonly Scope[] = ["profile"]): Scope[] | null { + const words = typeof value === "string" && value.trim() !== "" ? value.trim().split(/\s+/) : [...fallback]; const chosen: Scope[] = []; for (const word of words) { if (!SCOPE_NAMES.includes(word as Scope)) return null; @@ -232,6 +263,7 @@ export class OAuthError extends Error { // --- storage ------------------------------------------------------------------- const CODES = "nixamp_oauth_codes"; +const CLIENTS = "nixamp_oauth_clients"; const REFRESH = "nixamp_oauth_refresh"; const SCHEMA = ` @@ -264,6 +296,14 @@ const SCHEMA = ` ); CREATE INDEX IF NOT EXISTS ${REFRESH}_family ON ${REFRESH} (family); CREATE INDEX IF NOT EXISTS ${REFRESH}_user ON ${REFRESH} (user_id); + CREATE TABLE IF NOT EXISTS ${CLIENTS} ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + client_uri TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ + ); `; export const REFRESH_PREFIX = "nxr_"; @@ -335,21 +375,109 @@ export class AuthorizationServer { await this.ready; } + /** A built-in (or NIXAMP_OAUTH_CLIENTS) client. Never a registered one. */ client(id: unknown): OAuthClient | null { return typeof id === "string" ? (this.clients.find((one) => one.id === id) ?? null) : null; } + /** Any client: built in first, then one registered over RFC 7591. */ + async findClient(id: unknown): Promise { + const known = this.client(id); + if (known) return known; + if (typeof id !== "string" || !DYNAMIC_ID.test(id)) return null; + await this.ensure(); + const { rows } = await this.options.db.query( + `SELECT client_id, client_name, redirect_uris, client_uri FROM ${CLIENTS} WHERE client_id = $1`, + [id], + ); + const row = rows[0]; + if (!row) return null; + let uris: unknown = []; + try { + uris = JSON.parse(String(row["redirect_uris"] ?? "[]")); + } catch { + uris = []; + } + return { + id: String(row["client_id"]), + name: String(row["client_name"] ?? id), + redirectUris: Array.isArray(uris) ? uris.filter((one): one is string => typeof one === "string") : [], + ...(row["client_uri"] ? { homepage: String(row["client_uri"]) } : {}), + dynamic: true, + }; + } + + /** + * RFC 7591 dynamic client registration, for MCP connectors. Public clients + * only: there is no secret to hand out, PKCE is what protects the code. + * Redirects are https (no fragment, no credentials) or http on a loopback + * address (RFC 8252), whose port may then change on every sign-in. + */ + async register(body: Record): Promise> { + const uris = body["redirect_uris"]; + if (!Array.isArray(uris) || uris.length === 0 || uris.length > 20) { + throw new OAuthError("invalid_redirect_uri", "redirect_uris must list 1 to 20 URIs"); + } + for (const uri of uris) { + if (!registrableRedirect(uri)) { + throw new OAuthError("invalid_redirect_uri", `not allowed: ${String(uri).slice(0, 200)} (https, or http on localhost/127.0.0.1)`); + } + } + const method = body["token_endpoint_auth_method"] ?? "none"; + if (method !== "none") { + throw new OAuthError("invalid_client_metadata", "only public clients may register: token_endpoint_auth_method must be none"); + } + const grants = Array.isArray(body["grant_types"]) ? body["grant_types"].map(String) : ["authorization_code", "refresh_token"]; + if (grants.some((one) => one !== "authorization_code" && one !== "refresh_token")) { + throw new OAuthError("invalid_client_metadata", "grant_types may only be authorization_code and refresh_token"); + } + const types = Array.isArray(body["response_types"]) ? body["response_types"].map(String) : ["code"]; + if (types.some((one) => one !== "code")) throw new OAuthError("invalid_client_metadata", "response_types may only be code"); + const name = typeof body["client_name"] === "string" + ? body["client_name"].replace(/[\u0000-\u001f\u007f<>]/g, "").trim().slice(0, 100) + : ""; + let homepage: string | null = null; + if (typeof body["client_uri"] === "string") { + try { + if (new URL(body["client_uri"]).protocol === "https:") homepage = body["client_uri"].slice(0, 500); + } catch { + homepage = null; + } + } + await this.ensure(); + const id = `mcp_${randomBytes(16).toString("hex")}`; + const redirectUris = [...new Set(uris as string[])]; + const at = this.now(); + await this.options.db.query( + `INSERT INTO ${CLIENTS} (client_id, client_name, redirect_uris, client_uri, created_at) VALUES ($1, $2, $3, $4, $5)`, + [id, name || "MCP client", JSON.stringify(redirectUris), homepage, new Date(at).toISOString()], + ); + return { + client_id: id, + client_id_issued_at: Math.floor(at / 1000), + client_name: name || "MCP client", + redirect_uris: redirectUris, + ...(homepage ? { client_uri: homepage } : {}), + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: CONNECTOR_SCOPE.join(" "), + }; + } + /** RFC 8414. Everything a client needs to find, in the place it looks. */ metadata(): Record { return { issuer: this.issuer, authorization_endpoint: `${this.issuer}/api/v1/oauth/authorize`, token_endpoint: `${this.issuer}/api/v1/oauth/token`, + registration_endpoint: `${this.issuer}/api/v1/oauth/register`, revocation_endpoint: `${this.issuer}/api/v1/oauth/revoke`, userinfo_endpoint: `${this.issuer}/api/v1/oauth/userinfo`, scopes_supported: SCOPE_NAMES, response_types_supported: ["code"], response_modes_supported: ["query"], + authorization_response_iss_parameter_supported: true, grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], @@ -363,8 +491,8 @@ export class AuthorizationServer { * The order matters: the client and redirect URI are checked first, and a * problem with either is answered to the browser, never to the URI. */ - check(params: URLSearchParams): AuthorizeRequest | AuthorizeRefusal { - const client = this.client(params.get("client_id")); + async check(params: URLSearchParams): Promise { + const client = await this.findClient(params.get("client_id")); if (client === null) return { error: "invalid_client", description: "unknown client_id" }; const redirectUri = params.get("redirect_uri") ?? ""; if (!redirectUri || !redirectAllowed(client, redirectUri)) { @@ -380,7 +508,7 @@ export class AuthorizationServer { if (method !== "S256" || !CHALLENGE.test(codeChallenge)) { return refuse("invalid_request", "code_challenge with code_challenge_method=S256 is required"); } - const scope = parseScope(params.get("scope")); + const scope = parseScope(params.get("scope"), client.dynamic ? CONNECTOR_SCOPE : ["profile"]); if (scope === null) return refuse("invalid_scope", `scope may name ${SCOPE_NAMES.join(", ")}`); return { client, redirectUri, scope, state, codeChallenge }; } @@ -423,7 +551,7 @@ export class AuthorizationServer { * its secret, in the body or as basic auth; a public client must not * present one it does not have. Either way the client named must exist. */ - authenticateClient(form: URLSearchParams, authorization: string | undefined): OAuthClient { + async authenticateClient(form: URLSearchParams, authorization: string | undefined): Promise { let id = form.get("client_id") ?? ""; let secret = form.get("client_secret") ?? ""; const basic = /^Basic\s+(.+)$/i.exec(authorization ?? "")?.[1]; @@ -435,7 +563,7 @@ export class AuthorizationServer { secret = decodeURIComponent(decoded.slice(cut + 1)); } } - const client = this.client(id); + const client = await this.findClient(id); if (client === null) throw new OAuthError("invalid_client", "unknown client_id", 401); if (client.secretHash) { if (!secret || !sameHash(client.secretHash, hashSecret(secret))) { @@ -477,7 +605,9 @@ export class AuthorizationServer { expires_in: Math.round(ACCESS_TTL_MS / 1000), scope, }; - if (words.includes("offline_access")) { + // A registered connector always gets one: claude.ai and ChatGPT never ask + // for offline_access, and an hourly re-consent would be the result. + if (words.includes("offline_access") || client.dynamic) { const refresh = mintRefresh(); const at = this.now(); await this.options.db.query( @@ -652,12 +782,17 @@ export class AuthorizationServer { ORDER BY created_at DESC`, [userId], ); + const names = new Map(); + for (const row of rows) { + const clientId = String(row["client_id"] ?? ""); + if (!names.has(clientId)) names.set(clientId, (await this.findClient(clientId))?.name ?? clientId); + } return rows.map((row) => { const clientId = String(row["client_id"] ?? ""); return { id: String(row["id"] ?? ""), clientId, - clientName: this.client(clientId)?.name ?? clientId, + clientName: names.get(clientId) ?? clientId, scope: String(row["scope"] ?? ""), createdAt: asTime(row["created_at"]) ?? 0, }; diff --git a/src/school-discovery.ts b/src/school-discovery.ts index 03f4a97..25da30c 100644 --- a/src/school-discovery.ts +++ b/src/school-discovery.ts @@ -15,6 +15,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { SCHOOL_PUBLIC_TOOLS, SCHOOL_TOOLS, callSchoolTool, isSchoolPublicTool, isSchoolTool } from "./school-mcp.ts"; import { SCHOOL_SITE } from "./school-client.ts"; +import { toolsListResult } from "./mcp-annotations.ts"; export interface SchoolDiscoveryContext { /** This school's origin, e.g. https://backtoschool.help. */ @@ -35,7 +36,8 @@ const CORS = { "access-control-allow-headers": "authorization, content-type, mcp-session-id, mcp-protocol-version", }; -const PATHS = new Set(["/llms.txt", "/openapi.json", "/mcp", "/mcp/public", "/.well-known/oauth-protected-resource"]); +const PRM = "/.well-known/oauth-protected-resource"; +const PATHS = new Set(["/llms.txt", "/openapi.json", "/mcp", "/mcp/public", PRM, `${PRM}/mcp`]); /** Is this host the school? backtoschool.help (or NIXAMP_SCHOOL_URL), with or without www. */ export function isSchoolSite(site: string | undefined, env: NodeJS.ProcessEnv = process.env): boolean { @@ -141,13 +143,13 @@ export async function handleSchoolDiscovery(request: IncomingMessage, response: response.end(); return true; } - if (path === "/llms.txt" || path === "/openapi.json" || path === "/.well-known/oauth-protected-resource") { + if (path === "/llms.txt" || path === "/openapi.json" || path === PRM || path === `${PRM}/mcp`) { if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "GET only" }); return true; } const [type, body] = path === "/llms.txt" ? ["text/plain; charset=utf-8", schoolLlmsTxt(site, ctx.accountSite)] : ["application/json; charset=utf-8", JSON.stringify(path === "/openapi.json" ? schoolOpenapi(site, ctx.accountSite, ctx.version) - : { resource: site, authorization_servers: [ctx.accountSite], bearer_methods_supported: ["header"], resource_name: "BackToSchool.help" }, null, 2)]; + : { resource: `${site}/mcp`, authorization_servers: [ctx.accountSite], bearer_methods_supported: ["header"], resource_name: "BackToSchool.help" }, null, 2)]; response.writeHead(200, { ...CORS, "content-type": type, "cache-control": "public, max-age=300" }); response.end(method === "HEAD" ? undefined : body); return true; @@ -193,7 +195,7 @@ export async function handleSchoolDiscovery(request: IncomingMessage, response: } else if (name === "ping") { answers.push({ jsonrpc: "2.0", id, result: {} }); } else if (name === "tools/list") { - answers.push({ jsonrpc: "2.0", id, result: { tools } }); + answers.push({ jsonrpc: "2.0", id, result: toolsListResult(tools, signed ? "private" : "public") }); } else if (name === "tools/call" && allowed(String(message.params?.["name"] ?? ""))) { const result = await callSchoolTool(String(message.params!["name"]), (message.params?.["arguments"] ?? {}) as Record, { site, token: ctx.token || null, ...(ctx.fetcher ? { fetcher: ctx.fetcher } : {}), diff --git a/src/server.ts b/src/server.ts index 533a79e..5640715 100644 --- a/src/server.ts +++ b/src/server.ts @@ -65,6 +65,7 @@ import { } from "./oauth.ts"; import { AuthorizationServer, BACKTOSCHOOL_CLIENT, SCOPE_NAMES, clientsFrom } from "./oauth-server.ts"; import { handleOAuthApi, oauthApiPath } from "./oauth-api.ts"; +import { toolsListResult } from "./mcp-annotations.ts"; import { WatchParties } from "./watch-party.ts"; import { needsAdmin, needsMember, Owner } from "./owner.ts"; import { createHash as sha } from "node:crypto"; @@ -4737,7 +4738,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) { } else if (method === "ping") { answers.push({ jsonrpc: "2.0", id, result: {} }); } else if (method === "tools/list") { - answers.push({ jsonrpc: "2.0", id, result: { tools: PUBLIC_TOOLS } }); + answers.push({ jsonrpc: "2.0", id, result: toolsListResult(PUBLIC_TOOLS, "public") }); } else if (method === "tools/call" && isPublicTool(String(message.params?.["name"] ?? ""))) { answers.push({ jsonrpc: "2.0", id, result: await callPublicTool(String(message.params!["name"]), (message.params?.["arguments"] ?? {}) as Record, { guide: stationGuide }) }); } else { @@ -4820,9 +4821,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) { } /* Where a client of /mcp finds the authorization server (RFC 9728). */ - if (path === "/.well-known/oauth-protected-resource" && options.site && options.authServer) { + // `resource` is the MCP URL itself (RFC 9728 §3.3: a client checks it equals + // the URL it was given), also at the path-suffixed form clients try first. + if ((path === "/.well-known/oauth-protected-resource" || path === "/.well-known/oauth-protected-resource/mcp") && options.site && options.authServer) { json(response, 200, { - resource: options.site, + resource: `${options.site.replace(/\/+$/, "")}/mcp`, authorization_servers: [options.site], bearer_methods_supported: ["header"], scopes_supported: SCOPE_NAMES, diff --git a/test/mcp-annotations.test.ts b/test/mcp-annotations.test.ts new file mode 100644 index 0000000..596d54a --- /dev/null +++ b/test/mcp-annotations.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { annotate, unannotated } from "../src/mcp-annotations.ts"; +import { TOOLS } from "../src/mcp.ts"; +import { PUBLIC_TOOLS } from "../src/directory-mcp.ts"; +import { SCHOOL_PUBLIC_TOOLS, SCHOOL_TOOLS } from "../src/school-mcp.ts"; + +test("every tool nixamp or the school serves has a title and its hints", () => { + assert.deepEqual(unannotated([...TOOLS, ...PUBLIC_TOOLS, ...SCHOOL_TOOLS, ...SCHOOL_PUBLIC_TOOLS]), []); +}); + +test("deleting and ending are destructive, reading is read-only", () => { + const byName = new Map(annotate(TOOLS).map((tool) => [tool.name, tool.annotations])); + assert.equal(byName.get("class_delete")?.["destructiveHint"], true); + assert.equal(byName.get("class_delete")?.["readOnlyHint"], false); + assert.equal(byName.get("watch_party_end")?.["destructiveHint"], true); + assert.equal(byName.get("classes_list")?.["readOnlyHint"], true); + assert.equal(byName.get("music_buy")?.["openWorldHint"], true); +}); diff --git a/test/oauth-server.test.ts b/test/oauth-server.test.ts index d524cfb..81e4d71 100644 --- a/test/oauth-server.test.ts +++ b/test/oauth-server.test.ts @@ -27,6 +27,7 @@ import { cleanPartyCode, cleanPartyUrl, WatchParties } from "../src/watch-party. */ function fakeDb(): Queryable & { events: Map> } { const codes = new Map>(); + const clients = new Map>(); const refresh = new Map>(); const tokens = new Map>(); const parties = new Map>(); @@ -53,6 +54,17 @@ function fakeDb(): Queryable & { events: Map> } } if (sql.startsWith("UPDATE nixamp_tokens SET last_used_at")) return { rows: [] }; + // --- nixamp_oauth_clients (RFC 7591) ----------------------------------- + if (sql.startsWith("INSERT INTO nixamp_oauth_clients")) { + const [client_id, client_name, redirect_uris, client_uri, created_at] = values; + clients.set(String(client_id), { client_id, client_name, redirect_uris, client_uri, created_at }); + return { rows: [] }; + } + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_oauth_clients WHERE client_id = $1")) { + const row = clients.get(String(values[0])); + return { rows: row ? [row] : [] }; + } + // --- nixamp_oauth_codes ----------------------------------------------- if (sql.startsWith("INSERT INTO nixamp_oauth_codes")) { const [code_hash, client_id, user_id, email, redirect_uri, scope, code_challenge, family, created_at, expires_at] = values; @@ -255,7 +267,7 @@ interface Harness { session: string; } -async function withServer(run: (harness: Harness) => Promise): Promise { +async function withServer(run: (harness: Harness) => Promise, extra: { site?: string } = {}): Promise { const db = fakeDb(); const accounts = fakeAccounts(db); const authServer = new AuthorizationServer({ @@ -280,6 +292,7 @@ async function withServer(run: (harness: Harness) => Promise): Promise((done) => http.listen(0, "127.0.0.1", done)); const { port } = http.address() as AddressInfo; @@ -749,3 +762,183 @@ test("an account can see what it connected, and take it away", async () => { assert.equal(await harness.accounts.whoIs(token), null); }); }); + +// --- MCP connectors: RFC 7591 registration (claude.ai, ChatGPT, Claude Code) --- + +const CLAUDE = "https://claude.ai/api/mcp/auth_callback"; +const CHATGPT = "https://chatgpt.com/connector_platform_oauth_redirect"; + +async function register(harness: Harness, body: unknown, path = "/api/v1/oauth/register"): Promise<{ status: number; body: Record; type: string }> { + const answer = await fetch(`${harness.base}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: answer.status, body: (await answer.json()) as Record, type: answer.headers.get("content-type") ?? "" }; +} + +test("the metadata advertises registration, S256 only, refresh, and the iss parameter", async () => { + await withServer(async (harness) => { + const meta = (await (await fetch(`${harness.base}/.well-known/oauth-authorization-server`)).json()) as Record; + assert.equal(meta["registration_endpoint"], "https://nixamp.test/api/v1/oauth/register"); + assert.deepEqual(meta["code_challenge_methods_supported"], ["S256"]); + assert.deepEqual(meta["response_types_supported"], ["code"]); + assert.deepEqual(meta["grant_types_supported"], ["authorization_code", "refresh_token"]); + assert.ok((meta["token_endpoint_auth_methods_supported"] as string[]).includes("none")); + assert.equal(meta["authorization_response_iss_parameter_supported"], true); + }); +}); + +test("a connector registers as a public client, with the redirects claude.ai, ChatGPT and a CLI use", async () => { + await withServer(async (harness) => { + const made = await register(harness, { + client_name: "Claude", + redirect_uris: [CLAUDE, CHATGPT, "http://localhost:33418/callback", "http://127.0.0.1:6274/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }); + assert.equal(made.status, 201); + assert.match(String(made.body["client_id"]), /^mcp_[0-9a-f]{32}$/); + assert.equal(made.body["token_endpoint_auth_method"], "none"); + assert.equal(made.body["client_secret"], undefined); + + // Refused: a confidential client, a plain-http host, a fragment, nothing at all. + assert.equal((await register(harness, { redirect_uris: [CLAUDE], token_endpoint_auth_method: "client_secret_basic" })).body["error"], "invalid_client_metadata"); + assert.equal((await register(harness, { redirect_uris: ["http://evil.example/callback"] })).body["error"], "invalid_redirect_uri"); + assert.equal((await register(harness, { redirect_uris: ["https://x.example/cb#frag"] })).body["error"], "invalid_redirect_uri"); + assert.equal((await register(harness, { redirect_uris: [] })).status, 400); + assert.equal((await register(harness, { redirect_uris: [CLAUDE], grant_types: ["password"] })).body["error"], "invalid_client_metadata"); + + // The RFC 7591 default path answers JSON too, never the web app's page. + const fallback = await register(harness, { redirect_uris: [CLAUDE] }, "/register"); + assert.equal(fallback.status, 201); + assert.match(fallback.type, /application\/json/); + }); +}); + +test("a registered connector: consent, code with iss, form-encoded exchange, refresh without asking, then /mcp", async () => { + await withServer(async (harness) => { + const made = await register(harness, { client_name: "Claude", redirect_uris: [CLAUDE, "http://localhost/callback"] }); + const clientId = String(made.body["client_id"]); + const verifier = randomBytes(32).toString("base64url"); + const asked = new URLSearchParams({ + response_type: "code", + client_id: clientId, + // A loopback redirect may come back on any port. + redirect_uri: "http://localhost:51234/callback", + state: "s1", + code_challenge: challengeFor(verifier), + code_challenge_method: "S256", + resource: "https://nixamp.test/mcp", + }); + + // Not signed in: a page that signs in here, unframeable, naming the client. + const anonymous = await fetch(`${harness.base}/api/v1/oauth/authorize?${asked}`); + assert.equal(anonymous.status, 401); + const signIn = await anonymous.text(); + assert.match(signIn, /Claude/); + assert.match(signIn, /\/api\/v1\/auth\/login/); + assert.match(anonymous.headers.get("content-security-policy") ?? "", /frame-ancestors 'none'/); + + // Signed in: the consent page names the client. + const consent = await fetch(`${harness.base}/api/v1/oauth/authorize?${asked}`, { headers: { authorization: `Bearer ${harness.session}` } }); + assert.equal(consent.status, 200); + assert.match(await consent.text(), /Connect Claude/); + + const allowed = await fetch(`${harness.base}/api/v1/oauth/authorize`, { + method: "POST", + redirect: "manual", + headers: { authorization: `Bearer ${harness.session}`, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ ...Object.fromEntries(asked), decision: "allow" }).toString(), + }); + const back = new URL(allowed.headers.get("location") ?? ""); + assert.equal(back.origin + back.pathname, "http://localhost:51234/callback"); + assert.equal(back.searchParams.get("state"), "s1"); + assert.equal(back.searchParams.get("iss"), "https://nixamp.test"); + const code = back.searchParams.get("code") ?? ""; + + const answer = await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "authorization_code", client_id: clientId, redirect_uri: "http://localhost:51234/callback", code, code_verifier: verifier }).toString(), + }); + assert.equal(answer.status, 200); + const granted = (await answer.json()) as Record; + assert.ok(granted["access_token"]?.startsWith("nxa_")); + // claude.ai never asks for offline_access; a connector gets a refresh token anyway. + assert.ok(granted["refresh_token"]?.startsWith("nxr_")); + assert.equal(granted["scope"], "profile parties streams stations offline_access"); + assert.equal(Number(granted["expires_in"]), 3600); + assert.equal((await harness.accounts.whoIs(granted["access_token"]!))?.id, "host-1"); + + const refreshed = await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: granted["refresh_token"]! }).toString(), + }); + assert.equal(refreshed.status, 200); + const next = (await refreshed.json()) as Record; + assert.notEqual(next["refresh_token"], granted["refresh_token"]); + + // The connections list names the registered client. + const connections = (await (await fetch(`${harness.base}/api/v1/oauth/connections`, { headers: { authorization: `Bearer ${harness.session}` } })).json()) as { connections: { clientName: string }[] }; + assert.ok(connections.connections.some((one) => one.clientName === "Claude")); + + // An unknown client is a page, never a redirect. + const unknown = await fetch(`${harness.base}/api/v1/oauth/authorize?${new URLSearchParams({ ...Object.fromEntries(asked), client_id: "mcp_00000000000000000000000000000000" })}`, { redirect: "manual", headers: { authorization: `Bearer ${harness.session}` } }); + assert.equal(unknown.status, 400); + }); +}); + +test("/mcp: 401 pointing at metadata whose resource is the MCP URL; OAuth token and the old nixamp token both list annotated tools", async () => { + await withServer(async (harness) => { + const call = (token?: string) => fetch(`${harness.base}/mcp`, { + method: "POST", + headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + const refused = await call(); + assert.equal(refused.status, 401); + assert.equal(refused.headers.get("www-authenticate"), 'Bearer resource_metadata="https://nixamp.test/.well-known/oauth-protected-resource"'); + for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) { + const prm = (await (await fetch(`${harness.base}${path}`)).json()) as { resource: string; authorization_servers: string[] }; + assert.equal(prm.resource, "https://nixamp.test/mcp"); + assert.equal(prm.authorization_servers[0], "https://nixamp.test"); + } + + // The token a person already had (CLI or session) still works. + const old = await call(harness.session); + assert.equal(old.status, 200); + + const made = await register(harness, { client_name: "ChatGPT", redirect_uris: [CHATGPT] }); + const clientId = String(made.body["client_id"]); + const verifier = randomBytes(32).toString("base64url"); + const allowed = await fetch(`${harness.base}/api/v1/oauth/authorize`, { + method: "POST", + redirect: "manual", + headers: { authorization: `Bearer ${harness.session}`, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: CHATGPT, state: "z", code_challenge: challengeFor(verifier), code_challenge_method: "S256", decision: "allow" }).toString(), + }); + const code = new URL(allowed.headers.get("location") ?? "").searchParams.get("code") ?? ""; + const granted = (await (await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "authorization_code", client_id: clientId, redirect_uri: CHATGPT, code, code_verifier: verifier }).toString(), + })).json()) as Record; + + const listed = await call(granted["access_token"]); + assert.equal(listed.status, 200); + const result = ((await listed.json()) as { result: { tools: { name: string; title?: string; annotations?: Record }[]; ttlMs?: number; cacheScope?: string; resultType?: string } }).result; + assert.ok(result.tools.length >= 40); + assert.equal(result.resultType, "complete"); + assert.equal(typeof result.ttlMs, "number"); + assert.equal(result.cacheScope, "private"); + for (const tool of result.tools) { + assert.ok(tool.title, `${tool.name} has a title`); + assert.equal(typeof tool.annotations?.["readOnlyHint"], "boolean", `${tool.name} readOnlyHint`); + assert.equal(typeof tool.annotations?.["destructiveHint"], "boolean", `${tool.name} destructiveHint`); + assert.equal(typeof tool.annotations?.["openWorldHint"], "boolean", `${tool.name} openWorldHint`); + } + }, { site: "https://nixamp.test" }); +}); diff --git a/test/school.test.ts b/test/school.test.ts index 76853fe..abbb11e 100644 --- a/test/school.test.ts +++ b/test/school.test.ts @@ -160,8 +160,17 @@ test("the school's host has its own llms.txt, OpenAPI and MCP; nixamp.com keeps const anonymous = await rpc("/mcp", { jsonrpc: "2.0", id: 3, method: "tools/list" }); assert.equal(anonymous.status, 401); assert.match(anonymous.headers.get("www-authenticate") ?? "", /oauth-protected-resource/); - const all = await (await rpc("/mcp", { jsonrpc: "2.0", id: 4, method: "tools/list" }, TOKEN)).json() as { result: { tools: unknown[] } }; + // RFC 9728: `resource` is exactly the MCP URL, at both discovery paths, and nixamp.com is the AS. + for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) { + const prm = await (await fetch(`${base}${path}`)).json() as { resource: string; authorization_servers: string[] }; + assert.equal(prm.resource, `${site.replace(/\/+$/, "")}/mcp`); + assert.deepEqual(prm.authorization_servers, ["https://nixamp.com"]); + } + const all = await (await rpc("/mcp", { jsonrpc: "2.0", id: 4, method: "tools/list" }, TOKEN)).json() as { result: { tools: Array<{ title?: string; annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean } }>; ttlMs?: number; cacheScope?: string } }; assert.equal(all.result.tools.length, SCHOOL_TOOLS.length); + assert.ok(all.result.tools.every((one) => one.title && typeof one.annotations?.readOnlyHint === "boolean" && typeof one.annotations?.destructiveHint === "boolean")); + assert.equal(all.result.cacheScope, "private"); + assert.equal(typeof all.result.ttlMs, "number"); const taught = await (await rpc("/mcp", { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "class_create", arguments: { title: "Draft one" } } }, TOKEN)).json() as { result: { content: Array<{ text: string }> } }; assert.match(taught.result.content[0]!.text, /Made \(draft\)/); assert.equal(fake.events.size, 1); diff --git a/test/transcript-routes.test.ts b/test/transcript-routes.test.ts index eb38731..e7c54ca 100644 --- a/test/transcript-routes.test.ts +++ b/test/transcript-routes.test.ts @@ -345,7 +345,7 @@ test("MCP over HTTP at /mcp: a bearer token, one message or a batch, silence for assert.equal((await fetch(`${base}/mcp`, { method: "POST", headers: mine, body: "{" })).status, 400); const metadata = (await (await fetch(`${base}/.well-known/oauth-protected-resource`)).json()) as { resource: string; authorization_servers: string[] }; - assert.equal(metadata.resource, "https://nixamp.test"); + assert.equal(metadata.resource, "https://nixamp.test/mcp"); assert.deepEqual(metadata.authorization_servers, ["https://nixamp.test"]); }); });