Add localization/theming feature to 0.3 version

This commit is contained in:
Alexander Sklar 2025-10-24 15:48:46 -07:00
commit 1640adefe8
No known key found for this signature in database
13 changed files with 559 additions and 18 deletions

View file

@ -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": "resources/${locale}.json",
"default_locale": "en-US"
},
"server": {
"type": "node",
"entry_point": "server/index.js",
@ -221,6 +237,7 @@ A full `manifest.json` with most of the optional fields looks like this:
### Optional Fields
- **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
- **long_description**: Detailed description for extension stores, markdown
- **repository**: Source code repository information (type and url)
@ -238,6 +255,38 @@ A full `manifest.json` with most of the optional fields looks like this:
- **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:
```json
"localization": {
"resources": "resources/${locale}.json",
"default_locale": "en-US"
}
```
- `resources` must include a `${locale}` placeholder. Clients resolve it relative to the server install directory.
- `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`.
- Values for the default locale stay in the main manifest; localized files only need to contain overrides.
- When a translation is missing, clients fall back to the default locale value from 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

View file

@ -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,56 @@ 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 +822,11 @@ export function buildManifest(
},
visualAssets: {
icon: string;
icons: Array<{
src: string;
sizes: string;
theme?: string;
}>;
screenshots: string[];
},
serverConfig: {
@ -767,6 +873,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 +901,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 +988,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 +1025,7 @@ export async function initExtension(
compatibility,
userConfig,
optionalFields,
localization,
);
// Write manifest

View file

@ -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,35 @@ 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 +129,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(),

View file

@ -1 +1 @@
export * from "./0.2.js";
export * from "./0.3.js";

164
src/schemas_loose/0.3.ts Normal file
View file

@ -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(),
});

View file

@ -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";

View file

@ -1 +1 @@
export * from "./0.2.js";
export * from "./0.3.js";

View file

@ -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;
/**

View file

@ -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",

View file

@ -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,10 @@ 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 +307,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 +330,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",

View file

@ -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);
});
});
});

View file

@ -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"
]
}
}
}

View file

@ -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"
]
}
}
}
}