From 050d1f99133451e1835cd7117e880353bd97aca7 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 8 Oct 2026 17:14:18 -0700 Subject: [PATCH] Fix qualified painting thumbnails and MCP initialization notifications --- system/backend/thumbnail.mjs | 9 +++- system/netlify/functions/mcp-remote.mjs | 10 ++++ system/tests/production-endpoints.test.mjs | 27 ++++++++++ system/tests/thumbnail.test.mjs | 61 ++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 system/tests/thumbnail.test.mjs diff --git a/system/backend/thumbnail.mjs b/system/backend/thumbnail.mjs index 12a157150c..4b752f157f 100755 --- a/system/backend/thumbnail.mjs +++ b/system/backend/thumbnail.mjs @@ -72,11 +72,18 @@ export async function generateThumbnail(source, options = {}) { * @param {string} slug - Painting slug (timestamp or short code) * @param {string} handleOrCode - User handle (@username) or user code (acXXXXX) - optional, for user paintings * @param {Object} options - Options (same as generateThumbnail) + * @param {string} options.userId - Owner ID for a fully qualified painting key * @returns {Promise} Thumbnail buffer */ export async function getThumbnailFromSlug(slug, handleOrCode = null, options = {}) { const size = options.size || 512; - const cleanSlug = slug.replace(/\.(png|zip)$/i, ""); + let cleanSlug = slug.replace(/\.(png|zip)$/i, ""); + // Stored slugs can already contain the owner's painting directory. + // Strip only this owner's prefix before adding the handle route below. + const ownerPrefix = options.userId && `${options.userId}/painting/`; + if (handleOrCode && ownerPrefix && cleanSlug.startsWith(ownerPrefix)) { + cleanSlug = cleanSlug.slice(ownerPrefix.length); + } const isDev = process.env.CONTEXT === "dev"; let imageUrl; diff --git a/system/netlify/functions/mcp-remote.mjs b/system/netlify/functions/mcp-remote.mjs index 9cc43e6245..1a53d57350 100644 --- a/system/netlify/functions/mcp-remote.mjs +++ b/system/netlify/functions/mcp-remote.mjs @@ -790,6 +790,16 @@ export async function handler(event, context) { const message = JSON.parse(event.body || "{}"); console.log(`🔧 MCP method: ${message.method}`); + // Initialization is a one-way notification, not a method call. + if (message.method === "notifications/initialized") { + if (message.jsonrpc !== "2.0" || Object.hasOwn(message, "id") || + (message.params !== undefined && (!message.params || + typeof message.params !== "object" || Array.isArray(message.params)))) { + return respond(400, { error: "Invalid initialized notification" }, corsHeaders); + } + return { statusCode: 202, headers: corsHeaders, body: "" }; + } + const result = await handleMCPMessage(message, authToken); const response = { jsonrpc: "2.0", id: message.id, result }; diff --git a/system/tests/production-endpoints.test.mjs b/system/tests/production-endpoints.test.mjs index f26e015571..bfa6edb7b5 100644 --- a/system/tests/production-endpoints.test.mjs +++ b/system/tests/production-endpoints.test.mjs @@ -30,6 +30,33 @@ async function load(name, { env = {}, mocks = {}, ...options } = {}) { return module.namespace.handler; } +test("MCP accepts initialized notifications without a JSON-RPC reply and keeps request responses", async () => { + const handler = await load("mcp-remote.mjs", { mocks: { "../../backend/http.mjs": http } }); + const post = message => handler({ httpMethod: "POST", path: "/mcp", headers: {}, body: JSON.stringify(message) }); + const initialized = await post({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + assert.equal(initialized.statusCode, 200); + assert.equal(JSON.parse(initialized.body).id, 0); + for (const params of [undefined, {}, { _meta: { fixture: true } }]) { + const response = await post({ jsonrpc: "2.0", method: "notifications/initialized", params }); + assert.equal(response.statusCode, 202); + assert.equal(response.body, ""); + assert.equal(response.headers["Access-Control-Allow-Origin"], "*"); + } + const tools = await post({ jsonrpc: "2.0", id: "tools", method: "tools/list" }); + assert.equal(tools.statusCode, 200); + assert.equal(JSON.parse(tools.body).id, "tools"); + assert.ok(JSON.parse(tools.body).result.tools.length > 0); +}); + +test("MCP does not accept malformed initialized notifications", async () => { + const handler = await load("mcp-remote.mjs", { mocks: { "../../backend/http.mjs": http } }); + const valid = { jsonrpc: "2.0", method: "notifications/initialized" }; + for (const patch of [{ jsonrpc: "1.0" }, { id: 0 }, { id: null }, { params: null }, { params: [] }, { params: "bad" }]) { + const response = await handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ ...valid, ...patch }) }); + assert.equal(response.statusCode, 400); + } +}); + test("mood lists return a successful empty collection for handles without moods", async () => { let records = [], failure, disconnected = 0; const database = { disconnect: async () => { disconnected++; } }; diff --git a/system/tests/thumbnail.test.mjs b/system/tests/thumbnail.test.mjs new file mode 100644 index 0000000000..6d74e58375 --- /dev/null +++ b/system/tests/thumbnail.test.mjs @@ -0,0 +1,61 @@ +// node --experimental-vm-modules --test system/tests/thumbnail.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import sharp from "sharp"; + +const source = await readFile(new URL("../backend/thumbnail.mjs", import.meta.url), "utf8"); +const original = await sharp({ create: { width: 2, height: 1, channels: 3, background: "red" } }).png().toBuffer(); + +async function fixture({ fallback = false } = {}) { + const urls = []; + const context = vm.createContext({ Buffer, process: { env: {}, argv: [] }, console: { log() {}, warn() {} } }); + const got = async url => { + urls.push(url); + if (fallback && urls.length === 1) throw Error("Pixel temporarily unavailable"); + return { body: original }; + }; + const gotModule = new vm.SyntheticModule(["got"], function () { this.setExport("got", got); }, { context }); + await gotModule.link(() => { throw Error("Unexpected import"); }); + await gotModule.evaluate(); + const module = new vm.SourceTextModule(source, { context, importModuleDynamically: () => gotModule }); + await module.link(name => { + assert.equal(name, "sharp"); + return new vm.SyntheticModule(["default"], function () { this.setExport("default", sharp); }, { context }); + }); + await module.evaluate(); + return { urls, thumbnail: module.namespace.getThumbnailFromSlug }; +} + +test("bare and owner-qualified painting slugs request the same thumbnail", async () => { + const slug = "2026.10.08.14.06.40.966"; + for (const key of [slug, `${slug}.png`, `auth0|fixture/painting/${slug}`, `auth0|fixture/painting/${slug}.png`]) { + const f = await fixture(); + const image = await f.thumbnail(key, "@fixture", { userId: "auth0|fixture" }); + assert.equal(f.urls[0], `https://aesthetic.computer/api/pixel/512:contain/@fixture/painting/${slug}.png`); + assert.equal(f.urls.length, 1); + assert.deepEqual(image, original); + } +}); + +test("thumbnail fallback uses the same normalized painting key and resizes the image", async () => { + const f = await fixture({ fallback: true }); + const image = await f.thumbnail("auth0|fixture/painting/example.png", "fixture", { userId: "auth0|fixture", size: 16 }); + assert.deepEqual(f.urls, [ + "https://aesthetic.computer/api/pixel/16:contain/@fixture/painting/example.png", + "https://aesthetic.computer/media/@fixture/painting/example.png", + ]); + const metadata = await sharp(image).metadata(); + assert.equal(metadata.width, 16); + assert.equal(metadata.height, 16); +}); + +test("guest paintings keep their public bucket and unrelated owner prefixes are not stripped", async () => { + const guest = await fixture(); + await guest.thumbnail("guest.png", null, { size: 16 }); + assert.deepEqual(guest.urls, ["https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/guest.png"]); + const other = await fixture(); + await other.thumbnail("auth0|other/painting/example", "fixture", { userId: "auth0|fixture" }); + assert.equal(other.urls[0], "https://aesthetic.computer/api/pixel/512:contain/@fixture/painting/auth0|other/painting/example.png"); +}); -- 2.51.2