From 02e06fd7a1fa7ba98cf650866920a050bee9c197 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 20:02:39 +0530 Subject: [PATCH 1/2] Add Turnstile to login and run MCP on the stateless platform kit Login: the hosted login page shows a Cloudflare Turnstile widget when TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set, and Better Auth's captcha plugin verifies the token on the email-OTP send request. Tokens must be issued for the API's host; Cloudflare's testing secrets skip that check. Setting only one key stops the API from starting. MCP: upgrade the CodeLit platform packages to 0.8.0 and serve MCP through createMcpServerKit. Requests are stateless (no sessions; 2025-era clients still work), tools are data with a risk level that sets the required scope, destructive tools (delete_media, seal_media) need confirm: true, and tool errors are platform errors. x-medialit-apikey still works and is mapped to the kit's X-API-Key. The MCP route parses its own JSON so bad bodies get a JSON-RPC error instead of a 500. --- apps/api/.env.example | 4 + apps/api/__tests__/mcp/call-tool.ts | 26 ++ apps/api/__tests__/mcp/media-tool.test.ts | 27 +- apps/api/__tests__/mcp/upload-tool.test.ts | 42 +- apps/api/__tests__/mcp/whoami-tool.test.ts | 82 ++-- apps/api/package.json | 14 +- apps/api/src/__tests__/lifecycle.test.ts | 2 +- apps/api/src/__tests__/login-captcha.test.ts | 116 +++++ apps/api/src/app.ts | 19 +- .../api/src/auth/__tests__/middleware.test.ts | 84 +--- .../src/auth/__tests__/resolve-auth.test.ts | 29 -- apps/api/src/auth/__tests__/scopes.test.ts | 127 +++--- apps/api/src/auth/middleware.ts | 36 +- apps/api/src/auth/options.ts | 56 +++ apps/api/src/auth/resolve-auth.ts | 12 +- apps/api/src/auth/scopes.ts | 6 +- apps/api/src/index.ts | 6 +- apps/api/src/lifecycle.ts | 6 +- apps/api/src/mcp/__tests__/routes.test.ts | 266 ++++++++++++ apps/api/src/mcp/auth-context.ts | 84 ---- apps/api/src/mcp/context.ts | 127 ++++++ apps/api/src/mcp/routes.ts | 215 +++++----- apps/api/src/mcp/server.ts | 126 ++---- apps/api/src/mcp/tool.ts | 67 +++ .../src/mcp/tools/__tests__/upload.test.ts | 26 +- apps/api/src/mcp/tools/errors.ts | 29 ++ apps/api/src/mcp/tools/media.ts | 406 ++++++------------ apps/api/src/mcp/tools/responses.ts | 19 - apps/api/src/mcp/tools/settings.ts | 168 +++----- apps/api/src/mcp/tools/signature.ts | 80 ++-- apps/api/src/mcp/tools/upload.ts | 151 +++---- apps/api/src/mcp/tools/whoami.ts | 106 ++--- apps/web/AGENTS.md | 2 +- bun.lock | 108 ++--- docs/prds/apikey.md | 2 +- frontlit/docs/for-agents/mcp-server.md | 6 +- packages/integration-tests/src/mcp.ts | 92 ++-- 37 files changed, 1429 insertions(+), 1345 deletions(-) create mode 100644 apps/api/__tests__/mcp/call-tool.ts create mode 100644 apps/api/src/__tests__/login-captcha.test.ts create mode 100644 apps/api/src/mcp/__tests__/routes.test.ts delete mode 100644 apps/api/src/mcp/auth-context.ts create mode 100644 apps/api/src/mcp/context.ts create mode 100644 apps/api/src/mcp/tool.ts create mode 100644 apps/api/src/mcp/tools/errors.ts delete mode 100644 apps/api/src/mcp/tools/responses.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index a26475c3..8d0995b9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,6 +8,10 @@ WEB_ORIGIN=http://localhost:3000 # Shared parent domain for the session cookie when the API and dashboard are on # different subdomains, for example `medialit.cloud`. Leave unset otherwise. # AUTH_COOKIE_DOMAIN= +# Cloudflare Turnstile on the hosted login page. Set both keys or neither. +# The widget's hostname in Cloudflare must be PUBLIC_API_URL's host. +# TURNSTILE_SITE_KEY= +# TURNSTILE_SECRET_KEY= BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min EMAIL=admin@example.com # Optional. An API key the API creates for EMAIL's default app at startup, so diff --git a/apps/api/__tests__/mcp/call-tool.ts b/apps/api/__tests__/mcp/call-tool.ts new file mode 100644 index 00000000..242735fb --- /dev/null +++ b/apps/api/__tests__/mcp/call-tool.ts @@ -0,0 +1,26 @@ +import { medialitMcpTools } from "../../src/mcp/server"; +import type { MedialitMcpContext } from "../../src/mcp/context"; + +/** Runs a tool the way the kit does after authentication, with the args + * already parsed. */ +export async function callTool( + name: string, + context: Partial & { user: any }, + args: Record = {}, +): Promise { + const tool = medialitMcpTools().find( + (candidate) => candidate.name === name, + ); + if (!tool) throw new Error(`No MCP tool named ${name}`); + return tool.handler({ + context: { + authKind: "apikey", + userId: context.user._id, + appId: "test-app", + scopes: ["data:read", "data:write"], + ...context, + }, + args: tool.inputSchema.parse(args), + signal: new AbortController().signal, + }); +} diff --git a/apps/api/__tests__/mcp/media-tool.test.ts b/apps/api/__tests__/mcp/media-tool.test.ts index fedf017d..a5adafd9 100644 --- a/apps/api/__tests__/mcp/media-tool.test.ts +++ b/apps/api/__tests__/mcp/media-tool.test.ts @@ -1,6 +1,7 @@ import test, { afterEach, describe, mock } from "node:test"; import assert from "node:assert"; -import { handleGetTotalStorageTool } from "../../src/mcp/tools/media"; +import mediaQueries from "../../src/media/queries"; +import { callTool } from "./call-tool"; import { maxStorageAllowedSubscribed } from "../../src/config/constants"; import { billingStateSource } from "../../src/billing/entitlements"; @@ -35,28 +36,16 @@ describe("MCP get_total_storage", () => { }; let queriedUserId: unknown; - const response = await handleGetTotalStorageTool( - {}, - { - authInfo: { - extra: { - authKind: "apikey", - userId: user._id, - user, - appId: "test-app", - }, - }, - }, - { - getTotalSpace: async ({ userId }: any) => { - queriedUserId = userId; - return 2103931; - }, + mock.method(mediaQueries, "getTotalSpace").mock.mockImplementation( + async ({ userId }: any) => { + queriedUserId = userId; + return 2103931; }, ); + const response = await callTool("get_total_storage", { user }); assert.equal(queriedUserId, user._id); - assert.deepEqual((response as any).structuredContent, { + assert.deepEqual(response, { storage: 2103931, maxStorage: maxStorageAllowedSubscribed, }); diff --git a/apps/api/__tests__/mcp/upload-tool.test.ts b/apps/api/__tests__/mcp/upload-tool.test.ts index 325b0fa4..a225f2e4 100644 --- a/apps/api/__tests__/mcp/upload-tool.test.ts +++ b/apps/api/__tests__/mcp/upload-tool.test.ts @@ -1,6 +1,6 @@ import test, { afterEach, describe, mock } from "node:test"; import assert from "node:assert"; -import { handleUploadMediaTool } from "../../src/mcp/tools/upload"; +import { callTool } from "./call-tool"; import mediaQueries from "../../src/media/queries"; import mediaService from "../../src/media/service"; import { @@ -12,19 +12,6 @@ import { NOT_ENOUGH_STORAGE, } from "../../src/config/strings"; -function authInfo(user: any) { - return { - authInfo: { - extra: { - authKind: "apikey", - userId: user.id, - user, - appId: "test-app", - }, - }, - }; -} - function uploadArgs(size: number) { return { fileBase64: Buffer.alloc(size).toString("base64"), @@ -47,16 +34,17 @@ describe("MCP upload_media", () => { throw new Error("upload should not be called"); }); - const response = await handleUploadMediaTool( + const response = await callTool( + "upload_media", + { user, userId: user.id }, uploadArgs(maxFileUploadSizeNotSubscribed + 1), - authInfo(user), ); - assert.equal((response as any).isError, true); - assert.equal( - response.content[0].text, - `${FILE_SIZE_EXCEEDED}. Allowed: ${maxFileUploadSizeNotSubscribed} bytes`, - ); + assert.equal(response.error.code, "validation_failed"); + assert.deepEqual(response.error.safeDetails, { + reason: "file_size_exceeded", + message: `${FILE_SIZE_EXCEEDED}. Allowed: ${maxFileUploadSizeNotSubscribed} bytes`, + }); }); test("rejects uploads that exceed remaining account storage", async () => { @@ -71,12 +59,16 @@ describe("MCP upload_media", () => { throw new Error("upload should not be called"); }); - const response = await handleUploadMediaTool( + const response = await callTool( + "upload_media", + { user, userId: user.id }, uploadArgs(1024), - authInfo(user), ); - assert.equal((response as any).isError, true); - assert.equal(response.content[0].text, NOT_ENOUGH_STORAGE); + assert.equal(response.error.code, "forbidden"); + assert.deepEqual(response.error.safeDetails, { + reason: "not_enough_storage", + message: NOT_ENOUGH_STORAGE, + }); }); }); diff --git a/apps/api/__tests__/mcp/whoami-tool.test.ts b/apps/api/__tests__/mcp/whoami-tool.test.ts index 266629ed..c2254393 100644 --- a/apps/api/__tests__/mcp/whoami-tool.test.ts +++ b/apps/api/__tests__/mcp/whoami-tool.test.ts @@ -1,8 +1,16 @@ -import test, { afterEach, describe, mock } from "node:test"; +import test, { afterEach, before, describe, mock } from "node:test"; import assert from "node:assert"; -import { handleWhoamiTool } from "../../src/mcp/tools/whoami"; +import path from "node:path"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { setDb, type AppDb } from "../../src/db/client"; +import * as schema from "../../src/db/schema/index"; +import { createAccount, createApp, listApps } from "../../src/db"; +import mediaQueries from "../../src/media/queries"; import { maxStorageAllowedSubscribed } from "../../src/config/constants"; import { billingStateSource } from "../../src/billing/entitlements"; +import { callTool } from "./call-tool"; function mockPaidPlan() { mock.method(billingStateSource, "read").mock.mockImplementation( @@ -22,19 +30,18 @@ function mockPaidPlan() { ); } -const user = { - id: "user-1", - _id: "user-1", - email: "hi@example.com", -}; +let user: any; +let app: { id: string; appId: string; name: string }; -function extra(authKind: "oauth" | "apikey") { - return { - authInfo: { - extra: { authKind, userId: user._id, user, appId: "internal-app" }, - }, - }; -} +before(async () => { + const db = drizzle(new PGlite(), { schema }); + await migrate(db, { + migrationsFolder: path.resolve(__dirname, "../../drizzle"), + }); + setDb(db as unknown as AppDb); + user = await createAccount({ email: "hi@example.com" }); + [app] = await listApps(user._id); +}); describe("MCP whoami", () => { afterEach(() => mock.restoreAll()); @@ -42,40 +49,45 @@ describe("MCP whoami", () => { test("reports the account, the connected app and its usage", async () => { mockPaidPlan(); const queried: unknown[] = []; - const response = await handleWhoamiTool({}, extra("oauth"), { - getApp: async (id: string) => { - queried.push(id); - return { - id, - appId: "app-1", - name: "My Store", - default: true, - deleted: false, - userId: user._id, - } as any; - }, - getMediaCount: async ({ userId, appId }: any) => { + mock.method(mediaQueries, "getMediaCount").mock.mockImplementation( + async ({ userId, appId }: any) => { queried.push([userId, appId]); return 3; }, - getTotalSpace: async () => 2048, + ); + mock.method(mediaQueries, "getTotalSpace").mock.mockImplementation( + async () => 2048, + ); + + const response = await callTool("whoami", { + authKind: "oauth", + user, + appId: app.id, }); - assert.deepEqual(queried, ["internal-app", ["user-1", "internal-app"]]); - assert.deepEqual((response as any).structuredContent, { + assert.deepEqual(queried, [[user._id, app.id]]); + assert.deepEqual(response, { auth: "oauth", email: "hi@example.com", - app: { id: "app-1", name: "My Store", default: true }, + app: { id: app.appId, name: app.name, default: true }, files: 3, storage: 2048, maxStorage: maxStorageAllowedSubscribed, }); // The app is named by its public ID, never the internal one. - assert.doesNotMatch(response.content[0].text, /internal-app/); + assert.doesNotMatch(JSON.stringify(response), new RegExp(app.id)); }); - test("rejects calls without auth", async () => { - const response = await handleWhoamiTool({}, {}); - assert.equal((response as any).isError, true); + test("refuses an app that was deleted", async () => { + const deleted = await createApp({ + userId: user._id, + name: "Gone", + deleted: true, + }); + const response = await callTool("whoami", { + user, + appId: deleted.id, + }); + assert.equal(response.error.code, "tenant_forbidden"); }); }); diff --git a/apps/api/package.json b/apps/api/package.json index 33aa301d..b81d76c2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,16 +40,15 @@ "@aws-sdk/s3-request-presigner": "^3.1146.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.4.1", - "@codelitdev/mcp-server-kit": "0.4.1", - "@codelitdev/oauth-server-kit": "0.4.1", - "@codelitdev/observability": "0.4.1", - "@codelitdev/platform": "0.4.1", + "@codelitdev/billing": "0.8.0", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@medialit/images": "workspace:*", "@medialit/models": "workspace:*", "@medialit/thumbnail": "workspace:*", "@medialit/utils": "workspace:*", - "@modelcontextprotocol/sdk": "1.32.0", "@tus/file-store": "^2.1.1", "@tus/server": "^2.4.5", "better-auth": "1.7.7", @@ -69,7 +68,8 @@ "zod": "3.25.76" }, "devDependencies": { - "@codelitdev/platform-conformance": "0.4.1", + "@codelitdev/platform-conformance": "0.8.0", + "@modelcontextprotocol/client": "2.4.0", "@electric-sql/pglite": "0.5.8", "@types/cors": "^2.8.12", "@types/express": "^4.17.20", diff --git a/apps/api/src/__tests__/lifecycle.test.ts b/apps/api/src/__tests__/lifecycle.test.ts index 8475ea94..ae49a36c 100644 --- a/apps/api/src/__tests__/lifecycle.test.ts +++ b/apps/api/src/__tests__/lifecycle.test.ts @@ -7,7 +7,7 @@ function recordingSteps(calls: string[]) { stopBackgroundJobs: () => { calls.push("jobs"); }, - closeMcpSessions: async () => { + closeMcp: async () => { calls.push("mcp"); }, closeServer: async () => { diff --git a/apps/api/src/__tests__/login-captcha.test.ts b/apps/api/src/__tests__/login-captcha.test.ts new file mode 100644 index 00000000..aba5f807 --- /dev/null +++ b/apps/api/src/__tests__/login-captcha.test.ts @@ -0,0 +1,116 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import type { AppDb } from "../db/client.js"; +import { loginCaptchaConfig } from "../auth/options.js"; + +/* + * With Turnstile keys set, the hosted login page renders the widget and the + * API refuses to email a sign-in code without a Turnstile token. + */ +let server: Server; +let origin: string; +let client: PGlite; +const siteKey = "1x00000000000000000000AA"; + +before(async () => { + process.env.TURNSTILE_SITE_KEY = siteKey; + process.env.TURNSTILE_SECRET_KEY = "1x0000000000000000000000000000000AA"; + + const schema = await import("../db/schema/index.js"); + const { setDb } = await import("../db/client.js"); + client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../drizzle", + ), + }); + setDb(db as unknown as AppDb); + + const { createMedialitAuth } = await import("../auth/better-auth.js"); + const { createApp } = await import("../app.js"); + const { apiReadiness } = await import("../lifecycle.js"); + + server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const auth = createMedialitAuth({ + publicApiUrl: origin, + secret: "captcha-test-secret-that-is-at-least-32-chars", + webOrigin: "http://127.0.0.1:3000", + }); + server.on( + "request", + createApp({ + auth, + webOrigin: "http://127.0.0.1:3000", + readiness: () => + apiReadiness({ + started: false, + shuttingDown: false, + pingDatabase: async () => undefined, + }), + }), + ); +}); + +after(async () => { + delete process.env.TURNSTILE_SITE_KEY; + delete process.env.TURNSTILE_SECRET_KEY; + await new Promise((resolve) => server.close(() => resolve())); + await client.close(); +}); + +test("the login page renders the Turnstile widget", async () => { + const response = await fetch(`${origin}/login`); + assert.equal(response.status, 200); + const html = await response.text(); + assert.match(html, new RegExp(`data-sitekey="${siteKey}"`)); + assert.match(html, /challenges\.cloudflare\.com\/turnstile\/v0\/api\.js/); +}); + +test("sending a sign-in code needs a Turnstile token", async () => { + const response = await fetch( + `${origin}/api/auth/email-otp/send-verification-otp`, + { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ + email: "someone@example.com", + type: "sign-in", + }), + }, + ); + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.code, "MISSING_RESPONSE"); +}); + +test("Turnstile is off without keys and refuses half a configuration", () => { + assert.equal(loginCaptchaConfig({}), undefined); + assert.deepEqual( + loginCaptchaConfig({ + TURNSTILE_SITE_KEY: " site ", + TURNSTILE_SECRET_KEY: "secret", + }), + { siteKey: "site", secretKey: "secret" }, + ); + assert.throws( + () => loginCaptchaConfig({ TURNSTILE_SITE_KEY: "site" }), + /must be set together/, + ); + assert.throws( + () => loginCaptchaConfig({ TURNSTILE_SECRET_KEY: "secret" }), + /must be set together/, + ); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f75065b2..951cbe8e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,7 +19,11 @@ import type { apiReadiness } from "./lifecycle"; import { cleanupTUSUploads } from "./tus/cleanup"; import { cleanupExpiredTempUploads } from "./media/cleanup"; import type { MedialitAuth } from "./auth/better-auth"; -import { AUTH_BASE_PATH, MCP_SCOPES_SUPPORTED } from "./auth/options"; +import { + AUTH_BASE_PATH, + MCP_SCOPES_SUPPORTED, + loginCaptchaConfig, +} from "./auth/options"; import { setBearerAuth } from "./auth/bearer"; import { createOAuthAppPages } from "./auth/oauth-app-pages"; import { oauthAppSelectionAdapter } from "./auth/oauth-app-selection"; @@ -41,6 +45,7 @@ export function createApp(input: { readiness: () => Promise; }): express.Express { const { auth, webOrigin } = input; + const loginCaptcha = loginCaptchaConfig(); const app = express(); app.set( @@ -79,6 +84,14 @@ export function createApp(input: { allowedRedirectOrigins: [new URL(webOrigin).origin], defaultRedirectUrl: `${webOrigin}/`, loginMethods: [{ type: "email-otp" }], + ...(loginCaptcha + ? { + captcha: { + provider: "cloudflare-turnstile", + siteKey: loginCaptcha.siteKey, + }, + } + : {}), }), ); app.use( @@ -89,6 +102,9 @@ export function createApp(input: { }), ); + // The MCP route parses its own body so protocol errors stay JSON-RPC. + app.use(mcpRoutes); + app.use( express.json({ limit: "2mb", @@ -185,7 +201,6 @@ export function createApp(input: { app.use("/media/signature", signatureRoutes); app.use("/media", tusRoutes); app.use("/media", mediaRoutes); - app.use(mcpRoutes); app.get( "/cleanup/temp", diff --git a/apps/api/src/auth/__tests__/middleware.test.ts b/apps/api/src/auth/__tests__/middleware.test.ts index f341d7ab..415caa38 100644 --- a/apps/api/src/auth/__tests__/middleware.test.ts +++ b/apps/api/src/auth/__tests__/middleware.test.ts @@ -22,7 +22,7 @@ test("REST middleware maps successful OAuth auth to req.user and req.appId", asy const req: any = { headers: { authorization: "Bearer token" }, body: {} }; const res = response(); let nextCalled = false; - const middleware = createAuthMiddleware("rest", async () => ({ + const middleware = createAuthMiddleware(async () => ({ status: "authenticated", kind: "oauth", user: { id: "user-1" }, @@ -49,7 +49,7 @@ test("REST middleware maps API-key auth to internal IDs, never the secret", asyn }; const res = response(); let nextCalled = false; - const middleware = createAuthMiddleware("rest", async () => ({ + const middleware = createAuthMiddleware(async () => ({ status: "authenticated", kind: "apikey", user: { id: "user-1" }, @@ -72,7 +72,7 @@ test("REST middleware maps API-key auth to internal IDs, never the secret", asyn test("REST middleware uses shared invalid Bearer 401 behavior", async () => { const req: any = { headers: { authorization: "Bearer bad" }, body: {} }; const res = response(); - const middleware = createAuthMiddleware("rest", async () => ({ + const middleware = createAuthMiddleware(async () => ({ status: "invalid_token", })); @@ -86,81 +86,3 @@ test("REST middleware uses shared invalid Bearer 401 behavior", async () => { error_description: "Access token is invalid or expired", }); }); - -test("MCP middleware maps OAuth auth fields", async () => { - const req: any = { headers: { authorization: "Bearer token" }, body: {} }; - const res = response(); - let nextCalled = false; - const middleware = createAuthMiddleware("mcp", async () => ({ - status: "authenticated", - kind: "oauth", - user: { id: "user-1" }, - userId: "user-1", - clientId: "client-1", - scopes: ["read"], - appId: "internal-default", - })); - - await middleware(req, res as any, () => { - nextCalled = true; - }); - - assert.equal(nextCalled, true); - assert.deepEqual(req.user, { id: "user-1" }); - assert.equal(req.userId, "user-1"); - assert.equal(req.clientId, "client-1"); - assert.deepEqual(req.scopes, ["read"]); - assert.equal(req.appId, "internal-default"); -}); - -test("MCP middleware maps API-key auth fields with full data scopes", async () => { - const req: any = { - headers: { "x-medialit-apikey": "submitted-key" }, - body: {}, - }; - const res = response(); - let nextCalled = false; - const middleware = createAuthMiddleware("mcp", async () => ({ - status: "authenticated", - kind: "apikey", - user: { id: "user-1" }, - userId: "user-1", - appId: "internal-app", - apiKeyId: "internal-key", - })); - - await middleware(req, res as any, () => { - nextCalled = true; - }); - - assert.equal(nextCalled, true); - assert.deepEqual(req.user, { id: "user-1" }); - assert.equal(req.userId, "user-1"); - assert.equal(req.appId, "internal-app"); - assert.equal(req.apiKeyId, "internal-key"); - assert.equal(req.clientId, undefined); - // API keys predate scopes and keep full access. - assert.deepEqual(req.scopes, ["data:read", "data:write"]); -}); - -test("MCP middleware preserves missing-auth 401 behavior", async () => { - const req: any = { headers: {}, body: {} }; - const res = response(); - const middleware = createAuthMiddleware( - "mcp", - async (): Promise => ({ - status: "missing", - }), - ); - - await middleware(req, res as any, () => { - throw new Error("next should not be called"); - }); - - assert.equal(res.statusCode, 401); - assert.deepEqual(res.body, { - error: "unauthorized", - error_description: - "Missing authentication: provide Authorization: Bearer or x-medialit-apikey header", - }); -}); diff --git a/apps/api/src/auth/__tests__/resolve-auth.test.ts b/apps/api/src/auth/__tests__/resolve-auth.test.ts index b5f03efb..201816d2 100644 --- a/apps/api/src/auth/__tests__/resolve-auth.test.ts +++ b/apps/api/src/auth/__tests__/resolve-auth.test.ts @@ -341,32 +341,3 @@ test("ambiguous credentials return the Platform 400 error", () => { error_description: "Exactly one credential mechanism is allowed.", }); }); - -test("MCP auth errors advertise protected-resource metadata", () => { - const headers = new Map(); - const response = { - statusCode: 200, - setHeader(name: string, value: string) { - headers.set(name, value); - }, - status(code: number) { - this.statusCode = code; - return this; - }, - json(_body: unknown) { - return this; - }, - }; - const metadataUrl = - "http://localhost:8000/.well-known/oauth-protected-resource/mcp"; - - assert.equal( - sendAuthError(response, { status: "missing" }, metadataUrl), - true, - ); - assert.equal(response.statusCode, 401); - assert.equal( - headers.get("WWW-Authenticate"), - `Bearer resource_metadata="${metadataUrl}"`, - ); -}); diff --git a/apps/api/src/auth/__tests__/scopes.test.ts b/apps/api/src/auth/__tests__/scopes.test.ts index 088d4e69..86ecaba5 100644 --- a/apps/api/src/auth/__tests__/scopes.test.ts +++ b/apps/api/src/auth/__tests__/scopes.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { requireScope, toolScope } from "../scopes.js"; -import { createToolRegistrar, registerAllTools } from "../../mcp/server.js"; +import { medialitMcpTools } from "../../mcp/server.js"; +import type { MedialitMcpContext } from "../../mcp/context.js"; const READ_ONLY_TOOLS = new Set([ "list_media", @@ -12,94 +13,74 @@ const READ_ONLY_TOOLS = new Set([ "whoami", ]); -type Registered = { - name: string; - call: (extra: any) => any; -}; - -// Calls handlers the way the MCP SDK does: tools without an input schema get -// only the request extra. -function fakeServer() { - const tools: Registered[] = []; - const server: any = { - registerTool( - name: string, - config: { inputSchema?: unknown }, - handler: any, - ) { - tools.push({ - name, - call: (extra) => - config.inputSchema === undefined - ? handler(extra) - : handler({}, extra), - }); - }, +function context(scopes: string[]): MedialitMcpContext { + return { + authKind: "oauth", + userId: "user-1", + user: { _id: "user-1" }, + appId: "app-1", + scopes, }; - return { server, tools }; } -test("only tools marked read-only get the read scope", () => { - assert.equal(toolScope(true), "data:read"); - assert.equal(toolScope(false), "data:write"); - assert.equal(toolScope(undefined), "data:write"); +test("only read tools get the read scope", () => { + assert.equal(toolScope("read"), "data:read"); + assert.equal(toolScope("write"), "data:write"); + assert.equal(toolScope("destructive"), "data:write"); }); -test("every MCP tool rejects a token without its scope", async () => { - const { server, tools } = fakeServer(); - registerAllTools(server); +test("tools marked read-only are exactly the read tools", () => { + for (const tool of medialitMcpTools()) { + assert.equal( + tool.risk === "read", + READ_ONLY_TOOLS.has(tool.name), + tool.name, + ); + } +}); +test("every MCP tool rejects a token without its scope", async () => { + const tools = medialitMcpTools(); assert.equal(tools.length, 11); for (const tool of tools) { const scope = READ_ONLY_TOOLS.has(tool.name) ? "data:read" : "data:write"; - const result = await tool.call({ authInfo: { scopes: [] } }); - assert.equal(result.isError, true, tool.name); - assert.match(result.content[0].text, new RegExp(scope), tool.name); + const result = (await tool.handler({ + context: context([]), + args: {}, + signal: new AbortController().signal, + })) as { error: { code: string; safeDetails: any } }; + assert.equal(result.error.code, "forbidden", tool.name); + assert.equal(result.error.safeDetails.requiredScope, scope, tool.name); } }); -test("a read-only token can read but not write", async () => { - const { server, tools } = fakeServer(); - const registrar = createToolRegistrar(server); - const calls: string[] = []; - registrar.registerTool( - "read", - { annotations: { readOnlyHint: true } }, - () => calls.push("read"), - ); - registrar.registerTool( - "write", - { annotations: { readOnlyHint: false } }, - () => calls.push("write"), - ); - const extra = { authInfo: { scopes: ["data:read"] } }; - - for (const tool of tools) await tool.call(extra); - - assert.deepEqual(calls, ["read"]); +test("a read-only token cannot write", async () => { + const write = medialitMcpTools().filter((tool) => tool.risk !== "read"); + for (const tool of write) { + const result = (await tool.handler({ + context: context(["data:read"]), + args: {}, + signal: new AbortController().signal, + })) as { error: { safeDetails: any } }; + assert.equal( + result.error.safeDetails.reason, + "insufficient_scope", + tool.name, + ); + } }); -test("tools without input get the request extra as their second argument", async () => { - const { server, tools } = fakeServer(); - const registrar = createToolRegistrar(server); - const received: unknown[] = []; - registrar.registerTool( - "no_input", - { annotations: { readOnlyHint: true } }, - (args, extra) => received.push(args, extra), - ); - registrar.registerTool( - "with_input", - { inputSchema: {}, annotations: { readOnlyHint: true } }, - (args, extra) => received.push(args, extra), - ); - const extra = { authInfo: { scopes: ["data:read"] } }; - - for (const tool of tools) await tool.call(extra); - - assert.deepEqual(received, [{}, extra, {}, extra]); +test("destructive tools advertise confirm", () => { + for (const tool of medialitMcpTools()) { + const shape = (tool.inputSchema as any).shape; + assert.equal( + "confirm" in shape, + tool.risk === "destructive", + tool.name, + ); + } }); function response() { diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 48c76330..178e9024 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -1,5 +1,4 @@ import { NextFunction, Response } from "express"; -import { getMcpResourceMetadataUrl } from "./bearer"; import { AuthResult, resolveAuth, sendAuthError } from "./resolve-auth"; import { API_KEY_SCOPES } from "./scopes"; @@ -8,13 +7,7 @@ type AuthResolver = (input: { apiKeyHeader?: unknown; }) => Promise; -type AuthMiddlewareMode = "rest" | "mcp"; - -function applyAuthToRequest( - req: any, - auth: AuthResult, - mode: AuthMiddlewareMode, -) { +function applyAuthToRequest(req: any, auth: AuthResult) { if (auth.status !== "authenticated") return; req.user = auth.user; @@ -22,20 +15,9 @@ function applyAuthToRequest( req.appId = auth.appId; req.apiKeyId = auth.kind === "apikey" ? auth.apiKeyId : undefined; req.scopes = auth.kind === "oauth" ? auth.scopes : API_KEY_SCOPES; - - if (mode === "mcp") { - req.authKind = auth.kind; - req.userId = auth.userId; - if (auth.kind === "oauth") { - req.clientId = auth.clientId; - } - } } -export function createAuthMiddleware( - mode: AuthMiddlewareMode, - authResolver: AuthResolver = resolveAuth, -) { +export function createAuthMiddleware(authResolver: AuthResolver = resolveAuth) { return async function authMiddleware( req: any, res: Response, @@ -46,20 +28,12 @@ export function createAuthMiddleware( apiKeyHeader: req.headers["x-medialit-apikey"], }); - if ( - sendAuthError( - res, - auth, - mode === "mcp" ? getMcpResourceMetadataUrl() : undefined, - ) - ) - return; + if (sendAuthError(res, auth)) return; if (auth.status !== "authenticated") return; - applyAuthToRequest(req, auth, mode); + applyAuthToRequest(req, auth); next(); }; } -export const apikey = createAuthMiddleware("rest"); -export const mcpAuth = createAuthMiddleware("mcp"); +export const apikey = createAuthMiddleware(); diff --git a/apps/api/src/auth/options.ts b/apps/api/src/auth/options.ts index c0686168..71e6a97f 100644 --- a/apps/api/src/auth/options.ts +++ b/apps/api/src/auth/options.ts @@ -2,6 +2,7 @@ import { oauthProvider } from "@better-auth/oauth-provider"; import { cimd } from "@better-auth/cimd"; import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth"; import type { BetterAuthOptions } from "better-auth"; +import { captcha } from "better-auth/plugins"; import { emailOTP } from "better-auth/plugins/email-otp"; import { jwt } from "better-auth/plugins/jwt"; import { ensureProfile } from "@/db"; @@ -27,6 +28,39 @@ export const MCP_SCOPES_SUPPORTED = [ "offline_access", ] as const; +// The hosted login page sends the email-OTP request here. The code itself +// is only checked after the person proves they got the email, so this is the +// request bots abuse to send mail. +export const CAPTCHA_PROTECTED_ENDPOINTS = ["/email-otp/send-verification-otp"]; + +// https://developers.cloudflare.com/turnstile/troubleshooting/testing/ +const TURNSTILE_TESTING_SECRET = /^[123]x0{31}AA$/; + +export interface LoginCaptchaConfig { + siteKey: string; + secretKey: string; +} + +/** + * Cloudflare Turnstile for the hosted login page. Off unless both keys are + * set; one key without the other is a misconfiguration, not "off". + */ +export function loginCaptchaConfig( + env: Record = process.env, +): LoginCaptchaConfig | undefined { + const siteKey = env.TURNSTILE_SITE_KEY?.trim(); + const secretKey = env.TURNSTILE_SECRET_KEY?.trim(); + if (!siteKey && !secretKey) { + return undefined; + } + if (!siteKey || !secretKey) { + throw new Error( + "TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must be set together", + ); + } + return { siteKey, secretKey }; +} + export function authUrls(publicApiUrl: string, webOrigin?: string) { const normalized = publicApiUrl.replace(/\/$/, ""); return { @@ -55,6 +89,7 @@ export function medialitAuthOptions(input: { // session cookie on its own host. Set this to the shared parent domain // (for example `medialit.cloud`) when the two are different subdomains. const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim(); + const loginCaptcha = loginCaptchaConfig(); return { appName: "MediaLit", baseURL: urls.publicApiUrl, @@ -133,6 +168,27 @@ export function medialitAuthOptions(input: { adapter: oauthAppSelectionAdapter, }), }), + ...(loginCaptcha + ? [ + captcha({ + provider: "cloudflare-turnstile", + secretKey: loginCaptcha.secretKey, + endpoints: CAPTCHA_PROTECTED_ENDPOINTS, + // The widget only renders on the API's login page. + // Cloudflare's testing secrets always report + // example.com, so they skip this check. + ...(TURNSTILE_TESTING_SECRET.test( + loginCaptcha.secretKey, + ) + ? {} + : { + allowedHostnames: [ + new URL(urls.publicApiUrl).hostname, + ], + }), + }), + ] + : []), cimd({ fetchClientMetadataResource, metadataProfile: "mcp-2026-07-28", diff --git a/apps/api/src/auth/resolve-auth.ts b/apps/api/src/auth/resolve-auth.ts index 4f9969af..d792a14a 100644 --- a/apps/api/src/auth/resolve-auth.ts +++ b/apps/api/src/auth/resolve-auth.ts @@ -59,17 +59,7 @@ export type AuthResult = | { status: "ambiguous" } | { status: "missing" }; -export function sendAuthError( - res: any, - auth: AuthResult, - resourceMetadataUrl?: string, -): boolean { - if (auth.status !== "authenticated" && resourceMetadataUrl) { - res.setHeader( - "WWW-Authenticate", - `Bearer resource_metadata="${resourceMetadataUrl}"`, - ); - } +export function sendAuthError(res: any, auth: AuthResult): boolean { if (auth.status === "invalid_token") { res.status(401).json({ error: "invalid_token", diff --git a/apps/api/src/auth/scopes.ts b/apps/api/src/auth/scopes.ts index c738bfb2..ab92c8d0 100644 --- a/apps/api/src/auth/scopes.ts +++ b/apps/api/src/auth/scopes.ts @@ -9,9 +9,9 @@ export const API_KEY_SCOPES: readonly string[] = [ DATA_WRITE_SCOPE, ]; -/** MCP tools declare `readOnlyHint`; anything not read-only needs write. */ -export function toolScope(readOnlyHint: boolean | undefined): DataScope { - return readOnlyHint === true ? DATA_READ_SCOPE : DATA_WRITE_SCOPE; +/** MCP tools declare a risk; anything that is not a read needs write. */ +export function toolScope(risk: "read" | "write" | "destructive"): DataScope { + return risk === "read" ? DATA_READ_SCOPE : DATA_WRITE_SCOPE; } export function hasScope( diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3c127dd5..86fb5c93 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,7 @@ import { config as loadDotFile } from "dotenv"; loadDotFile(); import { checkDatabaseConnection } from "./config/db"; -import { closeMcpSessions } from "./mcp/routes"; +import { closeMcp } from "./mcp/routes"; import logger, { shutdownObservability } from "./services/log"; import { bootstrap, checkBootstrapConfig } from "./bootstrap"; import { closeDb, getDb, seedWebOAuthClient } from "@/db"; @@ -15,6 +15,7 @@ import { cleanupTUSUploads } from "./tus/cleanup"; import { cleanupExpiredTempUploads } from "./media/cleanup"; import { HOUR_IN_SECONDS } from "./config/constants"; import { createMedialitAuth } from "./auth/better-auth"; +import { loginCaptchaConfig } from "./auth/options"; import { createApp } from "./app"; import { startBillingMaintenance } from "./billing/maintenance"; @@ -97,7 +98,7 @@ checkConfig() clearInterval(tempCleanup); await stopBillingMaintenance(); }, - closeMcpSessions, + closeMcp, closeServer: () => new Promise((resolve, reject) => { server.close((error) => @@ -150,6 +151,7 @@ async function checkConfig() { ); } checkBootstrapConfig(); + loginCaptchaConfig(); if ( !process.env.BETTER_AUTH_SECRET || Buffer.byteLength(process.env.BETTER_AUTH_SECRET, "utf8") < 32 diff --git a/apps/api/src/lifecycle.ts b/apps/api/src/lifecycle.ts index 9e42290a..239004eb 100644 --- a/apps/api/src/lifecycle.ts +++ b/apps/api/src/lifecycle.ts @@ -12,8 +12,8 @@ export type ShutdownSteps = { /** Stops timers that start new work (cleanup jobs, billing batches). */ /** May return a promise, for example to let a running billing batch finish. */ stopBackgroundJobs: () => void | Promise; - /** Ends open MCP streams, which would otherwise keep the server open. */ - closeMcpSessions: () => Promise; + /** Refuses new MCP requests and closes the MCP handler. */ + closeMcp: () => Promise; /** Stops accepting connections and waits for in-flight requests. */ closeServer: () => Promise; closeDatabase: () => Promise; @@ -33,7 +33,7 @@ export function createApiShutdown( hooks: [ async () => { await steps.stopBackgroundJobs(); - await steps.closeMcpSessions(); + await steps.closeMcp(); await steps.closeServer(); await steps.closeDatabase(); await steps.flushObservability(); diff --git a/apps/api/src/mcp/__tests__/routes.test.ts b/apps/api/src/mcp/__tests__/routes.test.ts new file mode 100644 index 00000000..c3a965fa --- /dev/null +++ b/apps/api/src/mcp/__tests__/routes.test.ts @@ -0,0 +1,266 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import type { AppDb } from "../../db/client.js"; + +/* + * The MCP endpoint end to end: the real app, a real API key, and the + * official MCP client. Requests are stateless; there are no sessions. + */ +process.env.MEDIALIT_DEPLOYMENT_MODE = "oss"; + +let server: Server; +let origin: string; +let client: PGlite; +let apiKey: string; +let appPublicId: string; +const clients: Client[] = []; + +before(async () => { + const schema = await import("../../db/schema/index.js"); + const { setDb } = await import("../../db/client.js"); + client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle", + ), + }); + setDb(db as unknown as AppDb); + + const { createAccount, listApps } = await import("../../db/index.js"); + const { createApiKey } = await import("../../apikey/queries.js"); + const user = await createAccount({ email: "mcp@example.com" }); + const [app] = await listApps(user._id); + appPublicId = app.appId; + ({ secret: apiKey } = await createApiKey({ + appId: app.id, + name: "MCP test", + createdById: user._id, + })); + + const { createMedialitAuth } = await import("../../auth/better-auth.js"); + const { createApp } = await import("../../app.js"); + const { apiReadiness } = await import("../../lifecycle.js"); + + server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const auth = createMedialitAuth({ + publicApiUrl: origin, + secret: "mcp-route-test-secret-that-is-at-least-32-chars", + webOrigin: "http://127.0.0.1:3000", + }); + server.on( + "request", + createApp({ + auth, + webOrigin: "http://127.0.0.1:3000", + readiness: () => + apiReadiness({ + started: false, + shuttingDown: false, + pingDatabase: async () => undefined, + }), + }), + ); +}); + +after(async () => { + await Promise.allSettled(clients.map((mcp) => mcp.close())); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await client.close(); +}); + +async function connect(headers: Record) { + const mcp = new Client( + { name: "route-test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + clients.push(mcp); + await mcp.connect( + new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + requestInit: { headers }, + }), + ); + return mcp; +} + +function post(body: unknown, headers: Record = {}) { + return fetch(`${origin}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +const listTools = (headers: Record) => + post({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, headers); + +test("an API key sent as x-medialit-apikey lists and calls tools", async () => { + const mcp = await connect({ "x-medialit-apikey": apiKey }); + + const { tools } = await mcp.listTools(); + assert.deepEqual(tools.map((tool) => tool.name).sort(), [ + "create_upload_signature", + "delete_media", + "get_media", + "get_media_count", + "get_media_settings", + "get_total_storage", + "list_media", + "seal_media", + "update_media_settings", + "upload_media", + "whoami", + ]); + const deleteMedia = tools.find((tool) => tool.name === "delete_media")!; + assert.equal(deleteMedia.annotations?.destructiveHint, true); + assert.ok(deleteMedia.inputSchema.properties?.confirm); + + const whoami = await mcp.callTool({ name: "whoami", arguments: {} }); + assert.equal(whoami.isError, undefined); + const { maxStorage, ...identity } = whoami.structuredContent as any; + assert.deepEqual(identity, { + auth: "apikey", + email: "mcp@example.com", + app: { id: appPublicId, name: "My Store", default: true }, + files: 0, + storage: 0, + }); + assert.ok(Number.isSafeInteger(maxStorage) && maxStorage > 0); + assert.doesNotMatch(JSON.stringify(whoami), new RegExp(apiKey)); +}); + +test("a destructive tool needs confirm: true", async () => { + const mcp = await connect({ "x-medialit-apikey": apiKey }); + const refused = await mcp.callTool({ + name: "delete_media", + arguments: { mediaId: "missing" }, + }); + assert.equal(refused.isError, true); + assert.deepEqual(refused.structuredContent, { + code: "forbidden", + message: "The requested action is not permitted.", + details: { confirmation: "required" }, + }); + + const missing = await mcp.callTool({ + name: "get_media", + arguments: { mediaId: "missing" }, + }); + assert.equal(missing.isError, true); + assert.equal((missing.structuredContent as any).code, "not_found"); +}); + +test("2025-era clients initialize without a session", async () => { + const response = await post( + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "legacy", version: "1.0.0" }, + }, + }, + { "x-medialit-apikey": apiKey }, + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("mcp-session-id"), null); + const body = await response.json(); + assert.equal(body.result.serverInfo.name, "MediaLit"); + + const tools = await listTools({ + "x-medialit-apikey": apiKey, + "mcp-protocol-version": "2025-11-25", + }); + assert.equal(tools.status, 200); + assert.equal((await tools.json()).result.tools.length, 11); +}); + +test("a request without credentials gets the OAuth challenge", async () => { + const response = await listTools({}); + assert.equal(response.status, 401); + assert.equal( + response.headers.get("www-authenticate"), + `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, + ); + assert.equal((await response.json()).code, "unauthenticated"); + + const invalid = await listTools({ authorization: "Bearer not-a-token" }); + assert.equal(invalid.status, 401); + assert.deepEqual((await invalid.json()).details, { + reason: "invalid_token", + }); + + const wrongKey = await listTools({ "x-medialit-apikey": "ml_live_nope" }); + assert.equal(wrongKey.status, 401); +}); + +test("two credentials at once are ambiguous", async () => { + const pairs: Record[] = [ + { "x-medialit-apikey": apiKey, "x-api-key": apiKey }, + { "x-medialit-apikey": apiKey, authorization: "Bearer token" }, + ]; + for (const headers of pairs) { + const response = await listTools(headers); + assert.equal(response.status, 400); + assert.equal((await response.json()).code, "credential_ambiguous"); + } +}); + +test("CORS preflight allows MediaLit's key header; GET, non-JSON and bad JSON are refused", async () => { + const preflight = await fetch(`${origin}/mcp`, { + method: "OPTIONS", + headers: { origin: "https://client.example" }, + }); + assert.equal(preflight.status, 204); + assert.equal( + preflight.headers.get("access-control-allow-origin"), + "https://client.example", + ); + assert.match( + preflight.headers.get("access-control-allow-headers") ?? "", + /x-medialit-apikey/, + ); + + const get = await fetch(`${origin}/mcp`, { + headers: { "x-medialit-apikey": apiKey }, + }); + assert.equal(get.status, 405); + + const text = await fetch(`${origin}/mcp`, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + assert.equal(text.status, 415); + + const malformed = await fetch(`${origin}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }); + assert.equal(malformed.status, 400); + assert.equal((await malformed.json()).error.code, -32700); +}); diff --git a/apps/api/src/mcp/auth-context.ts b/apps/api/src/mcp/auth-context.ts deleted file mode 100644 index 34305794..00000000 --- a/apps/api/src/mcp/auth-context.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; - -export type MedialitMcpAuthExtra = { - authKind: "oauth" | "apikey"; - /** Internal id of the authenticated account. */ - userId: string; - user: any; - /** Internal ID of the app. OAuth requests act on the app picked at - * authorization; API-key requests act on the key's own app. */ - appId: string; - /** Internal ID of the API key, for API-key requests only. Signatures - * record it so revoking the key revokes them. Never the secret. */ - apiKeyId?: string; -}; - -export type MedialitMcpAuthInfo = AuthInfo & { - extra: MedialitMcpAuthExtra; -}; - -type McpAuthRequest = { - authKind?: unknown; - userId?: unknown; - clientId?: unknown; - appId?: unknown; - apiKeyId?: unknown; - scopes?: unknown; - user?: any; -}; - -/** Builds the auth info the MCP transport hands to tool handlers from the - * fields `mcpAuth` sets on the request. */ -export function createMcpAuthInfo(input: unknown): MedialitMcpAuthInfo | null { - if (!input || typeof input !== "object") return null; - const req = input as McpAuthRequest; - if ( - (req.authKind !== "oauth" && req.authKind !== "apikey") || - typeof req.userId !== "string" || - !req.userId || - typeof req.appId !== "string" || - !req.appId || - !req.user - ) { - return null; - } - const scopes = Array.isArray(req.scopes) - ? req.scopes.filter( - (scope): scope is string => typeof scope === "string", - ) - : []; - const clientId = - req.authKind === "oauth" && typeof req.clientId === "string" - ? req.clientId - : "apikey"; - return { - // The SDK requires a token. Never put a credential secret here. - token: `${req.authKind}:${clientId}`, - clientId, - scopes, - extra: { - authKind: req.authKind, - userId: req.userId, - user: req.user, - appId: req.appId, - ...(req.authKind === "apikey" && typeof req.apiKeyId === "string" - ? { apiKeyId: req.apiKeyId } - : {}), - }, - }; -} - -/** The authenticated account and app for a tool call, or `null`. */ -export function getMcpAuth(extra: any): MedialitMcpAuthExtra | null { - const auth = extra?.authInfo?.extra as MedialitMcpAuthExtra | undefined; - if ( - !auth || - (auth.authKind !== "oauth" && auth.authKind !== "apikey") || - !auth.userId || - !auth.appId || - !auth.user - ) { - return null; - } - return auth; -} diff --git a/apps/api/src/mcp/context.ts b/apps/api/src/mcp/context.ts new file mode 100644 index 00000000..5b9ec81c --- /dev/null +++ b/apps/api/src/mcp/context.ts @@ -0,0 +1,127 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { McpToolDefinition } from "@codelitdev/mcp-server-kit"; +import { + type AuthenticationResult, + createPlatformError, + type HeaderMap, + type PlatformError, +} from "@codelitdev/platform"; +import { type AuthResult, resolveAuth } from "../auth/resolve-auth"; +import { API_KEY_SCOPES } from "../auth/scopes"; + +/** What every MediaLit MCP tool runs with. Internal IDs only; no credential + * secret ever reaches a tool. */ +export type MedialitMcpContext = { + authKind: "oauth" | "apikey"; + /** Internal id of the authenticated account. */ + userId: string; + user: any; + /** Internal ID of the app. OAuth requests act on the app picked at + * authorization; API-key requests act on the key's own app. */ + appId: string; + /** Internal ID of the API key, for API-key requests only. Signatures + * record it so revoking the key revokes them. */ + apiKeyId?: string; + /** OAuth scopes granted to the token. API keys carry every data scope. */ + scopes: readonly string[]; +}; + +export type MedialitMcpTool = McpToolDefinition; + +/** The kit hands `resolveContext` a principal id and credential only, so the + * full auth result is kept for the request's duration here. */ +const authByRequest = new AsyncLocalStorage<{ auth?: AuthResult }>(); + +export function runMcpRequest(handle: () => Promise): Promise { + return authByRequest.run({}, handle); +} + +function headerValue(headers: HeaderMap, name: string): string | undefined { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +/** MCP accepts an OAuth bearer token or an API key. The route passes a + * `x-medialit-apikey` key on as `X-API-Key`, the header the kit reads. */ +export async function authenticateMcpRequest( + headers: HeaderMap, +): Promise> { + const auth = await resolveAuth({ + authorization: headerValue(headers, "authorization"), + apiKeyHeader: headerValue(headers, "x-api-key"), + }); + const store = authByRequest.getStore(); + if (store) store.auth = auth; + + switch (auth.status) { + case "authenticated": + return { + kind: "authenticated", + principalId: auth.userId, + credential: + auth.kind === "oauth" + ? { + kind: "oauth", + credentialId: auth.clientId, + scopes: auth.scopes, + } + : { kind: "api_key", credentialId: auth.apiKeyId }, + }; + case "missing": + return { kind: "absent" }; + case "ambiguous": + return { + kind: "rejected", + error: createPlatformError("credential_ambiguous"), + }; + case "forbidden": + return { + kind: "rejected", + error: createPlatformError("forbidden", { + safeDetails: { reason: auth.error }, + }), + }; + case "invalid_token": + return { + kind: "rejected", + error: createPlatformError("unauthenticated", { + safeDetails: { reason: "invalid_token" }, + }), + }; + default: + return { + kind: "rejected", + error: createPlatformError("unauthenticated"), + }; + } +} + +export async function resolveMcpContext(): Promise< + | { ok: true; context: MedialitMcpContext } + | { ok: false; error: PlatformError } +> { + const auth = authByRequest.getStore()?.auth; + if (auth?.status !== "authenticated") { + return { ok: false, error: createPlatformError("unauthenticated") }; + } + // An OAuth token whose account has no app has nothing to act on. + if (!auth.appId) { + return { + ok: false, + error: createPlatformError("tenant_required", { + safeDetails: { reason: "no_app" }, + }), + }; + } + return { + ok: true, + context: { + authKind: auth.kind, + userId: auth.userId, + user: auth.user, + appId: auth.appId, + ...(auth.kind === "apikey" ? { apiKeyId: auth.apiKeyId } : {}), + scopes: auth.kind === "oauth" ? auth.scopes : API_KEY_SCOPES, + }, + }; +} diff --git a/apps/api/src/mcp/routes.ts b/apps/api/src/mcp/routes.ts index e14b1964..662e08c1 100644 --- a/apps/api/src/mcp/routes.ts +++ b/apps/api/src/mcp/routes.ts @@ -1,16 +1,30 @@ -import { Router } from "express"; +import { mcpCorsHeaders } from "@codelitdev/mcp-server-kit"; +import { + createPlatformError, + type HeaderMap, + toPublicHttpError, +} from "@codelitdev/platform"; +import express, { + type NextFunction, + type Request, + type Response, + Router, +} from "express"; import rateLimit from "express-rate-limit"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { mcpCorsHeaders, patchMcpAccept } from "@codelitdev/mcp-server-kit"; -import { mcpAuth } from "../auth/middleware"; -import { createMcpAuthInfo } from "./auth-context"; -import { createMCPSession } from "./server"; +import { getMcpResourceMetadataUrl } from "../auth/bearer"; +import { runMcpRequest } from "./context"; +import { createMedialitMcp } from "./server"; + +/** MediaLit's own API-key header, allowed on top of the kit's CORS list. */ +const MEDIALIT_CORS_HEADERS = "x-medialit-apikey"; const router = Router(); +const mcp = createMedialitMcp(); const mcpLimiter = rateLimit({ windowMs: 60_000, max: 60, + skip: (req) => req.method === "OPTIONS", standardHeaders: true, legacyHeaders: false, message: { @@ -19,124 +33,117 @@ const mcpLimiter = rateLimit({ }, }); -const mcpSessions = new Map(); - -const mcpCors = (req: any, res: any, next: any) => { - const headers = mcpCorsHeaders(req.headers); - headers["Access-Control-Allow-Headers"] = - `${headers["Access-Control-Allow-Headers"]}, x-medialit-apikey`; - for (const [name, value] of Object.entries(headers)) { - res.header(name, value); - } - if (req.method === "OPTIONS") { - return res.status(204).end(); +/** Protocol parse errors go back as JSON-RPC, not Express error pages. The + * limit fits `upload_media`'s base64 file content. */ +function jsonBody(req: Request, res: Response, next: NextFunction) { + if (req.method !== "POST") return next(); + if (!req.is("application/json")) { + res.status(415).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Content-Type must be application/json", + }, + id: null, + }); + return; } - next(); -}; - -function patchMcpAcceptHeaders(req: any) { - const patched = patchMcpAccept(req.headers); - const newAccept = Array.isArray(patched.accept) - ? patched.accept.join(", ") - : patched.accept; - if (!newAccept || newAccept === req.headers.accept) return; - req.headers.accept = newAccept; - - const rawHeaders: string[] = req.rawHeaders; - let found = false; - for (let i = 0; i < rawHeaders.length; i += 2) { - if (rawHeaders[i].toLowerCase() === "accept") { - rawHeaders[i + 1] = newAccept; - found = true; - break; + express.json({ limit: "2mb" })(req, res, (error?: unknown) => { + if (error) { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32700, message: "Parse error" }, + id: null, + }); + return; } + next(); + }); +} + +function withMedialitHeaders(res: Response) { + const allowed = res.getHeader("Access-Control-Allow-Headers"); + if (allowed) { + res.setHeader( + "Access-Control-Allow-Headers", + `${allowed}, ${MEDIALIT_CORS_HEADERS}`, + ); } - if (!found) rawHeaders.push("Accept", newAccept); } -/** Runs after `mcpAuth`, which accepts exactly one of an OAuth bearer token - * or an `x-medialit-apikey` header. */ -function attachMcpAuthInfo(req: any, res: any, next: any) { - const authInfo = createMcpAuthInfo(req); - if (!authInfo) { - return res.status(401).json({ - error: "unauthorized", - error_description: "MCP authentication context is incomplete.", - }); +function discoveryCors(req: Request, res: Response, next: NextFunction) { + for (const [name, value] of Object.entries(mcpCorsHeaders(req.headers))) { + res.setHeader(name, value); + } + withMedialitHeaders(res); + if (req.method === "OPTIONS") { + res.status(204).end(); + return; } - req.auth = authInfo; next(); } -router.use(["/.well-known", "/oauth"], mcpCors); +router.use(["/.well-known", "/oauth"], discoveryCors); -router.post( +router.all( "/mcp", - mcpCors, mcpLimiter, - mcpAuth, - attachMcpAuthInfo, - async (req: any, res: any) => { - patchMcpAcceptHeaders(req); - - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (sessionId) { - const transport = mcpSessions.get(sessionId); - if (!transport) { - return res.status(404).json({ - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }); + jsonBody, + async (req: Request, res: Response, next: NextFunction) => { + try { + const headers: Record = { + ...req.headers, + }; + // MediaLit clients send their key as `x-medialit-apikey`; the kit + // reads `X-API-Key`. Both at once stay ambiguous. + const medialitKey = headers["x-medialit-apikey"]; + delete headers["x-medialit-apikey"]; + if (medialitKey && headers["x-api-key"]) { + const ambiguous = toPublicHttpError( + createPlatformError("credential_ambiguous"), + ); + res.status(ambiguous.status).json(ambiguous.body); + return; } - await transport.handleRequest(req, res, req.body); - } else { - const transport = createMCPSession( - (id) => mcpSessions.set(id, transport), - (id) => mcpSessions.delete(id), - ); - await transport.handleRequest(req, res, req.body); - } - }, -); + if (medialitKey) headers["x-api-key"] = medialitKey; -router.delete( - "/mcp", - mcpCors, - mcpLimiter, - mcpAuth, - attachMcpAuthInfo, - async (req: any, res: any) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - if (!sessionId) { - return res.status(400).json({ - jsonrpc: "2.0", - error: { code: -32600, message: "Session ID required" }, - id: null, + const controller = new AbortController(); + res.once("close", () => { + if (!res.writableFinished) controller.abort(); }); - } + const result = await runMcpRequest(() => + mcp.handle({ + method: req.method, + headers: headers as HeaderMap, + body: req.body, + signal: controller.signal, + }), + ); - const transport = mcpSessions.get(sessionId); - if (!transport) { - return res.status(404).json({ - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }); + for (const [name, value] of Object.entries(result.headers)) { + res.setHeader(name, value); + } + withMedialitHeaders(res); + const resourceMetadataUrl = getMcpResourceMetadataUrl(); + if (result.status === 401 && resourceMetadataUrl) { + // Spec-compliant MCP clients discover OAuth from this challenge. + res.setHeader( + "WWW-Authenticate", + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ); + } + res.status(result.status); + if (result.body === null) res.end(); + else res.json(result.body); + } catch (error) { + next(error); } - - await transport.handleRequest(req, res); }, ); -router.options("/mcp", mcpCors); - -/** Closes open MCP sessions so their streams do not hold the server open. */ -export async function closeMcpSessions(): Promise { - const transports = [...mcpSessions.values()]; - mcpSessions.clear(); - await Promise.allSettled(transports.map((transport) => transport.close())); +/** Refuses new MCP requests and closes the kit's handler. */ +export function closeMcp(): Promise { + return mcp.shutdown(); } export default router; diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index 88b4c3ec..46ad12ca 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -1,98 +1,50 @@ -import crypto from "crypto"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; -import type { AnyZodObject, ZodRawShape } from "zod"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { registerMediaTools } from "./tools/media"; -import { registerSignatureTool } from "./tools/signature"; -import { registerSettingsTools } from "./tools/settings"; -import { registerUploadTool } from "./tools/upload"; -import { registerWhoamiTool } from "./tools/whoami"; -import { hasScope, insufficientScopeMessage, toolScope } from "../auth/scopes"; +import { + createMcpServerKit, + type McpServerKit, +} from "@codelitdev/mcp-server-kit"; +import { captureException } from "../services/log"; +import { + authenticateMcpRequest, + type MedialitMcpContext, + type MedialitMcpTool, + resolveMcpContext, +} from "./context"; +import { mediaTools } from "./tools/media"; +import { settingsTools } from "./tools/settings"; +import { signatureTools } from "./tools/signature"; +import { uploadTools } from "./tools/upload"; +import { whoamiTools } from "./tools/whoami"; -export type McpToolConfig = { - description?: string; - inputSchema?: ZodRawShape; - outputSchema?: ZodRawShape | AnyZodObject; - annotations?: ToolAnnotations; -}; +export const MEDIALIT_MCP_VERSION = "2.0.0"; -export type McpToolHandler = (args: any, extra: any) => unknown; - -export interface McpToolRegistrar { - registerTool( - name: string, - config: McpToolConfig, - handler: McpToolHandler, - ): void; -} - -/** - * Registers tools on `server` so every tool checks the caller's OAuth scopes: - * tools annotated `readOnlyHint: true` need data:read, all others need - * data:write. API keys carry both scopes. - * - * The SDK calls handlers of tools without an input schema with only the - * request extra. Registered handlers are always called as `(args, extra)`. - */ -export function createToolRegistrar(server: McpServer): McpToolRegistrar { - return { - registerTool(name, config, handler) { - const scope = toolScope(config.annotations?.readOnlyHint); - const hasInput = config.inputSchema !== undefined; - const register = server.registerTool.bind(server) as ( - ...args: any[] - ) => unknown; - register(name, config, (...callArgs: any[]) => { - const [args, extra] = hasInput ? callArgs : [{}, callArgs[0]]; - if (!hasScope(extra?.authInfo?.scopes, scope)) { - return { - content: [ - { - type: "text" as const, - text: insufficientScopeMessage(scope), - }, - ], - isError: true, - }; - } - return handler(args, extra); - }); - }, - }; -} - -export function registerAllTools(server: McpServer): void { - const tools = createToolRegistrar(server); - registerWhoamiTool(tools); - registerMediaTools(tools); - registerSignatureTool(tools); - registerSettingsTools(tools); - registerUploadTool(tools); +/** Every MediaLit MCP tool, in a stable order so catalog caches and upstream + * prompt caches see a deterministic tool list. */ +export function medialitMcpTools(): MedialitMcpTool[] { + return [ + ...whoamiTools(), + ...mediaTools(), + ...signatureTools(), + ...settingsTools(), + ...uploadTools(), + ]; } /** - * Create a new MCP session (transport + server pair). - * Each connecting client must get its own session — the - * WebStandardStreamableHTTPServerTransport is single-session by design. + * MediaLit's MCP endpoint on the CodeLit Platform kit: stateless, protocol + * 2026-07-28 plus 2025-era Streamable HTTP, with MediaLit's own credential + * and app resolution. */ -export function createMCPSession( - onsessioninitialized: (sessionId: string) => void, - onsessionclosed: (sessionId: string) => void, -): StreamableHTTPServerTransport { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - enableJsonResponse: true, - onsessioninitialized, - onsessionclosed, - }); - const server = new McpServer({ +export function createMedialitMcp(): McpServerKit { + return createMcpServerKit({ name: "MediaLit", - version: "1.0.0", + version: MEDIALIT_MCP_VERSION, description: "MediaLit MCP server — manage media files, storage, and upload settings for one MediaLit app: the app picked when the client was authorized, or the API key's app. Supports listing, inspecting, deleting, and sealing media items, checking the connected account and app (whoami), querying storage usage, generating upload signatures, and configuring media processing settings.", + instructions: + "Every tool acts on one MediaLit app; whoami shows which. Uploads are temporary until sealed. Deleting and sealing media can't be undone: confirm the target with the user, then pass confirm: true.", + authenticate: authenticateMcpRequest, + resolveContext: resolveMcpContext, + onError: ({ error, source }) => captureException({ error, source }), + tools: medialitMcpTools(), }); - registerAllTools(server); - server.connect(transport); - return transport; } diff --git a/apps/api/src/mcp/tool.ts b/apps/api/src/mcp/tool.ts new file mode 100644 index 00000000..c6b4b429 --- /dev/null +++ b/apps/api/src/mcp/tool.ts @@ -0,0 +1,67 @@ +import type { McpToolRisk } from "@codelitdev/mcp-server-kit"; +import { z } from "zod"; +import { hasScope, insufficientScopeMessage, toolScope } from "../auth/scopes"; +import type { MedialitMcpContext, MedialitMcpTool } from "./context"; +import { isToolError, toolError } from "./tools/errors"; + +export type MedialitToolDefinition = { + name: string; + /** Shown to people in place of `name`; directories require one. */ + title: string; + description: string; + risk: McpToolRisk; + inputSchema: z.ZodObject; + outputSchema: z.AnyZodObject; + annotations?: MedialitMcpTool["annotations"]; + handler: (input: { + context: MedialitMcpContext; + args: z.infer>; + signal: AbortSignal; + }) => Promise; +}; + +const confirmSchema = z + .boolean() + .optional() + .describe( + "Set to true once the user has confirmed this action; it can't be undone.", + ); + +/** + * A MediaLit MCP tool. Read tools need the data:read scope and every other + * tool needs data:write; API keys carry both. A destructive tool advertises + * `confirm`, which the kit requires to be `true`. Results are parsed with the + * output schema, which drops fields the schema does not list: the SDK rejects + * a result with properties its advertised schema does not allow. + */ +export function medialitTool( + definition: MedialitToolDefinition, +): MedialitMcpTool { + const { handler, ...tool } = definition; + const scope = toolScope(tool.risk); + return { + ...tool, + inputSchema: + tool.risk === "destructive" + ? tool.inputSchema.extend({ confirm: confirmSchema }) + : tool.inputSchema, + annotations: { openWorldHint: false, ...tool.annotations }, + async handler({ context, args, signal }) { + if (!hasScope(context.scopes, scope)) { + return toolError("forbidden", { + reason: "insufficient_scope", + requiredScope: scope, + message: insufficientScopeMessage(scope), + }); + } + const result = await handler({ + context, + args: args as z.infer>, + signal, + }); + return isToolError(result) + ? result + : tool.outputSchema.parse(result); + }, + }; +} diff --git a/apps/api/src/mcp/tools/__tests__/upload.test.ts b/apps/api/src/mcp/tools/__tests__/upload.test.ts index 4607eee2..a529c15e 100644 --- a/apps/api/src/mcp/tools/__tests__/upload.test.ts +++ b/apps/api/src/mcp/tools/__tests__/upload.test.ts @@ -31,19 +31,13 @@ before(async () => { const PNG_BASE64 = Buffer.from("not-really-a-png").toString("base64"); -function authExtra() { +function context() { return { - authInfo: { - token: "t", - clientId: "c", - scopes: ["data:write"], - extra: { - authKind: "apikey", - userId: "user-1", - user: { _id: "user-1" }, - appId: "app-1", - }, - }, + authKind: "apikey" as const, + userId: "user-1", + user: { _id: "user-1" }, + appId: "app-1", + scopes: ["data:read", "data:write"], }; } @@ -63,14 +57,14 @@ async function uploadWithFileName(fileName: string) { mediaId: "media-1", }); try { - const result = await handleUploadMediaTool( - { + const result = await handleUploadMediaTool({ + context: context(), + args: { fileBase64: PNG_BASE64, fileName, mimeType: "image/png", }, - authExtra(), - ); + }); return { result, seen }; } finally { (mediaService as any).upload = upload; diff --git a/apps/api/src/mcp/tools/errors.ts b/apps/api/src/mcp/tools/errors.ts new file mode 100644 index 00000000..9fe02144 --- /dev/null +++ b/apps/api/src/mcp/tools/errors.ts @@ -0,0 +1,29 @@ +import { + createPlatformError, + type PlatformError, + type PlatformErrorCode, + type PlatformSafeDetails, +} from "@codelitdev/platform"; + +/** A tool's failure: the kit returns it as an `isError` result carrying the + * platform `code`, plus `details` (a stable `reason`, and a `message` for the + * model when the reason alone is not enough). */ +export type ToolError = { error: PlatformError }; + +export function toolError( + code: PlatformErrorCode, + details?: PlatformSafeDetails, +): ToolError { + return { + error: createPlatformError( + code, + details ? { safeDetails: details } : {}, + ), + }; +} + +export const notFound = (): ToolError => toolError("not_found"); + +export function isToolError(value: unknown): value is ToolError { + return Boolean(value && typeof value === "object" && "error" in value); +} diff --git a/apps/api/src/mcp/tools/media.ts b/apps/api/src/mcp/tools/media.ts index ecb763a7..58cd7237 100644 --- a/apps/api/src/mcp/tools/media.ts +++ b/apps/api/src/mcp/tools/media.ts @@ -1,11 +1,11 @@ -import type { McpToolRegistrar } from "../server"; import { z } from "zod"; import mediaService from "../../media/service"; -import * as mediaQueries from "../../media/queries"; +import mediaQueries from "../../media/queries"; import { maxStorageFor } from "../../billing/entitlements"; -import { NOT_FOUND, SUCCESS } from "../../config/strings"; -import { getMcpAuth } from "../auth-context"; -import { AUTH_ERROR, INTERNAL_ERROR } from "./responses"; +import { NOT_ENOUGH_STORAGE, SUCCESS } from "../../config/strings"; +import type { MedialitMcpTool } from "../context"; +import { medialitTool } from "../tool"; +import { notFound, toolError } from "./errors"; import { mediaListSchema, mediaSchema, @@ -13,277 +13,155 @@ import { successMessageSchema, } from "./schemas"; -export function registerMediaTools(server: McpToolRegistrar): void { - server.registerTool( - "list_media", - { - description: - "Returns a paginated list of media items. Optionally filter by access level (public or private) or group label.", - inputSchema: { - page: z - .number() - .int() - .min(1) - .optional() - .describe("Page number (default: 1)"), - limit: z - .number() - .int() - .min(1) - .optional() - .describe("Items per page (default: 10)"), - access: z - .enum(["public", "private"]) - .optional() - .describe("Filter by access level"), - group: z.string().optional().describe("Filter by group label"), - }, - outputSchema: mediaListSchema, - annotations: { - title: "List media", - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, +export const mediaTools = (): MedialitMcpTool[] => [ + medialitTool({ + name: "list_media", + title: "List media", + risk: "read", + description: + "Returns a paginated list of media items. Optionally filter by access level (public or private) or group label.", + inputSchema: z.object({ + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Items per page (default: 10)"), + access: z + .enum(["public", "private"]) + .optional() + .describe("Filter by access level"), + group: z.string().optional().describe("Filter by group label"), + }), + outputSchema: mediaListSchema, + annotations: { idempotentHint: true }, + async handler({ context, args }) { + const mediaItems = await mediaService.getPage({ + userId: context.userId, + appId: context.appId, + access: args.access, + page: args.page || 1, + group: args.group, + recordsPerPage: args.limit || 10, + }); + return { mediaItems }; }, - async (args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; - try { - const currentPage = args.page || 1; - const recordsPerPage = args.limit || 10; - const result = await mediaService.getPage({ - userId, - appId, - access: args.access, - page: currentPage, - group: args.group, - recordsPerPage, - }); - return { - content: [ - { type: "text" as const, text: JSON.stringify(result) }, - ], - structuredContent: { mediaItems: result }, - }; - } catch { - return INTERNAL_ERROR; - } - }, - ); + }), - server.registerTool( - "get_media", - { - description: - "Returns full metadata for a single media item identified by its ID.", - inputSchema: { - mediaId: z.string().describe("Media item ID"), - }, - outputSchema: mediaSchema, - annotations: { - title: "Get media", - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, + medialitTool({ + name: "get_media", + title: "Get media", + risk: "read", + description: + "Returns full metadata for a single media item identified by its ID.", + inputSchema: z.object({ + mediaId: z.string().describe("Media item ID"), + }), + outputSchema: mediaSchema, + annotations: { idempotentHint: true }, + async handler({ context, args }) { + const media = await mediaService.getMediaDetails({ + userId: context.userId, + appId: context.appId, + mediaId: args.mediaId, + }); + return media ?? notFound(); }, - async (args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; - try { - const media = await mediaService.getMediaDetails({ - userId, - appId, - mediaId: args.mediaId, - }); - if (!media) { - return { - content: [ - { - type: "text" as const, - text: NOT_FOUND, - }, - ], - isError: true, - }; - } - return { - content: [ - { type: "text" as const, text: JSON.stringify(media) }, - ], - structuredContent: media as any, - }; - } catch { - return INTERNAL_ERROR; - } - }, - ); + }), - server.registerTool( - "get_media_count", - { - description: - "Returns the total number of media items in the connected app.", - outputSchema: z.object({ count: z.number() }), - annotations: { - title: "Get media count", - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, - }, - async (_args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; - try { - const count = await mediaQueries.getMediaCount({ - userId, - appId, - }); - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ count }), - }, - ], - structuredContent: { count }, - }; - } catch { - return INTERNAL_ERROR; - } + medialitTool({ + name: "get_media_count", + title: "Get media count", + risk: "read", + description: + "Returns the total number of media items in the connected app.", + inputSchema: z.object({}), + outputSchema: z.object({ count: z.number() }), + annotations: { idempotentHint: true }, + async handler({ context }) { + const count = await mediaQueries.getMediaCount({ + userId: context.userId, + appId: context.appId, + }); + return { count }; }, - ); + }), - server.registerTool( - "get_total_storage", - { - description: - "Returns the total storage used by the connected app and the account storage limit, both in bytes.", - outputSchema: storageSchema, - annotations: { - title: "Get total storage", - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, + medialitTool({ + name: "get_total_storage", + title: "Get total storage", + risk: "read", + description: + "Returns the total storage used by the connected app and the account storage limit, both in bytes.", + inputSchema: z.object({}), + outputSchema: storageSchema, + annotations: { idempotentHint: true }, + async handler({ context }) { + const storage = await mediaQueries.getTotalSpace({ + userId: context.userId, + appId: context.appId, + }); + return { storage, maxStorage: await maxStorageFor(context.user) }; }, - handleGetTotalStorageTool, - ); + }), - server.registerTool( - "delete_media", - { - description: - "Permanently deletes a media item. This action cannot be undone.", - inputSchema: { - mediaId: z.string().describe("ID of the media item to delete"), - }, - outputSchema: successMessageSchema, - annotations: { - title: "Delete media", - readOnlyHint: false, - destructiveHint: true, - openWorldHint: true, - }, + medialitTool({ + name: "delete_media", + title: "Delete media", + risk: "destructive", + description: + "Permanently deletes a media item. This action cannot be undone.", + inputSchema: z.object({ + mediaId: z.string().describe("ID of the media item to delete"), + }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { + await mediaService.deleteMedia({ + userId: context.userId, + appId: context.appId, + mediaId: args.mediaId, + }); + return { message: SUCCESS }; }, - async (args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; - try { - await mediaService.deleteMedia({ - userId, - appId, - mediaId: args.mediaId, - }); - const response = { message: SUCCESS }; - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(response), - }, - ], - structuredContent: response, - }; - } catch { - return INTERNAL_ERROR; - } - }, - ); + }), - server.registerTool( - "seal_media", - { - description: - "Finalizes a temporary upload for retention, preventing automatic cleanup. Public uploads become publicly accessible. Already sealed items are returned unchanged.", - inputSchema: { - mediaId: z.string().describe("ID of the media item to seal"), - }, - outputSchema: mediaSchema, - annotations: { - title: "Seal media", - readOnlyHint: false, - destructiveHint: true, - openWorldHint: true, - }, - }, - async (args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; + medialitTool({ + name: "seal_media", + title: "Seal media", + // Sealing can't be undone, and a public upload becomes public. + risk: "destructive", + description: + "Finalizes a temporary upload for retention, preventing automatic cleanup. Public uploads become publicly accessible. Already sealed items are returned unchanged.", + inputSchema: z.object({ + mediaId: z.string().describe("ID of the media item to seal"), + }), + outputSchema: mediaSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { try { - const media = await mediaService.sealMedia({ - userId, - appId, + return await mediaService.sealMedia({ + userId: context.userId, + appId: context.appId, mediaId: args.mediaId, }); - return { - content: [ - { type: "text" as const, text: JSON.stringify(media) }, - ], - structuredContent: media as any, - }; - } catch { - return INTERNAL_ERROR; + } catch (error) { + const message = (error as Error | undefined)?.message; + if (message === "Media not found") return notFound(); + if (message === NOT_ENOUGH_STORAGE) { + return toolError("forbidden", { + reason: "not_enough_storage", + message: NOT_ENOUGH_STORAGE, + }); + } + throw error; } }, - ); -} - -export async function handleGetTotalStorageTool( - _args: unknown, - extra: any, - dependencies = { getTotalSpace: mediaQueries.getTotalSpace }, -) { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { user, userId, appId } = auth; - try { - const storage = await dependencies.getTotalSpace({ userId, appId }); - const response = { - storage, - maxStorage: await maxStorageFor(user), - }; - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(response), - }, - ], - structuredContent: response, - }; - } catch { - return INTERNAL_ERROR; - } -} + }), +]; diff --git a/apps/api/src/mcp/tools/responses.ts b/apps/api/src/mcp/tools/responses.ts deleted file mode 100644 index 0dd7771c..00000000 --- a/apps/api/src/mcp/tools/responses.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const AUTH_ERROR = { - content: [ - { - type: "text" as const, - text: "Authentication required: valid API credentials were not provided.", - }, - ], - isError: true, -}; - -export const INTERNAL_ERROR = { - content: [ - { - type: "text" as const, - text: "An error occurred while processing your request.", - }, - ], - isError: true, -}; diff --git a/apps/api/src/mcp/tools/settings.ts b/apps/api/src/mcp/tools/settings.ts index a81a37c1..8ef2a8e0 100644 --- a/apps/api/src/mcp/tools/settings.ts +++ b/apps/api/src/mcp/tools/settings.ts @@ -1,114 +1,70 @@ -import type { McpToolRegistrar } from "../server"; import { z } from "zod"; import { getMediaSettings } from "../../media-settings/service"; import { updateMediaSettings } from "../../media-settings/queries"; import { SUCCESS } from "../../config/strings"; -import { getMcpAuth } from "../auth-context"; -import { AUTH_ERROR, INTERNAL_ERROR } from "./responses"; +import type { MedialitMcpTool } from "../context"; +import { medialitTool } from "../tool"; import { mediaSettingsSchema, successMessageSchema } from "./schemas"; -export function registerSettingsTools(server: McpToolRegistrar): void { - // get_media_settings - server.registerTool( - "get_media_settings", - { - description: - "Returns the current image processing configuration for the connected app, including WebP conversion settings and thumbnail dimensions.", - outputSchema: mediaSettingsSchema, - annotations: { - title: "Get media settings", - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, - }, - async (_args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; - try { - const settings = await getMediaSettings(userId, appId); - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(settings), - }, - ], - structuredContent: (settings as any) ?? {}, - }; - } catch { - return INTERNAL_ERROR; - } - }, - ); +export const settingsTools = (): MedialitMcpTool[] => [ + medialitTool({ + name: "get_media_settings", + title: "Get media settings", + risk: "read", + description: + "Returns the current image processing configuration for the connected app, including WebP conversion settings and thumbnail dimensions.", + inputSchema: z.object({}), + outputSchema: mediaSettingsSchema, + annotations: { idempotentHint: true }, + handler: ({ context }) => + getMediaSettings(context.userId, context.appId), + }), - // update_media_settings - server.registerTool( - "update_media_settings", - { - description: - "Overwrites image processing settings for the connected app. Supply only the fields you want to change; omitted fields retain their current values.", - inputSchema: { - useWebP: z - .boolean() - .optional() - .describe("Convert uploaded images to WebP format"), - webpOutputQuality: z - .number() - .int() - .min(0) - .max(100) - .optional() - .describe("WebP output quality 0–100"), - thumbnailWidth: z - .number() - .int() - .positive() - .optional() - .describe("Generated thumbnail width in pixels"), - thumbnailHeight: z - .number() - .int() - .positive() - .optional() - .describe("Generated thumbnail height in pixels"), - }, - outputSchema: successMessageSchema, - annotations: { - title: "Update media settings", - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId } = auth; - try { - await updateMediaSettings({ - userId, - appId, - useWebP: args.useWebP, - webpOutputQuality: args.webpOutputQuality, - thumbnailWidth: args.thumbnailWidth, - thumbnailHeight: args.thumbnailHeight, - }); - const response = { message: SUCCESS }; - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(response), - }, - ], - structuredContent: response, - }; - } catch { - return INTERNAL_ERROR; - } + medialitTool({ + name: "update_media_settings", + title: "Update media settings", + // Settings can be set back, and only affect files uploaded later. + risk: "write", + description: + "Overwrites image processing settings for the connected app. Supply only the fields you want to change; omitted fields retain their current values.", + inputSchema: z.object({ + useWebP: z + .boolean() + .optional() + .describe("Convert uploaded images to WebP format"), + webpOutputQuality: z + .number() + .int() + .min(0) + .max(100) + .optional() + .describe("WebP output quality 0–100"), + thumbnailWidth: z + .number() + .int() + .positive() + .optional() + .describe("Generated thumbnail width in pixels"), + thumbnailHeight: z + .number() + .int() + .positive() + .optional() + .describe("Generated thumbnail height in pixels"), + }), + outputSchema: successMessageSchema, + annotations: { idempotentHint: true }, + async handler({ context, args }) { + // The upsert leaves a field it is given as undefined unchanged. + await updateMediaSettings({ + userId: context.userId, + appId: context.appId, + useWebP: args.useWebP as boolean, + webpOutputQuality: args.webpOutputQuality as number, + thumbnailWidth: args.thumbnailWidth as number, + thumbnailHeight: args.thumbnailHeight as number, + }); + return { message: SUCCESS }; }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/signature.ts b/apps/api/src/mcp/tools/signature.ts index 774fdd54..0e31107a 100644 --- a/apps/api/src/mcp/tools/signature.ts +++ b/apps/api/src/mcp/tools/signature.ts @@ -1,56 +1,34 @@ -import type { McpToolRegistrar } from "../server"; import { z } from "zod"; import { generateSignature } from "../../signature/service"; -import { getMcpAuth } from "../auth-context"; -import { AUTH_ERROR, INTERNAL_ERROR } from "./responses"; +import type { MedialitMcpTool } from "../context"; +import { medialitTool } from "../tool"; import { signatureSchema } from "./schemas"; -export function registerSignatureTool(server: McpToolRegistrar): void { - server.registerTool( - "create_upload_signature", - { - description: - "Generates a time-limited HMAC signature that authorizes a direct client-side file upload to MediaLit storage. The returned signature must be passed by the client when initiating the upload.", - inputSchema: { - group: z - .string() - .optional() - .describe( - "Optional group label to associate with the uploaded file", - ), - }, - outputSchema: signatureSchema, - annotations: { - title: "Create upload signature", - readOnlyHint: false, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, +export const signatureTools = (): MedialitMcpTool[] => [ + medialitTool({ + name: "create_upload_signature", + title: "Create upload signature", + risk: "write", + description: + "Generates a time-limited HMAC signature that authorizes a direct client-side file upload to MediaLit storage. The returned signature must be passed by the client when initiating the upload.", + inputSchema: z.object({ + group: z + .string() + .optional() + .describe( + "Optional group label to associate with the uploaded file", + ), + }), + outputSchema: signatureSchema, + annotations: { idempotentHint: true }, + async handler({ context, args }) { + const signature = await generateSignature({ + userId: context.userId, + appId: context.appId, + apiKeyId: context.apiKeyId, + group: args.group, + }); + return { signature }; }, - async (args: any, extra: any) => { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { userId, appId, apiKeyId } = auth; - try { - const signature = await generateSignature({ - userId, - appId, - apiKeyId, - group: args.group, - }); - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ signature }), - }, - ], - structuredContent: { signature } as Record, - }; - } catch { - return INTERNAL_ERROR; - } - }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/upload.ts b/apps/api/src/mcp/tools/upload.ts index b433b64a..2c9574bb 100644 --- a/apps/api/src/mcp/tools/upload.ts +++ b/apps/api/src/mcp/tools/upload.ts @@ -1,45 +1,18 @@ import os from "os"; import path from "path"; import { writeFile, mkdir, copyFile, mkdtemp, rm } from "fs/promises"; -import type { McpToolRegistrar } from "../server"; import { z } from "zod"; import mediaService from "../../media/service"; import { validateUploadConstraints } from "../../media/storage-middleware"; -import { getMcpAuth } from "../auth-context"; -import { AUTH_ERROR } from "./responses"; +import type { MedialitMcpContext, MedialitMcpTool } from "../context"; +import { medialitTool } from "../tool"; +import { toolError } from "./errors"; import { mediaSchema } from "./schemas"; -function toolError(text: string) { - return { - content: [ - { - type: "text" as const, - text, - }, - ], - isError: true, - }; -} - -const BASE64_ERROR = { - content: [ - { - type: "text" as const, - text: "Invalid file data: could not decode base64 content.", - }, - ], - isError: true, -}; - -const UPLOAD_ERROR = { - content: [ - { - type: "text" as const, - text: "An error occurred while uploading the file.", - }, - ], - isError: true, -}; +const BASE64_ERROR = toolError("validation_failed", { + reason: "invalid_base64", + message: "Invalid file data: could not decode base64 content.", +}); function isValidBase64(str: string): boolean { // Base64 must be non-empty and match the expected pattern @@ -48,54 +21,49 @@ function isValidBase64(str: string): boolean { return /^[A-Za-z0-9+/]*={0,2}$/.test(str); } -export function registerUploadTool(server: McpToolRegistrar): void { - server.registerTool( - "upload_media", - { - description: - "Upload a file to MediaLit storage from base64-encoded content. The upload is temporary until sealed.", - inputSchema: { - fileBase64: z - .string() - .min(1, "Base64 content cannot be empty") - .describe("Base64-encoded file content"), - fileName: z - .string() - .min(1, "Filename cannot be empty") - .describe("Filename with extension"), - mimeType: z - .string() - .min(1, "MIME type cannot be empty") - .describe("MIME type of the file"), - caption: z - .string() - .optional() - .describe("Optional caption for the file"), - access: z - .enum(["public", "private"]) - .optional() - .describe("Access level (default: private)"), - group: z - .string() - .optional() - .describe("Group label for organizing files"), - }, - outputSchema: mediaSchema, - annotations: { - title: "Upload media", - readOnlyHint: false, - destructiveHint: false, - openWorldHint: true, - }, - }, - handleUploadMediaTool, - ); -} +const uploadInputSchema = z.object({ + fileBase64: z + .string() + .min(1, "Base64 content cannot be empty") + .describe("Base64-encoded file content"), + fileName: z + .string() + .min(1, "Filename cannot be empty") + .describe("Filename with extension"), + mimeType: z + .string() + .min(1, "MIME type cannot be empty") + .describe("MIME type of the file"), + caption: z.string().optional().describe("Optional caption for the file"), + access: z + .enum(["public", "private"]) + .optional() + .describe("Access level (default: private)"), + group: z.string().optional().describe("Group label for organizing files"), +}); + +export const uploadTools = (): MedialitMcpTool[] => [ + medialitTool({ + name: "upload_media", + title: "Upload media", + risk: "write", + description: + "Upload a file to MediaLit storage from base64-encoded content. The upload is temporary until sealed.", + inputSchema: uploadInputSchema, + outputSchema: mediaSchema, + annotations: { openWorldHint: true }, + handler: handleUploadMediaTool, + }), +]; -export async function handleUploadMediaTool(args: any, extra: any) { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { user, userId, appId } = auth; +export async function handleUploadMediaTool({ + context, + args, +}: { + context: MedialitMcpContext; + args: z.infer; +}) { + const { user, userId, appId } = context; // Validate base64 if (!isValidBase64(args.fileBase64)) { @@ -119,7 +87,12 @@ export async function handleUploadMediaTool(args: any, extra: any) { user, }); if (!validation.valid) { - return toolError(validation.error); + return toolError( + validation.reason === "file_size_exceeded" + ? "validation_failed" + : "forbidden", + { reason: validation.reason, message: validation.error }, + ); } const tempDir = await mkdtemp(path.join(os.tmpdir(), "mcp-upload-")); @@ -150,23 +123,11 @@ export async function handleUploadMediaTool(args: any, extra: any) { caption: args.caption || "", group: args.group, }); - const media = await mediaService.getMediaDetails({ + return await mediaService.getMediaDetails({ userId, appId, mediaId, }); - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(media), - }, - ], - structuredContent: media as any, - }; - } catch (err) { - return UPLOAD_ERROR; } finally { rm(tempDir, { recursive: true, force: true }).catch(() => { // Ignore cleanup errors diff --git a/apps/api/src/mcp/tools/whoami.ts b/apps/api/src/mcp/tools/whoami.ts index 3015b00b..baff93d6 100644 --- a/apps/api/src/mcp/tools/whoami.ts +++ b/apps/api/src/mcp/tools/whoami.ts @@ -1,69 +1,47 @@ +import { z } from "zod"; import { getAppById } from "@/db"; import { maxStorageFor } from "../../billing/entitlements"; -import * as mediaQueries from "../../media/queries"; -import { getMcpAuth } from "../auth-context"; -import type { McpToolRegistrar } from "../server"; -import { AUTH_ERROR, INTERNAL_ERROR } from "./responses"; +import mediaQueries from "../../media/queries"; +import type { MedialitMcpTool } from "../context"; +import { medialitTool } from "../tool"; +import { toolError } from "./errors"; import { whoamiSchema } from "./schemas"; -export function registerWhoamiTool(server: McpToolRegistrar): void { - server.registerTool( - "whoami", - { - description: - "Returns the signed-in account, the app this connection manages files in, and that app's file count and storage use against the account limit (bytes).", - outputSchema: whoamiSchema, - annotations: { - title: "Who am I", - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, +export const whoamiTools = (): MedialitMcpTool[] => [ + medialitTool({ + name: "whoami", + title: "Who am I", + risk: "read", + description: + "Returns the signed-in account, the app this connection manages files in, and that app's file count and storage use against the account limit (bytes).", + inputSchema: z.object({}), + outputSchema: whoamiSchema, + annotations: { idempotentHint: true }, + async handler({ context }) { + const { authKind, user, userId, appId } = context; + const [app, files, storage] = await Promise.all([ + getAppById(appId), + mediaQueries.getMediaCount({ userId, appId }), + mediaQueries.getTotalSpace({ userId, appId }), + ]); + if (!app || app.deleted) { + return toolError("tenant_forbidden", { + reason: "app_not_found", + }); + } + // Identify the app by its public ID; never an internal ID or a key. + return { + auth: authKind, + email: user.email, + app: { + id: app.appId, + name: app.name, + default: Boolean(app.default), + }, + files, + storage, + maxStorage: await maxStorageFor(user), + }; }, - handleWhoamiTool, - ); -} - -export async function handleWhoamiTool( - _args: unknown, - extra: any, - dependencies = { - getApp: getAppById, - getMediaCount: mediaQueries.getMediaCount, - getTotalSpace: mediaQueries.getTotalSpace, - }, -) { - const auth = getMcpAuth(extra); - if (!auth) return AUTH_ERROR; - const { authKind, user, userId, appId } = auth; - try { - const [app, files, storage] = await Promise.all([ - dependencies.getApp(appId), - dependencies.getMediaCount({ userId, appId }), - dependencies.getTotalSpace({ userId, appId }), - ]); - if (!app || app.deleted) return AUTH_ERROR; - // Identify the app by its public ID; never an internal ID or a key. - const response = { - auth: authKind, - email: user.email, - app: { - id: app.appId, - name: app.name, - default: Boolean(app.default), - }, - files, - storage, - maxStorage: await maxStorageFor(user), - }; - return { - content: [ - { type: "text" as const, text: JSON.stringify(response) }, - ], - structuredContent: response, - }; - } catch { - return INTERNAL_ERROR; - } -} + }), +]; diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 305bca2a..8771c3a3 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -21,7 +21,7 @@ -# This is NOT the Next.js you know +## This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. diff --git a/bun.lock b/bun.lock index d73f0b3d..3cf50172 100644 --- a/bun.lock +++ b/bun.lock @@ -30,16 +30,15 @@ "@aws-sdk/s3-request-presigner": "^3.1146.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.4.1", - "@codelitdev/mcp-server-kit": "0.4.1", - "@codelitdev/oauth-server-kit": "0.4.1", - "@codelitdev/observability": "0.4.1", - "@codelitdev/platform": "0.4.1", + "@codelitdev/billing": "0.8.0", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@medialit/images": "workspace:*", "@medialit/models": "workspace:*", "@medialit/thumbnail": "workspace:*", "@medialit/utils": "workspace:*", - "@modelcontextprotocol/sdk": "1.32.0", "@tus/file-store": "^2.1.1", "@tus/server": "^2.4.5", "better-auth": "1.7.7", @@ -59,8 +58,9 @@ "zod": "3.25.76", }, "devDependencies": { - "@codelitdev/platform-conformance": "0.4.1", + "@codelitdev/platform-conformance": "0.8.0", "@electric-sql/pglite": "0.5.8", + "@modelcontextprotocol/client": "2.4.0", "@types/cors": "^2.8.12", "@types/express": "^4.17.20", "@types/express-fileupload": "^1.2.2", @@ -562,19 +562,19 @@ "@cnakazawa/watch": ["@cnakazawa/watch@1.0.4", "", { "dependencies": { "exec-sh": "0.3.6", "minimist": "1.2.8" }, "bin": { "watch": "cli.js" } }, "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ=="], - "@codelitdev/billing": ["@codelitdev/billing@0.4.1", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-xWd13SsfWcTumcM1wR4UAdX6U40wYGZzYOUmA5TweMuuRArOntr85M3lBJpUH63TO+r+LAh3PiZYdjldEu94fg=="], + "@codelitdev/billing": ["@codelitdev/billing@0.8.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-xq6AzksmRG2oVyzgwqxRUSxU8QaZcj4WDv9rOHXfebQhqFjkMV9X46QGDTP9s0LxSTgriLY03JPQMT9+aOsqZg=="], "@codelitdev/design-system": ["@codelitdev/design-system@0.4.1", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-s7z7BRa6u9O0Tlh3RJvLkc08+ojgAbtDjLf/RNB6GBnqnS7XrFM7ms1ZTZthcqFiI7+D4MVVVB6MdH958az5Ug=="], - "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.4.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" }, "peerDependencies": { "@codelitdev/platform": ">=0.4.1" } }, "sha512-itWudClAnlpHZgGnvCC3f3bNjHvaYlZlHKfUl6nbRVlumPs98CnutFjxieK6c+kexye4Aj1TyWcRtTxD+DfveQ=="], + "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.8.0", "", { "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.8.0" } }, "sha512-k3c/lP2njh4HxxYgLLWcpPf41N1QU7w5eddcf6ZP5vRPxVQskZDrU8XKclTXbNcEuONvuq96hMrwoQsWW2tgFw=="], - "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.4.1", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-mUOBtMlWLkOVS7sjdo7HgS/H0Nf5A3Nc+0pbt97HC6GK89YpSC3eaXKT6Ro4ta/bIphNbCgcSAl5lxIQf2SbFg=="], + "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.8.0", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-hEVG5G0tAacDHv44evzKPM+AJQmu2bND2AtspAJFlRuE9QLJteu/1N7aLgGcPlDdA1+YI5+s0J0fbXpBT8rPsQ=="], - "@codelitdev/observability": ["@codelitdev/observability@0.4.1", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-eGXXu5MHGZxYcm9VraOoZL8tN/yNEQkp9wC1j9LRgUOWJHN6Eiv/zhehM7Beecee5I9HKl7dX1zSfMuT5mFcHA=="], + "@codelitdev/observability": ["@codelitdev/observability@0.8.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-BX7STE1SqwPvuTBSyIpADkw7Ef3haWgTGoKFuOMxO5t+0Ow3bp/8hmk/dcpjNKK+oV2q2sDKbcVDEgWrmdUVCg=="], - "@codelitdev/platform": ["@codelitdev/platform@0.4.1", "", { "peerDependencies": { "@codelitdev/billing": ">=0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-vHY32w9Jjmdm7CvIII1Z/Q4eTTsLmVGCpLiU8u8RvKEomFSUhaISrnTATh5gfq1xcWBzXsLj76XpUHoubZwX3g=="], + "@codelitdev/platform": ["@codelitdev/platform@0.8.0", "", { "peerDependencies": { "@codelitdev/billing": ">=0.8.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-RqPgDtZRvJ81IbVyeZFDG8Y+qcNTKuCt3O7mq6gvMoln/og0Ma/P9MCJ6zx09KLLXjFYhzUADP/evThwF62h7g=="], - "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.4.1", "", { "peerDependencies": { "@codelitdev/platform": ">=0.4.1" } }, "sha512-rVebA+uBvzADIBVATVl42U/ifEuaNLJYfD7+vF71gBt0ms4wKi1wAwxLol6aopBSR2+DwL9+Mq//COZBSHTchQ=="], + "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.8.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.8.0" } }, "sha512-gmbwoPs23W/Zm2WuRKWewQ7rm1dB7dMyY3F2pNxnMi8vhBKprNuQNK3qb9++rwiawTjhsDlpuyLD6BPTKgseAw=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -676,8 +676,6 @@ "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "9.3.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], - "@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], @@ -808,7 +806,11 @@ "@medialit/web": ["@medialit/web@workspace:apps/web"], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], + + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.2", "", { "dependencies": { "sparse-bitfield": "3.0.3" } }, "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg=="], @@ -1190,8 +1192,6 @@ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "ajv-formats": ["ajv-formats@3.0.1", "", { "peerDependencies": { "ajv": "^8.0.0" }, "optionalPeers": ["ajv"] }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "1.1.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], @@ -1634,8 +1634,6 @@ "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "1.1.0" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], @@ -1750,8 +1748,6 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], - "hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], "html-encoding-sniffer": ["html-encoding-sniffer@1.0.2", "", { "dependencies": { "whatwg-encoding": "1.0.5" } }, "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw=="], @@ -1788,8 +1784,6 @@ "ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "1.1.2", "debug": "4.4.0", "denque": "2.1.0", "lodash.defaults": "4.2.0", "lodash.isarguments": "3.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="], - "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-accessor-descriptor": ["is-accessor-descriptor@1.0.1", "", { "dependencies": { "hasown": "2.0.2" } }, "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA=="], @@ -1848,8 +1842,6 @@ "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "1.0.4", "gopd": "1.2.0", "has-tostringtag": "1.0.2", "hasown": "2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -1986,8 +1978,6 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], @@ -2398,7 +2388,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.1", "iconv-lite": "0.7.2", "unpipe": "1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], @@ -2456,8 +2446,6 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], @@ -2488,8 +2476,6 @@ "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "4.4.3", "depd": "2.0.0", "is-promise": "4.0.0", "parseurl": "1.3.3", "path-to-regexp": "8.3.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "rsvp": ["rsvp@4.8.5", "", {}, "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "1.2.3" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -2936,17 +2922,17 @@ "@medialit/scripts/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "@medialit/web/@codelitdev/observability": ["@codelitdev/observability@0.4.1", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-eGXXu5MHGZxYcm9VraOoZL8tN/yNEQkp9wC1j9LRgUOWJHN6Eiv/zhehM7Beecee5I9HKl7dX1zSfMuT5mFcHA=="], + "@medialit/web/eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "4.5.1", "@eslint-community/regexpp": "4.12.1", "@eslint/eslintrc": "2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "0.13.0", "@humanwhocodes/module-importer": "1.0.1", "@nodelib/fs.walk": "1.2.8", "@ungap/structured-clone": "1.3.0", "ajv": "6.12.6", "chalk": "4.1.2", "cross-spawn": "7.0.6", "debug": "4.4.0", "doctrine": "3.0.0", "escape-string-regexp": "4.0.0", "eslint-scope": "7.2.2", "eslint-visitor-keys": "3.4.3", "espree": "9.6.1", "esquery": "1.6.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "6.0.1", "find-up": "5.0.0", "glob-parent": "6.0.2", "globals": "13.24.0", "graphemer": "1.4.0", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "is-path-inside": "3.0.3", "js-yaml": "4.1.0", "json-stable-stringify-without-jsonify": "1.0.1", "levn": "0.4.1", "lodash.merge": "4.6.2", "minimatch": "3.1.2", "natural-compare": "1.4.0", "optionator": "0.9.4", "strip-ansi": "6.0.1", "text-table": "0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], "@medialit/web/tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "arg": "5.0.2", "chokidar": "3.6.0", "didyoumean": "1.2.2", "dlv": "1.1.3", "fast-glob": "3.3.3", "glob-parent": "6.0.2", "is-glob": "4.0.3", "jiti": "1.21.7", "lilconfig": "3.1.3", "micromatch": "4.0.8", "normalize-path": "3.0.0", "object-hash": "3.0.0", "picocolors": "1.1.1", "postcss": "8.5.3", "postcss-import": "15.1.0", "postcss-js": "4.0.1", "postcss-load-config": "4.0.2", "postcss-nested": "6.2.0", "postcss-selector-parser": "6.1.2", "resolve": "1.22.10", "sucrase": "3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@modelcontextprotocol/client/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "2.0.0", "body-parser": "2.2.2", "content-disposition": "1.1.0", "content-type": "1.0.5", "cookie": "0.7.1", "cookie-signature": "1.2.2", "debug": "4.4.3", "depd": "2.0.0", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "finalhandler": "2.1.1", "fresh": "2.0.0", "http-errors": "2.0.0", "merge-descriptors": "2.0.0", "mime-types": "3.0.2", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "1.3.3", "proxy-addr": "2.0.7", "qs": "6.15.2", "range-parser": "1.2.1", "router": "2.2.0", "send": "1.2.1", "serve-static": "2.2.1", "statuses": "2.0.1", "type-is": "2.1.0", "vary": "1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "@modelcontextprotocol/core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - "@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], - - "@modelcontextprotocol/sdk/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "@modelcontextprotocol/server/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -3116,8 +3102,6 @@ "body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.2", "toidentifier": "1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], - "browser-resolve/resolve": ["resolve@1.1.7", "", {}, "sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -3326,8 +3310,6 @@ "raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.2", "toidentifier": "1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "read-pkg/path-type": ["path-type@3.0.0", "", { "dependencies": { "pify": "3.0.0" } }, "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg=="], @@ -3346,8 +3328,6 @@ "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "5.0.1" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "router/path-to-regexp": ["path-to-regexp@8.4.0", "", {}, "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg=="], - "sane/anymatch": ["anymatch@2.0.0", "", { "dependencies": { "micromatch": "3.1.10", "normalize-path": "2.1.1" } }, "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw=="], "sane/execa": ["execa@1.0.0", "", { "dependencies": { "cross-spawn": "6.0.6", "get-stream": "4.1.0", "is-stream": "1.1.0", "npm-run-path": "2.0.2", "p-finally": "1.0.0", "signal-exit": "3.0.7", "strip-eof": "1.0.0" } }, "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA=="], @@ -3540,28 +3520,6 @@ "@medialit/web/tailwindcss/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - - "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "on-finished": "2.4.1", "parseurl": "1.3.3", "statuses": "2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "fresh": "2.0.0", "http-errors": "2.0.1", "mime-types": "3.0.2", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "1.2.1", "statuses": "2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "2.0.0", "escape-html": "1.0.3", "parseurl": "1.3.3", "send": "1.2.1" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "2.0.0", "media-typer": "1.1.0", "mime-types": "3.0.2" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3998,22 +3956,6 @@ "@medialit/web/tailwindcss/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "@modelcontextprotocol/sdk/express/body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.2", "toidentifier": "1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "@modelcontextprotocol/sdk/express/send/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.2", "toidentifier": "1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "@modelcontextprotocol/sdk/express/send/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "babel-jest/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -4150,8 +4092,6 @@ "@medialit/web/eslint/file-entry-cache/flat-cache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "7.2.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "@modelcontextprotocol/sdk/express/body-parser/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "babel-jest/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/docs/prds/apikey.md b/docs/prds/apikey.md index a2ed899b..954d33ad 100644 --- a/docs/prds/apikey.md +++ b/docs/prds/apikey.md @@ -406,7 +406,7 @@ could then keep uploading for up to a day afterwards. SendLit and FrontLit have no credentials derived from a key, so this is specific to MediaLit. - **Creating.** `generateSignature` stores `app_id` and, for API-key callers, - `api_key_id`: `req.apiKeyId` over REST, `getMcpAuth(extra).apiKeyId` over + `api_key_id`: `req.apiKeyId` over REST, the tool context's `apiKeyId` over MCP. Signatures minted through OAuth or the dashboard leave it null. - **Revoking a key.** Deleting the key's signatures happens in the same diff --git a/frontlit/docs/for-agents/mcp-server.md b/frontlit/docs/for-agents/mcp-server.md index a9734027..47f36a98 100644 --- a/frontlit/docs/for-agents/mcp-server.md +++ b/frontlit/docs/for-agents/mcp-server.md @@ -115,7 +115,9 @@ To prevent cluttering of the media registry with unfinished uploads, MediaLit en ## Tools Reference -The MediaLit MCP server exposes the following tools to connected agents: +The MediaLit MCP server exposes the following tools to connected agents. + +`seal_media` and `delete_media` can't be undone, so they only run with `confirm: true`. Agents should check with you first, then pass it. ### Media Management @@ -157,6 +159,7 @@ Finalizes a temporary media upload, allowing it to persist and show up in lists/ - **Inputs:** - `mediaId` _(string, required)_: The unique ID of the media item to seal. + - `confirm` _(boolean, required to run)_: `true` once you have confirmed sealing. - **Output:** The updated media document. #### `delete_media` @@ -165,6 +168,7 @@ Permanently deletes a media file and all generated derivatives (thumbnails, WebP - **Inputs:** - `mediaId` _(string, required)_: The unique ID of the media item to delete. + - `confirm` _(boolean, required to run)_: `true` once you have confirmed the deletion. - **Output:** `{ deleted: true, mediaId: string }` --- diff --git a/packages/integration-tests/src/mcp.ts b/packages/integration-tests/src/mcp.ts index c0ba3030..2c3b069a 100644 --- a/packages/integration-tests/src/mcp.ts +++ b/packages/integration-tests/src/mcp.ts @@ -4,8 +4,8 @@ * MEDIALIT_APIKEY=... MEDIALIT_SERVER=localhost:8000 \ * bun --filter @medialit/integration-tests test:mcp * - * This exercises the MCP Streamable HTTP endpoint and deletes only media - * created by this run. + * This exercises the stateless MCP Streamable HTTP endpoint and deletes only + * media created by this run. */ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; @@ -76,10 +76,10 @@ function mediaSettings(value: unknown): MediaSettings { }; } -class McpSession { - private sessionId?: string; +/** A 2025-era Streamable HTTP client. The server is stateless: it never + * issues a session ID, so every request stands alone. */ +class McpClient { private protocolVersion?: string; - private lastSessionId?: string; private nextId = 1; constructor( @@ -96,7 +96,6 @@ class McpSession { "content-type": "application/json", "x-medialit-apikey": this.apiKey, }); - if (this.sessionId) headers.set("mcp-session-id", this.sessionId); if (this.protocolVersion) { headers.set("mcp-protocol-version", this.protocolVersion); } @@ -108,8 +107,11 @@ class McpSession { redirect: "error", signal: AbortSignal.timeout(30_000), }); - this.lastSessionId = - response.headers.get("mcp-session-id") ?? this.lastSessionId; + assert.equal( + response.headers.get("mcp-session-id"), + null, + "The MCP server must not issue sessions", + ); const body = await response.text(); if (response.status !== expectedStatus) { throw new Error( @@ -156,7 +158,6 @@ class McpSession { "MCP initialize returned an error", ); const result = object(response.result, "initialize result"); - this.sessionId = nonemptyText(this.lastSessionId, "MCP session ID"); this.protocolVersion = nonemptyText( result.protocolVersion, "MCP protocol version", @@ -209,30 +210,6 @@ class McpSession { } return object(result.structuredContent, `${name} structuredContent`); } - - async close(): Promise { - if (!this.sessionId) return; - const headers = new Headers({ "x-medialit-apikey": this.apiKey }); - headers.set("mcp-session-id", this.sessionId); - if (this.protocolVersion) { - headers.set("mcp-protocol-version", this.protocolVersion); - } - const response = await fetch(this.endpoint, { - method: "DELETE", - headers, - redirect: "error", - signal: AbortSignal.timeout(30_000), - }); - if (response.status !== 200) { - const body = (await response.text()) - .split(this.apiKey) - .join("[redacted]"); - throw new Error( - `MCP session close: expected HTTP 200, got ${response.status}: ${body.slice(0, 1000)}`, - ); - } - this.sessionId = undefined; - } } export async function runMcpIntegrationTests( @@ -285,7 +262,7 @@ export async function runMcpIntegrationTests( let settingsToRestore: MediaSettings | undefined; let failure: unknown; const cleanupErrors: unknown[] = []; - const client = new McpSession(endpoint, apiKey); + const client = new McpClient(endpoint, apiKey); async function storage(): Promise { const result = await client.callTool("get_total_storage"); @@ -426,6 +403,11 @@ export async function runMcpIntegrationTests( assert.equal(sealAnnotations.readOnlyHint, false); assert.equal(sealAnnotations.destructiveHint, true); assert.equal(sealAnnotations.openWorldHint, true); + const sealInput = object(sealTool.inputSchema, "seal_media input"); + assert( + object(sealInput.properties, "seal_media properties").confirm, + "Destructive tools must advertise confirm", + ); const originalSettings = await getMediaSettings(); settingsToRestore = originalSettings; @@ -488,13 +470,25 @@ export async function runMcpIntegrationTests( [], "Signed draft uploads must stay hidden", ); + // Destructive tools refuse to run until the user has confirmed. + const unconfirmed = await client.callToolResult("delete_media", { + mediaId: signedMediaId, + }); + assert.equal(unconfirmed.isError, true); + assert.deepEqual( + object(unconfirmed.structuredContent, "refusal").details, + { confirmation: "required" }, + ); const signedDelete = await client.callTool("delete_media", { mediaId: signedMediaId, + confirm: true, }); assert.equal(signedDelete.message, "success"); pending.delete(signedMediaId); await checkTotals(0, 0); - log("PASS create_upload_signature and delete_media on a signed draft"); + log( + "PASS create_upload_signature, and delete_media on a signed draft after confirmation", + ); uploadAttempted = true; const filename = `${group}.png`; @@ -522,10 +516,16 @@ export async function runMcpIntegrationTests( await checkTotals(png.length, 0); log("PASS upload_media and draft visibility"); - const sealed = await client.callTool("seal_media", { mediaId }); + const sealed = await client.callTool("seal_media", { + mediaId, + confirm: true, + }); assert.equal(sealed.mediaId, mediaId); assert.equal(sealed.access, "private"); - const sealedAgain = await client.callTool("seal_media", { mediaId }); + const sealedAgain = await client.callTool("seal_media", { + mediaId, + confirm: true, + }); assert.equal(sealedAgain.mediaId, mediaId); assert.equal(sealedAgain.access, "private"); const retrieved = await client.callTool("get_media", { mediaId }); @@ -540,7 +540,10 @@ export async function runMcpIntegrationTests( await checkTotals(png.length, 1); log("PASS seal_media, get_media, list_media and storage/count totals"); - const deleted = await client.callTool("delete_media", { mediaId }); + const deleted = await client.callTool("delete_media", { + mediaId, + confirm: true, + }); assert.equal(deleted.message, "success"); pending.delete(mediaId); const missing = await client.callToolResult("get_media", { mediaId }); @@ -563,7 +566,10 @@ export async function runMcpIntegrationTests( } for (const mediaId of pending) { try { - await client.callTool("delete_media", { mediaId }); + await client.callTool("delete_media", { + mediaId, + confirm: true, + }); pending.delete(mediaId); log(`CLEANUP deleted ${mediaId}`); } catch (error) { @@ -584,12 +590,6 @@ export async function runMcpIntegrationTests( log(`CLEANUP verification failed: ${String(error)}`); } } - try { - await client.close(); - } catch (error) { - cleanupErrors.push(error); - log(`CLEANUP failed to close MCP session: ${String(error)}`); - } } if (failure) throw failure; @@ -599,5 +599,5 @@ export async function runMcpIntegrationTests( "MCP integration cleanup failed", ); } - log("PASS MediaLit MCP lifecycle; media and session cleaned up"); + log("PASS MediaLit MCP lifecycle; media cleaned up"); } From 04c62fa0b26243daece4c70945e859227b55912b Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 20:06:37 +0530 Subject: [PATCH 2/2] Check the Turnstile script by exact string and drop an unused test import CodeQL flags an unanchored regex used on a URL, even in a test. --- apps/api/src/__tests__/login-captcha.test.ts | 8 ++++++-- apps/api/src/auth/__tests__/middleware.test.ts | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/api/src/__tests__/login-captcha.test.ts b/apps/api/src/__tests__/login-captcha.test.ts index aba5f807..d7498bc6 100644 --- a/apps/api/src/__tests__/login-captcha.test.ts +++ b/apps/api/src/__tests__/login-captcha.test.ts @@ -75,8 +75,12 @@ test("the login page renders the Turnstile widget", async () => { const response = await fetch(`${origin}/login`); assert.equal(response.status, 200); const html = await response.text(); - assert.match(html, new RegExp(`data-sitekey="${siteKey}"`)); - assert.match(html, /challenges\.cloudflare\.com\/turnstile\/v0\/api\.js/); + assert.ok(html.includes(`data-sitekey="${siteKey}"`)); + assert.ok( + html.includes( + '