diff --git a/src/node/validate.ts b/src/node/validate.ts index b958798..1e6b85a 100644 --- a/src/node/validate.ts +++ b/src/node/validate.ts @@ -13,6 +13,8 @@ import { import { getManifestVersionFromRawData } from "../shared/manifestVersionResolve.js"; import { getAllFilesWithCount, readMcpbIgnorePatterns } from "./files.js"; +const RECOMMENDED_ICON_SIZE = 512; + /** * Check if a buffer contains a valid PNG file signature */ @@ -31,6 +33,33 @@ function isPNG(buffer: Buffer): boolean { ); } +/** + * Read the pixel dimensions of a PNG from its IHDR chunk. + * + * The IHDR chunk is required by the spec to be the first chunk and to + * immediately follow the 8-byte signature: a 4-byte length, the "IHDR" type, + * then the 4-byte big-endian width and height. Returns null if the buffer is + * too short or the IHDR chunk is not where the spec requires it to be. + */ +function getPNGDimensions( + buffer: Buffer, +): { width: number; height: number } | null { + // 8 (signature) + 4 (length) + 4 (type) + 4 (width) + 4 (height) = 24 + if (buffer.length < 24) { + return null; + } + + // Bytes 12-15 must spell "IHDR" for the width/height offsets to be valid. + if (buffer.toString("ascii", 12, 16) !== "IHDR") { + return null; + } + + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} + /** * Validate icon field in manifest * @param iconPath - The icon path from manifest.json @@ -89,10 +118,19 @@ function validateIcon( `Icon file must be PNG format. The file at "${iconPath}" does not appear to be a valid PNG file.`, ); } else { - // File exists and is a valid PNG - add recommendation - warnings.push( - "Icon validation passed. Recommended size is 512×512 pixels for best display in Claude Desktop.", - ); + // File exists and is a valid PNG. Only recommend a different size + // when the icon is not already at the recommended dimensions. + const dimensions = getPNGDimensions(buffer); + if ( + dimensions && + (dimensions.width !== RECOMMENDED_ICON_SIZE || + dimensions.height !== RECOMMENDED_ICON_SIZE) + ) { + warnings.push( + `Icon is ${dimensions.width}×${dimensions.height} pixels. ` + + `Recommended size is ${RECOMMENDED_ICON_SIZE}×${RECOMMENDED_ICON_SIZE} pixels for best display in Claude Desktop.`, + ); + } } } catch (error) { errors.push( diff --git a/test/icon-validation.test.ts b/test/icon-validation.test.ts index bdc7bbe..b93ad2b 100644 --- a/test/icon-validation.test.ts +++ b/test/icon-validation.test.ts @@ -87,6 +87,61 @@ describe("Icon Validation", () => { ]); fs.writeFileSync(join(testFixturesDir, "valid-icon.png"), validPngBuffer); + // Create a valid PNG file declaring the recommended 512x512 dimensions. + // Only the signature and the IHDR width/height are read by validation, so + // a minimal chunk layout is enough to exercise the size check. + const recommendedSizePngBuffer = Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, // PNG signature + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, // IHDR chunk + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, // 512x512 dimensions + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1f, + 0x15, + 0xc4, + 0x89, // IHDR data + CRC + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, + 0x44, // IEND chunk + 0xae, + 0x42, + 0x60, + 0x82, + ]); + fs.writeFileSync( + join(testFixturesDir, "recommended-size-icon.png"), + recommendedSizePngBuffer, + ); + // Create an invalid (non-PNG) file fs.writeFileSync( join(testFixturesDir, "invalid-icon.jpg"), @@ -102,6 +157,10 @@ describe("Icon Validation", () => { icon: "valid-icon.png", }); + createTestManifest("recommended-size-icon.json", { + icon: "recommended-size-icon.png", + }); + createTestManifest("invalid-remote-url.json", { icon: "https://example.com/icon.png", }); @@ -161,14 +220,28 @@ describe("Icon Validation", () => { } describe("Valid icon configurations", () => { - it("should pass validation with a valid local PNG icon", () => { + it("should recommend the standard size for a PNG that is not 512x512", () => { const manifestPath = join(testFixturesDir, "valid-local-icon.json"); const result = execSync(`node ${cliPath} validate ${manifestPath}`, { encoding: "utf-8", }); expect(result).toContain("Manifest schema validation passes!"); - expect(result).toContain("Icon validation passed"); + expect(result).toContain("Icon validation warnings"); + expect(result).toContain("Icon is 1×1 pixels"); + expect(result).toContain("Recommended size is 512×512 pixels"); + expect(result).not.toContain("ERROR"); + }); + + it("should not recommend a size when the PNG is already 512x512", () => { + const manifestPath = join(testFixturesDir, "recommended-size-icon.json"); + const result = execSync(`node ${cliPath} validate ${manifestPath}`, { + encoding: "utf-8", + }); + + expect(result).toContain("Manifest schema validation passes!"); + expect(result).not.toContain("Recommended size"); + expect(result).not.toContain("ERROR"); }); it("should pass validation when no icon is specified", () => { @@ -326,7 +399,7 @@ describe("Icon Validation", () => { }); expect(result).toContain("Manifest schema validation passes!"); - expect(result).toContain("Icon validation passed"); + expect(result).not.toContain("ERROR"); }); }); });