Skip to content

Commit ba74af5

Browse files
Merge pull request #136 from bthompson-sys/feat/icon-validation
feat: add comprehensive icon validation to mcpb validate command
2 parents 11d1397 + 55e3c3c commit ba74af5

File tree

4 files changed

+532
-2
lines changed

4 files changed

+532
-2
lines changed

examples/hello-world-node/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "../../dist/mcpb-manifest.schema.json",
3-
"manifest_version": "0.1",
3+
"manifest_version": "0.3",
44
"name": "hello-world-node",
55
"display_name": "Hello World MCP Server (Reference Extension)",
66
"version": "0.1.0",

src/node/validate.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, statSync } from "fs";
22
import * as fs from "fs/promises";
33
import { DestroyerOfModules } from "galactus";
44
import * as os from "os";
5-
import { join, resolve } from "path";
5+
import { dirname, isAbsolute, join, resolve } from "path";
66
import prettyBytes from "pretty-bytes";
77

88
import { unpackExtension } from "../cli/unpack.js";
@@ -11,6 +11,102 @@ import {
1111
LATEST_MANIFEST_SCHEMA_LOOSE,
1212
} from "../shared/constants.js";
1313

14+
/**
15+
* Check if a buffer contains a valid PNG file signature
16+
*/
17+
function isPNG(buffer: Buffer): boolean {
18+
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
19+
return (
20+
buffer.length >= 8 &&
21+
buffer[0] === 0x89 &&
22+
buffer[1] === 0x50 &&
23+
buffer[2] === 0x4e &&
24+
buffer[3] === 0x47 &&
25+
buffer[4] === 0x0d &&
26+
buffer[5] === 0x0a &&
27+
buffer[6] === 0x1a &&
28+
buffer[7] === 0x0a
29+
);
30+
}
31+
32+
/**
33+
* Validate icon field in manifest
34+
* @param iconPath - The icon path from manifest.json
35+
* @param baseDir - The base directory containing the manifest
36+
* @returns Validation result with errors and warnings
37+
*/
38+
function validateIcon(
39+
iconPath: string,
40+
baseDir: string,
41+
): { valid: boolean; errors: string[]; warnings: string[] } {
42+
const errors: string[] = [];
43+
const warnings: string[] = [];
44+
45+
const isRemoteUrl =
46+
iconPath.startsWith("http://") || iconPath.startsWith("https://");
47+
const hasVariableSubstitution = iconPath.includes("${__dirname}");
48+
const isAbsolutePath = isAbsolute(iconPath);
49+
50+
// Warn about remote URLs (best practice: use local files)
51+
if (isRemoteUrl) {
52+
warnings.push(
53+
"Icon path uses a remote URL. " +
54+
'Best practice for local MCP servers: Use local files like "icon": "icon.png" for maximum compatibility. ' +
55+
"Claude Desktop currently only supports local icon files in bundles.",
56+
);
57+
}
58+
59+
// Check for ${__dirname} variable (error - doesn't work)
60+
if (hasVariableSubstitution) {
61+
errors.push(
62+
"Icon path should not use ${__dirname} variable substitution. " +
63+
'Use a simple relative path like "icon.png" instead of "${__dirname}/icon.png".',
64+
);
65+
}
66+
67+
// Check for absolute path (error - not portable)
68+
if (isAbsolutePath) {
69+
errors.push(
70+
"Icon path must be relative to the bundle root, not an absolute path. " +
71+
`Found: "${iconPath}"`,
72+
);
73+
}
74+
75+
// Only proceed with file checks if the path looks like a local file
76+
if (!isRemoteUrl && !isAbsolutePath && !hasVariableSubstitution) {
77+
// Check file existence
78+
const fullIconPath = join(baseDir, iconPath);
79+
if (!existsSync(fullIconPath)) {
80+
errors.push(`Icon file not found at path: ${iconPath}`);
81+
} else {
82+
try {
83+
// Check PNG format
84+
const buffer = readFileSync(fullIconPath);
85+
if (!isPNG(buffer)) {
86+
errors.push(
87+
`Icon file must be PNG format. The file at "${iconPath}" does not appear to be a valid PNG file.`,
88+
);
89+
} else {
90+
// File exists and is a valid PNG - add recommendation
91+
warnings.push(
92+
"Icon validation passed. Recommended size is 512×512 pixels for best display in Claude Desktop.",
93+
);
94+
}
95+
} catch (error) {
96+
errors.push(
97+
`Unable to read icon file at "${iconPath}": ${error instanceof Error ? error.message : "Unknown error"}`,
98+
);
99+
}
100+
}
101+
}
102+
103+
return {
104+
valid: errors.length === 0,
105+
errors,
106+
warnings,
107+
};
108+
}
109+
14110
export function validateManifest(inputPath: string): boolean {
15111
try {
16112
const resolvedPath = resolve(inputPath);
@@ -28,6 +124,28 @@ export function validateManifest(inputPath: string): boolean {
28124

29125
if (result.success) {
30126
console.log("Manifest schema validation passes!");
127+
128+
// Validate icon if present
129+
if (manifestData.icon) {
130+
const baseDir = dirname(manifestPath);
131+
const iconValidation = validateIcon(manifestData.icon, baseDir);
132+
133+
if (iconValidation.errors.length > 0) {
134+
console.log("\nERROR: Icon validation failed:\n");
135+
iconValidation.errors.forEach((error) => {
136+
console.log(` - ${error}`);
137+
});
138+
return false;
139+
}
140+
141+
if (iconValidation.warnings.length > 0) {
142+
console.log("\nIcon validation warnings:\n");
143+
iconValidation.warnings.forEach((warning) => {
144+
console.log(` - ${warning}`);
145+
});
146+
}
147+
}
148+
31149
return true;
32150
} else {
33151
console.log("ERROR: Manifest validation failed:\n");

test/cli.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,90 @@ describe("DXT CLI", () => {
8080
fs.unlinkSync(invalidJsonPath);
8181
});
8282

83+
describe("icon validation integration", () => {
84+
const testDir = join(__dirname, "icon-test");
85+
86+
beforeAll(() => {
87+
// Create test directory
88+
if (!fs.existsSync(testDir)) {
89+
fs.mkdirSync(testDir, { recursive: true });
90+
}
91+
92+
// Create a valid PNG file (1x1 transparent pixel)
93+
const validPngBuffer = Buffer.from([
94+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
95+
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
96+
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
97+
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
98+
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
99+
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
100+
]);
101+
fs.writeFileSync(join(testDir, "icon.png"), validPngBuffer);
102+
});
103+
104+
afterAll(() => {
105+
// Clean up test directory
106+
if (fs.existsSync(testDir)) {
107+
fs.rmSync(testDir, { recursive: true, force: true });
108+
}
109+
});
110+
111+
it("should pass validation with valid local icon", () => {
112+
const manifestWithIcon = join(testDir, "manifest-with-icon.json");
113+
fs.writeFileSync(
114+
manifestWithIcon,
115+
JSON.stringify({
116+
manifest_version: LATEST_MANIFEST_VERSION,
117+
name: "test-with-icon",
118+
version: "1.0.0",
119+
description: "Test with icon",
120+
author: { name: "Test" },
121+
icon: "icon.png",
122+
server: {
123+
type: "node",
124+
entry_point: "server/index.js",
125+
mcp_config: { command: "node" },
126+
},
127+
}),
128+
);
129+
130+
const result = execSync(`node ${cliPath} validate ${manifestWithIcon}`, {
131+
encoding: "utf-8",
132+
});
133+
134+
expect(result).toContain("Manifest schema validation passes!");
135+
expect(result).toContain("Icon validation");
136+
});
137+
138+
it("should warn about manifest with remote icon URL but not fail", () => {
139+
const manifestWithUrl = join(testDir, "manifest-with-url.json");
140+
fs.writeFileSync(
141+
manifestWithUrl,
142+
JSON.stringify({
143+
manifest_version: LATEST_MANIFEST_VERSION,
144+
name: "test-with-url",
145+
version: "1.0.0",
146+
description: "Test with URL",
147+
author: { name: "Test" },
148+
icon: "https://example.com/icon.png",
149+
server: {
150+
type: "node",
151+
entry_point: "server/index.js",
152+
mcp_config: { command: "node" },
153+
},
154+
}),
155+
);
156+
157+
const result = execSync(`node ${cliPath} validate ${manifestWithUrl}`, {
158+
encoding: "utf-8",
159+
});
160+
161+
expect(result).toContain("Manifest schema validation passes!");
162+
expect(result).toContain("Icon validation warnings");
163+
expect(result).toContain("Icon path uses a remote URL");
164+
});
165+
});
166+
83167
describe("pack and unpack", () => {
84168
const tempDir = join(__dirname, "temp-pack-test");
85169
const packedFilePath = join(__dirname, "test-extension.dxt");

0 commit comments

Comments
 (0)