diff --git a/MANIFEST.md b/MANIFEST.md index 3b38944..837cee8 100644 --- a/MANIFEST.md +++ b/MANIFEST.md @@ -1,7 +1,7 @@ # MCPB Manifest.json Spec -Current version: `0.2` -Last updated: 2025-09-12 +Current version: `0.3` +Last updated: 2025-10-24 ## Manifest Schema @@ -11,7 +11,7 @@ A basic `manifest.json` with just the required fields looks like this: ```jsonc { - "manifest_version": "0.2", // Manifest spec version this manifest conforms to + "manifest_version": "0.3", // Manifest spec version this manifest conforms to "name": "my-extension", // Machine-readable name (used for CLI, APIs) "version": "1.0.0", // Semantic version of your extension "description": "A simple MCP extension", // Brief description of what the extension does @@ -37,7 +37,7 @@ A basic `manifest.json` with just the required fields looks like this: ```json { - "manifest_version": "0.2", + "manifest_version": "0.3", "name": "my-extension", "version": "1.0.0", "description": "A simple MCP extension", @@ -71,7 +71,7 @@ A full `manifest.json` with most of the optional fields looks like this: ```json { - "manifest_version": "0.1", + "manifest_version": "0.3", "name": "My MCP Extension", "display_name": "My Awesome MCP Extension", "version": "1.0.0", @@ -90,10 +90,26 @@ A full `manifest.json` with most of the optional fields looks like this: "documentation": "https://docs.example.com/my-extension", "support": "https://github.com/your-username/my-extension/issues", "icon": "icon.png", + "icons": [ + { + "src": "assets/icons/icon-16-light.png", + "sizes": "16x16", + "theme": "light" + }, + { + "src": "assets/icons/icon-16-dark.png", + "sizes": "16x16", + "theme": "dark" + } + ], "screenshots": [ "assets/screenshots/screenshot1.png", "assets/screenshots/screenshot2.png" ], + "localization": { + "resources": "custom-directory-for-mcpb-resources/${locale}.json", + "default_locale": "en-US" + }, "server": { "type": "node", "entry_point": "server/index.js", @@ -214,30 +230,71 @@ A full `manifest.json` with most of the optional fields looks like this: - **manifest_version**: Specification version this extension conforms to - **name**: Machine-readable name (used for CLI, APIs) - **version**: Semantic version (semver) -- **description**: Brief description -- **author**: Author information object with name (required), email (optional), and url (optional) +- 🌎 **description**: Brief description. This field is localizable. +- 🌎 **author**: Author information object with name (required, localizable), email (optional), and url (optional) - **server**: Server configuration object ### Optional Fields -- **icon**: Path to a png icon file, either relative in the package or a https:// url. -- **display_name**: Human-friendly name for UI display -- **long_description**: Detailed description for extension stores, markdown -- **repository**: Source code repository information (type and url) -- **homepage**: Extension homepage URL -- **documentation**: Documentation URL -- **support**: Support/issues URL -- **screenshots**: Array of screenshot paths -- **tools**: Array of tools the extension provides -- **tools_generated**: Boolean indicating the server generates additional tools at runtime (default: false) -- **prompts**: Array of prompts the extension provides -- **prompts_generated**: Boolean indicating the server generates additional prompts at runtime (default: false) -- **keywords**: Search keywords -- **license**: License identifier -- **privacy_policies**: Array of URLs to privacy policies for external services that handle user data. Required when the extension connects to external services (first- or third-party) that process user data. Each URL should link to the respective service's privacy policy document -- **compatibility**: Compatibility requirements (client app version, platforms, and runtime versions) -- **user_config**: User-configurable options for the extension (see User Configuration section) +- **icon**: Path to a png icon file, either relative in the package or a `https://` url. +- **icons**: Array of icon descriptors (`src`, `sizes`, optional `theme`) for light/dark or size-specific assets. +- 🌎 **display_name**: Human-friendly name for UI display. This field is localizable. +- 🌎 **long_description**: Detailed description for extension stores, markdown. This field is localizable. +- **repository**: Source code repository information (type and url). +- **homepage**: Extension homepage URL. +- **documentation**: Documentation URL. +- **support**: Support/issues URL. +- **screenshots**: Array of screenshot paths. +- 🌎 **tools**: Array of tools the extension provides. This field is localizable. +- **tools_generated**: Boolean indicating the server generates additional tools at runtime (default: false). +- 🌎 **prompts**: Array of prompts the extension provides. This field is localizable. +- **prompts_generated**: Boolean indicating the server generates additional prompts at runtime (default: false). +- 🌎 **keywords**: Search keywords. This field is localizable. +- **license**: License identifier. +- **privacy_policies**: Array of URLs to privacy policies for external services that handle user data. Required when the extension connects to external services (first- or third-party) that process user data. Each URL should link to the respective service's privacy policy document. +- **compatibility**: Compatibility requirements (client app version, platforms, and runtime versions). +- **user_config**: User-configurable options for the extension (see User Configuration section). - **_meta**: Platform-specific client integration metadata (e.g., Windows `package_family_name`, macOS bundle identifiers) enabling tighter OS/app store integration. The keys in the `_meta` object are reverse-DNS namespaced, and the values are a dictionary of platform-specific metadata. +- **localization**: Location of translated strings for user-facing fields (`resources` path containing a `${locale}` placeholder and `default_locale`). + +### Localization + +Provide localized strings without bloating the manifest by pointing to external per-locale resource files. A localization entry looks like this: + +```jsonc +"localization": { + "resources": "relative-path-to-resources/${locale}.json", + "default_locale": "en-US" +} +``` + +- `resources` must include a `${locale}` placeholder. Clients resolve it relative to the server install directory. + - This property is optional, and its default value is **`mcpb-resources/${locale}.json`**. +- `default_locale` must be a valid [BCP 47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) identifier such as `en-US` or `zh-Hans`. + - This property is optional, and its default value is `en-US`. +- Values for the default locale stay in the main manifest; localized files only need to contain overrides. + +For tools and prompts, the descriptions are also localizable. + +#### Client guidelines +- if a client wants to show tool or prompt descriptions in their UI, the client should look for the locale-specific description override in the corresponding per-locale file. +- clients should only look for tools/prompts present in the manifest.json, i.e. prompts and tools that only exist in the per-locale file should be ignored. +- Clients should apply locale fallbacks if the client/user locale is not represented by the server. For example, if the user is in the `es-UY` locale but the server does not include that per-locale file, the client should look for an approrpiate fallback, e.g. `es-MX` or `es-ES`, or fall back to the values in the manifest. + +### Icons + +Use the `icons` array when you need multiple icon variants (different sizes or themes): + +```json +"icons": [ + { "src": "assets/icons/icon-16-light.png", "sizes": "16x16", "theme": "light" }, + { "src": "assets/icons/icon-16-dark.png", "sizes": "16x16", "theme": "dark" } +] +``` + +- `sizes` must be in `WIDTHxHEIGHT` form (e.g., `128x128`). +- `theme` is optional; use values like `light`, `dark`, or platform-specific labels (e.g., `high-contrast`). +- The legacy `icon` field remains supported for single assetsβ€”clients use it when `icons` is omitted. ## Compatibility @@ -606,9 +663,9 @@ For servers with a fixed set of capabilities, list them in arrays. Each prompt in the `prompts` array must include: - **name**: The identifier for the prompt -- **description** (optional): Explanation of what the prompt does +- 🌎 **description** (optional): Explanation of what the prompt does - **arguments** (optional): Array of argument names that can be used in the prompt text -- **text**: The actual prompt text that uses template variables like `${arguments.topic}` or `${arguments.aspect}` as placeholders for MCP Client-supplied arguments. If your argument is named `language`, you'd add `${arguments.language} where you expect it to show up in the prompt. +- 🌎 **text**: The actual prompt text that uses template variables like `${arguments.topic}` or `${arguments.aspect}` as placeholders for MCP Client-supplied arguments. If your argument is named `language`, you'd add `${arguments.language}` where you expect it to show up in the prompt. Example: diff --git a/src/cli/init.ts b/src/cli/init.ts index a3ae0db..c8e764a 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -449,6 +449,58 @@ export async function promptVisualAssets() { }, }); + const addIconVariants = await confirm({ + message: "Add theme/size-specific icons array?", + default: false, + }); + + const icons: Array<{ + src: string; + sizes: string; + theme?: string; + }> = []; + + if (addIconVariants) { + let addMoreIcons = true; + while (addMoreIcons) { + const iconSrc = await input({ + message: "Icon source path (relative to manifest):", + validate: (value) => { + if (!value.trim()) return "Icon path is required"; + if (value.includes("..")) return "Relative paths cannot include '..'"; + return true; + }, + }); + + const iconSizes = await input({ + message: "Icon size (e.g., 16x16):", + validate: (value) => { + if (!value.trim()) return "Icon size is required"; + if (!/^\d+x\d+$/.test(value)) { + return "Icon size must be in WIDTHxHEIGHT format (e.g., 128x128)"; + } + return true; + }, + }); + + const iconTheme = await input({ + message: "Icon theme (light, dark, or custom - optional):", + default: "", + }); + + icons.push({ + src: iconSrc, + sizes: iconSizes, + ...(iconTheme.trim() ? { theme: iconTheme.trim() } : {}), + }); + + addMoreIcons = await confirm({ + message: "Add another icon entry?", + default: false, + }); + } + } + const addScreenshots = await confirm({ message: "Add screenshots?", default: false, @@ -475,7 +527,57 @@ export async function promptVisualAssets() { } } - return { icon, screenshots }; + return { icon, icons, screenshots }; +} + +export async function promptLocalization() { + const configureLocalization = await confirm({ + message: "Configure localization resources?", + default: false, + }); + + if (!configureLocalization) { + return undefined; + } + + const placeholderRegex = /\$\{locale\}/i; + + const resourcesPath = await input({ + message: + "Localization resources path (must include ${locale} placeholder):", + default: "resources/${locale}.json", + validate: (value) => { + if (!value.trim()) { + return "Resources path is required"; + } + if (value.includes("..")) { + return "Relative paths cannot include '..'"; + } + if (!placeholderRegex.test(value)) { + return "Path must include a ${locale} placeholder"; + } + return true; + }, + }); + + const defaultLocale = await input({ + message: "Default locale (BCP 47, e.g., en-US):", + default: "en-US", + validate: (value) => { + if (!value.trim()) { + return "Default locale is required"; + } + if (!/^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value)) { + return "Default locale must follow BCP 47 (e.g., en-US or zh-Hans)"; + } + return true; + }, + }); + + return { + resources: resourcesPath, + default_locale: defaultLocale, + }; } export async function promptCompatibility( @@ -721,6 +823,11 @@ export function buildManifest( }, visualAssets: { icon: string; + icons: Array<{ + src: string; + sizes: string; + theme?: string; + }>; screenshots: string[]; }, serverConfig: { @@ -767,6 +874,10 @@ export function buildManifest( license: string; repository?: { type: string; url: string }; }, + localization?: { + resources: string; + default_locale: string; + }, ): McpbManifest { const { name, displayName, version, description, authorName } = basicInfo; const { authorEmail, authorUrl } = authorInfo; @@ -791,9 +902,11 @@ export function buildManifest( ...(urls.documentation ? { documentation: urls.documentation } : {}), ...(urls.support ? { support: urls.support } : {}), ...(visualAssets.icon ? { icon: visualAssets.icon } : {}), + ...(visualAssets.icons.length > 0 ? { icons: visualAssets.icons } : {}), ...(visualAssets.screenshots.length > 0 ? { screenshots: visualAssets.screenshots } : {}), + ...(localization ? { localization } : {}), server: { type: serverType, entry_point: entryPoint, @@ -876,8 +989,11 @@ export async function initExtension( ? { homepage: "", documentation: "", support: "" } : await promptUrls(); const visualAssets = nonInteractive - ? { icon: "", screenshots: [] } + ? { icon: "", icons: [], screenshots: [] } : await promptVisualAssets(); + const localization = nonInteractive + ? undefined + : await promptLocalization(); const serverConfig = nonInteractive ? getDefaultServerConfig(packageData) : await promptServerConfig(packageData); @@ -910,6 +1026,7 @@ export async function initExtension( compatibility, userConfig, optionalFields, + localization, ); // Write manifest diff --git a/src/schemas/0.3.ts b/src/schemas/0.3.ts index 3862ba4..126b532 100644 --- a/src/schemas/0.3.ts +++ b/src/schemas/0.3.ts @@ -3,6 +3,10 @@ import * as z from "zod"; export const MANIFEST_VERSION = "0.3"; +const LOCALE_PLACEHOLDER_REGEX = /\$\{locale\}/i; +const BCP47_REGEX = /^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; +const ICON_SIZE_REGEX = /^\d+x\d+$/; + export const McpServerConfigSchema = z.strictObject({ command: z.string(), args: z.array(z.string()).optional(), @@ -77,6 +81,32 @@ export const McpbUserConfigValuesSchema = z.record( z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]), ); +export const McpbManifestLocalizationSchema = z.strictObject({ + resources: z + .string() + .regex( + LOCALE_PLACEHOLDER_REGEX, + 'resources must include a "${locale}" placeholder', + ), + default_locale: z + .string() + .regex( + BCP47_REGEX, + "default_locale must be a valid BCP 47 locale identifier", + ), +}); + +export const McpbManifestIconSchema = z.strictObject({ + src: z.string(), + sizes: z + .string() + .regex( + ICON_SIZE_REGEX, + 'sizes must be in the format "WIDTHxHEIGHT" (e.g., "16x16")', + ), + theme: z.string().min(1, "theme cannot be empty when provided").optional(), +}); + export const McpbManifestSchema = z .strictObject({ $schema: z.string().optional(), @@ -96,7 +126,9 @@ export const McpbManifestSchema = z documentation: z.string().url().optional(), support: z.string().url().optional(), icon: z.string().optional(), + icons: z.array(McpbManifestIconSchema).optional(), screenshots: z.array(z.string()).optional(), + localization: McpbManifestLocalizationSchema.optional(), server: McpbManifestServerSchema, tools: z.array(McpbManifestToolSchema).optional(), tools_generated: z.boolean().optional(), diff --git a/src/schemas/latest.ts b/src/schemas/latest.ts index 79797d0..e79a613 100644 --- a/src/schemas/latest.ts +++ b/src/schemas/latest.ts @@ -1 +1 @@ -export * from "./0.2.js"; +export * from "./0.3.js"; diff --git a/src/schemas_loose/0.3.ts b/src/schemas_loose/0.3.ts new file mode 100644 index 0000000..6731a36 --- /dev/null +++ b/src/schemas_loose/0.3.ts @@ -0,0 +1,164 @@ +import * as z from "zod"; + +export const MANIFEST_VERSION = "0.3"; + +const LOCALE_PLACEHOLDER_REGEX = /\$\{locale\}/i; +const BCP47_REGEX = /^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; +const ICON_SIZE_REGEX = /^\d+x\d+$/; + +export const McpServerConfigSchema = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +export const McpbManifestAuthorSchema = z.object({ + name: z.string(), + email: z.string().email().optional(), + url: z.string().url().optional(), +}); + +export const McpbManifestRepositorySchema = z.object({ + type: z.string(), + url: z.string().url(), +}); + +export const McpbManifestPlatformOverrideSchema = + McpServerConfigSchema.partial(); + +export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({ + platform_overrides: z + .record(z.string(), McpbManifestPlatformOverrideSchema) + .optional(), +}); + +export const McpbManifestServerSchema = z.object({ + type: z.enum(["python", "node", "binary"]), + entry_point: z.string(), + mcp_config: McpbManifestMcpConfigSchema, +}); + +export const McpbManifestCompatibilitySchema = z + .object({ + claude_desktop: z.string().optional(), + platforms: z.array(z.enum(["darwin", "win32", "linux"])).optional(), + runtimes: z + .object({ + python: z.string().optional(), + node: z.string().optional(), + }) + .optional(), + }) + .passthrough(); + +export const McpbManifestToolSchema = z.object({ + name: z.string(), + description: z.string().optional(), +}); + +export const McpbManifestPromptSchema = z.object({ + name: z.string(), + description: z.string().optional(), + arguments: z.array(z.string()).optional(), + text: z.string(), +}); + +export const McpbUserConfigurationOptionSchema = z.object({ + type: z.enum(["string", "number", "boolean", "directory", "file"]), + title: z.string(), + description: z.string(), + required: z.boolean().optional(), + default: z + .union([z.string(), z.number(), z.boolean(), z.array(z.string())]) + .optional(), + multiple: z.boolean().optional(), + sensitive: z.boolean().optional(), + min: z.number().optional(), + max: z.number().optional(), +}); + +export const McpbUserConfigValuesSchema = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]), +); + +export const McpbManifestLocalizationSchema = z + .object({ + resources: z + .string() + .regex( + LOCALE_PLACEHOLDER_REGEX, + 'resources must include a "${locale}" placeholder', + ), + default_locale: z + .string() + .regex( + BCP47_REGEX, + "default_locale must be a valid BCP 47 locale identifier", + ), + }) + .passthrough(); + +export const McpbManifestIconSchema = z + .object({ + src: z.string(), + sizes: z + .string() + .regex( + ICON_SIZE_REGEX, + 'sizes must be in the format "WIDTHxHEIGHT" (e.g., "16x16")', + ), + theme: z.string().min(1).optional(), + }) + .passthrough(); + +export const McpbManifestSchema = z + .object({ + $schema: z.string().optional(), + dxt_version: z + .literal(MANIFEST_VERSION) + .optional() + .describe("@deprecated Use manifest_version instead"), + manifest_version: z.literal(MANIFEST_VERSION).optional(), + name: z.string(), + display_name: z.string().optional(), + version: z.string(), + description: z.string(), + long_description: z.string().optional(), + author: McpbManifestAuthorSchema, + repository: McpbManifestRepositorySchema.optional(), + homepage: z.string().url().optional(), + documentation: z.string().url().optional(), + support: z.string().url().optional(), + icon: z.string().optional(), + icons: z.array(McpbManifestIconSchema).optional(), + screenshots: z.array(z.string()).optional(), + localization: McpbManifestLocalizationSchema.optional(), + server: McpbManifestServerSchema, + tools: z.array(McpbManifestToolSchema).optional(), + tools_generated: z.boolean().optional(), + prompts: z.array(McpbManifestPromptSchema).optional(), + prompts_generated: z.boolean().optional(), + keywords: z.array(z.string()).optional(), + license: z.string().optional(), + privacy_policies: z.array(z.string().url()).optional(), + compatibility: McpbManifestCompatibilitySchema.optional(), + user_config: z + .record(z.string(), McpbUserConfigurationOptionSchema) + .optional(), + _meta: z.record(z.string(), z.record(z.string(), z.any())).optional(), + }) + .passthrough() + .refine((data) => !!(data.dxt_version || data.manifest_version), { + message: + "Either 'dxt_version' (deprecated) or 'manifest_version' must be provided", + }); + +export const McpbSignatureInfoSchema = z.object({ + status: z.enum(["signed", "unsigned", "self-signed"]), + publisher: z.string().optional(), + issuer: z.string().optional(), + valid_from: z.string().optional(), + valid_to: z.string().optional(), + fingerprint: z.string().optional(), +}); diff --git a/src/schemas_loose/index.ts b/src/schemas_loose/index.ts index b58933b..3548b1d 100644 --- a/src/schemas_loose/index.ts +++ b/src/schemas_loose/index.ts @@ -1,3 +1,4 @@ export * as v0_1 from "./0.1.js"; export * as v0_2 from "./0.2.js"; +export * as v0_3 from "./0.3.js"; export * as latest from "./latest.js"; diff --git a/src/schemas_loose/latest.ts b/src/schemas_loose/latest.ts index 79797d0..e79a613 100644 --- a/src/schemas_loose/latest.ts +++ b/src/schemas_loose/latest.ts @@ -1 +1 @@ -export * from "./0.2.js"; +export * from "./0.3.js"; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1e4c2d0..95a5cf4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,14 +1,16 @@ import { McpbManifestSchema as ManifestSchemaV0_1 } from "../schemas/0.1.js"; import { McpbManifestSchema as ManifestSchemaV0_2 } from "../schemas/0.2.js"; +import { McpbManifestSchema as ManifestSchemaV0_3 } from "../schemas/0.3.js"; import { McpbManifestSchema as CurrentManifestSchema } from "../schemas/latest.js"; import { McpbManifestSchema as LooseManifestSchemaV0_1 } from "../schemas_loose/0.1.js"; import { McpbManifestSchema as LooseManifestSchemaV0_2 } from "../schemas_loose/0.2.js"; +import { McpbManifestSchema as LooseManifestSchemaV0_3 } from "../schemas_loose/0.3.js"; import { McpbManifestSchema as CurrentLooseManifestSchema } from "../schemas_loose/latest.js"; /** * Latest manifest version - the version that new manifests should use */ -export const LATEST_MANIFEST_VERSION = "0.2" as const; +export const LATEST_MANIFEST_VERSION = "0.3" as const; /** * Map of manifest versions to their strict schemas @@ -16,6 +18,7 @@ export const LATEST_MANIFEST_VERSION = "0.2" as const; export const MANIFEST_SCHEMAS = { "0.1": ManifestSchemaV0_1, "0.2": ManifestSchemaV0_2, + "0.3": ManifestSchemaV0_3, } as const; /** @@ -24,6 +27,7 @@ export const MANIFEST_SCHEMAS = { export const MANIFEST_SCHEMAS_LOOSE = { "0.1": LooseManifestSchemaV0_1, "0.2": LooseManifestSchemaV0_2, + "0.3": LooseManifestSchemaV0_3, } as const; /** diff --git a/test/config.test.ts b/test/config.test.ts index 062c66e..f7a0eb8 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -90,7 +90,7 @@ describe("getMcpConfigForManifest", () => { }; const baseManifest: McpbManifest = { - manifest_version: "0.2", + manifest_version: "0.3", name: "test-extension", version: "1.0.0", description: "Test extension", @@ -305,7 +305,7 @@ describe("getMcpConfigForManifest", () => { describe("hasRequiredConfigMissing", () => { const baseManifest: McpbManifest = { - manifest_version: "0.2", + manifest_version: "0.3", name: "test-extension", version: "1.0.0", description: "Test extension", diff --git a/test/init.test.ts b/test/init.test.ts index 3cdea0c..4894c9e 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -194,6 +194,7 @@ describe("init functions", () => { }, { icon: "", + icons: [], screenshots: [], }, { @@ -215,6 +216,7 @@ describe("init functions", () => { keywords: "", license: "", }, + undefined, ); expect(manifest).toEqual({ @@ -258,6 +260,18 @@ describe("init functions", () => { }, { icon: "icon.png", + icons: [ + { + src: "assets/icons/icon-16-light.png", + sizes: "16x16", + theme: "light", + }, + { + src: "assets/icons/icon-16-dark.png", + sizes: "16x16", + theme: "dark", + }, + ], screenshots: ["screen1.png", "screen2.png"], }, { @@ -301,6 +315,10 @@ describe("init functions", () => { license: "MIT", repository: { type: "git", url: "https://github.com/user/repo" }, }, + { + resources: "resources/${locale}.json", + default_locale: "en-US", + }, ); expect(manifest).toEqual({ @@ -320,7 +338,23 @@ describe("init functions", () => { documentation: "https://docs.example.com", support: "https://support.example.com", icon: "icon.png", + icons: [ + { + src: "assets/icons/icon-16-light.png", + sizes: "16x16", + theme: "light", + }, + { + src: "assets/icons/icon-16-dark.png", + sizes: "16x16", + theme: "dark", + }, + ], screenshots: ["screen1.png", "screen2.png"], + localization: { + resources: "resources/${locale}.json", + default_locale: "en-US", + }, server: { type: "python", entry_point: "server/main.py", diff --git a/test/schemas.test.ts b/test/schemas.test.ts index f88fe47..325af17 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "fs"; import { join } from "path"; -import { McpbManifestSchema, v0_3 } from "../src/schemas/index.js"; +import { McpbManifestSchema, v0_2, v0_3 } from "../src/schemas/index.js"; describe("McpbManifestSchema", () => { it("should validate a valid manifest", () => { @@ -35,9 +35,26 @@ describe("McpbManifestSchema", () => { } }); + it("should accept a 0.2 manifest when upgraded to 0.3", () => { + const manifestPath = join(__dirname, "valid-manifest-0.2.json"); + const manifestContent = readFileSync(manifestPath, "utf-8"); + const manifestData = JSON.parse(manifestContent); + + const legacyResult = v0_2.McpbManifestSchema.safeParse(manifestData); + expect(legacyResult.success).toBe(true); + + const upgradedManifest = { + ...manifestData, + manifest_version: "0.3", + }; + const upgradedResult = v0_3.McpbManifestSchema.safeParse(upgradedManifest); + + expect(upgradedResult.success).toBe(true); + }); + it("should validate manifest with all optional fields", () => { const fullManifest = { - manifest_version: "0.2", + manifest_version: "0.3", name: "full-extension", display_name: "Full Featured Extension", version: "2.0.0", @@ -56,7 +73,23 @@ describe("McpbManifestSchema", () => { documentation: "https://docs.example.com", support: "https://support.example.com", icon: "icon.png", + icons: [ + { + src: "assets/icons/icon-16-light.png", + sizes: "16x16", + theme: "light", + }, + { + src: "assets/icons/icon-16-dark.png", + sizes: "16x16", + theme: "dark", + }, + ], screenshots: ["screenshot1.png", "screenshot2.png"], + localization: { + resources: "resources/${locale}.json", + default_locale: "en-US", + }, server: { type: "python", entry_point: "main.py", @@ -109,6 +142,8 @@ describe("McpbManifestSchema", () => { expect(result.data.tools).toHaveLength(1); expect(result.data.compatibility?.platforms).toContain("darwin"); expect(result.data.user_config?.api_key.type).toBe("string"); + expect(result.data.icons).toHaveLength(2); + expect(result.data.localization?.default_locale).toBe("en-US"); } }); @@ -117,7 +152,7 @@ describe("McpbManifestSchema", () => { serverTypes.forEach((type) => { const manifest = { - manifest_version: "0.2", + manifest_version: "0.3", name: "test", version: "1.0.0", description: "Test", @@ -213,4 +248,92 @@ describe("McpbManifestSchema", () => { }); }); + describe("localization", () => { + const base = { + manifest_version: "0.3" as const, + name: "loc-ext", + version: "1.0.0", + description: "Test manifest", + author: { name: "Author" }, + server: { + type: "node" as const, + entry_point: "server/index.js", + mcp_config: { command: "node", args: ["server/index.js"] }, + }, + }; + + it("requires a ${locale} placeholder", () => { + const manifest = { + ...base, + localization: { + resources: "resources/fr.json", + default_locale: "en-US", + }, + }; + const result = McpbManifestSchema.safeParse(manifest); + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((issue) => issue.message); + expect(messages.join(" ")).toContain("${locale}"); + } + }); + + it("rejects invalid default locale", () => { + const manifest = { + ...base, + localization: { + resources: "resources/${locale}.json", + default_locale: "en_us", + }, + }; + const result = McpbManifestSchema.safeParse(manifest); + expect(result.success).toBe(false); + }); + + it("accepts valid localization settings", () => { + const manifest = { + ...base, + localization: { + resources: "resources/${locale}.json", + default_locale: "en-US", + }, + }; + const result = McpbManifestSchema.safeParse(manifest); + expect(result.success).toBe(true); + }); + }); + + describe("icons", () => { + const base = { + manifest_version: "0.3" as const, + name: "icon-ext", + version: "1.0.0", + description: "Test manifest", + author: { name: "Author" }, + server: { + type: "python" as const, + entry_point: "main.py", + mcp_config: { command: "python", args: ["main.py"] }, + }, + }; + + it("rejects icons with invalid size format", () => { + const manifest = { + ...base, + icons: [{ src: "assets/icon.png", sizes: "16", theme: "light" }], + }; + const result = McpbManifestSchema.safeParse(manifest); + expect(result.success).toBe(false); + }); + + it("allows icons without theme", () => { + const manifest = { + ...base, + icons: [{ src: "assets/icon.png", sizes: "128x128" }], + }; + const result = McpbManifestSchema.safeParse(manifest); + expect(result.success).toBe(true); + }); + }); + }); diff --git a/test/valid-manifest-0.2.json b/test/valid-manifest-0.2.json new file mode 100644 index 0000000..c6f5fdb --- /dev/null +++ b/test/valid-manifest-0.2.json @@ -0,0 +1,21 @@ +{ + "manifest_version": "0.2", + "name": "legacy-extension", + "display_name": "Legacy Extension", + "version": "1.0.0", + "description": "An extension that predates 0.3", + "author": { + "name": "Legacy Author", + "email": "legacy@example.com" + }, + "server": { + "type": "node", + "entry_point": "server/index.js", + "mcp_config": { + "command": "node", + "args": [ + "${__dirname}/server/index.js" + ] + } + } +} \ No newline at end of file diff --git a/test/valid-manifest.json b/test/valid-manifest.json index c2d121d..c833deb 100644 --- a/test/valid-manifest.json +++ b/test/valid-manifest.json @@ -1,5 +1,5 @@ { - "manifest_version": "0.2", + "manifest_version": "0.3", "name": "test-extension", "display_name": "Test Extension", "version": "1.0.0", @@ -13,7 +13,9 @@ "entry_point": "server/index.js", "mcp_config": { "command": "node", - "args": ["${__dirname}/server/index.js"] + "args": [ + "${__dirname}/server/index.js" + ] } } -} +} \ No newline at end of file