From 2df49ec018cadf8407de4b49ff1e0fe154593d62 Mon Sep 17 00:00:00 2001 From: Alec McLeod Date: Mon, 8 Jun 2026 14:39:31 -0300 Subject: [PATCH] Preserve unknown nested keys in loose schemas (mcpb clean) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loose schemas exist to validate while preserving unrecognised data, but .passthrough() was applied only to the root object. Nested z.object() schemas (author, server, mcp_config/server config, repository, tool, prompt, user_config option) strip unknown keys, so cleanMcpb — which rewrites the manifest with the loose parse result — silently deleted any custom field nested under those objects. Add .passthrough() to the nested object schemas across all loose versions (0.1-0.4). .extend()/.partial() derivatives inherit it, so mcp_config and platform_overrides are covered via McpServerConfigSchema. Adds a test asserting nested unknown keys survive a loose parse. Fixes #264 --- src/schemas_loose/0.1.ts | 98 ++++++++++++++++++++++---------------- src/schemas_loose/0.2.ts | 98 ++++++++++++++++++++++---------------- src/schemas_loose/0.3.ts | 98 ++++++++++++++++++++++---------------- src/schemas_loose/0.4.ts | 100 ++++++++++++++++++++++----------------- test/schemas.test.ts | 41 ++++++++++++++++ 5 files changed, 266 insertions(+), 169 deletions(-) diff --git a/src/schemas_loose/0.1.ts b/src/schemas_loose/0.1.ts index ab7f69c..f98d872 100644 --- a/src/schemas_loose/0.1.ts +++ b/src/schemas_loose/0.1.ts @@ -2,22 +2,28 @@ import * as z from "zod"; export const MANIFEST_VERSION = "0.1"; -export const McpServerConfigSchema = z.object({ - command: z.string(), - args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), -}); +export const McpServerConfigSchema = z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); -export const McpbManifestAuthorSchema = z.object({ - name: z.string(), - email: z.string().email().optional(), - url: z.string().url().optional(), -}); +export const McpbManifestAuthorSchema = z + .object({ + name: z.string(), + email: z.string().email().optional(), + url: z.string().url().optional(), + }) + .passthrough(); -export const McpbManifestRepositorySchema = z.object({ - type: z.string(), - url: z.string().url(), -}); +export const McpbManifestRepositorySchema = z + .object({ + type: z.string(), + url: z.string().url(), + }) + .passthrough(); export const McpbManifestPlatformOverrideSchema = McpServerConfigSchema.partial(); @@ -28,11 +34,13 @@ export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({ .optional(), }); -export const McpbManifestServerSchema = z.object({ - type: z.enum(["python", "node", "binary"]), - entry_point: z.string(), - mcp_config: McpbManifestMcpConfigSchema, -}); +export const McpbManifestServerSchema = z + .object({ + type: z.enum(["python", "node", "binary"]), + entry_point: z.string(), + mcp_config: McpbManifestMcpConfigSchema, + }) + .passthrough(); export const McpbManifestCompatibilitySchema = z .object({ @@ -47,31 +55,37 @@ export const McpbManifestCompatibilitySchema = z }) .passthrough(); -export const McpbManifestToolSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}); +export const McpbManifestToolSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + }) + .passthrough(); -export const McpbManifestPromptSchema = z.object({ - name: z.string(), - description: z.string().optional(), - arguments: z.array(z.string()).optional(), - text: z.string(), -}); +export const McpbManifestPromptSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + arguments: z.array(z.string()).optional(), + text: z.string(), + }) + .passthrough(); -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 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(), + }) + .passthrough(); export const McpbManifestSchema = z .object({ diff --git a/src/schemas_loose/0.2.ts b/src/schemas_loose/0.2.ts index bc177d2..a0280e4 100644 --- a/src/schemas_loose/0.2.ts +++ b/src/schemas_loose/0.2.ts @@ -2,22 +2,28 @@ import * as z from "zod"; export const MANIFEST_VERSION = "0.2"; -export const McpServerConfigSchema = z.object({ - command: z.string(), - args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), -}); +export const McpServerConfigSchema = z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); -export const McpbManifestAuthorSchema = z.object({ - name: z.string(), - email: z.string().email().optional(), - url: z.string().url().optional(), -}); +export const McpbManifestAuthorSchema = z + .object({ + name: z.string(), + email: z.string().email().optional(), + url: z.string().url().optional(), + }) + .passthrough(); -export const McpbManifestRepositorySchema = z.object({ - type: z.string(), - url: z.string().url(), -}); +export const McpbManifestRepositorySchema = z + .object({ + type: z.string(), + url: z.string().url(), + }) + .passthrough(); export const McpbManifestPlatformOverrideSchema = McpServerConfigSchema.partial(); @@ -28,11 +34,13 @@ export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({ .optional(), }); -export const McpbManifestServerSchema = z.object({ - type: z.enum(["python", "node", "binary"]), - entry_point: z.string(), - mcp_config: McpbManifestMcpConfigSchema, -}); +export const McpbManifestServerSchema = z + .object({ + type: z.enum(["python", "node", "binary"]), + entry_point: z.string(), + mcp_config: McpbManifestMcpConfigSchema, + }) + .passthrough(); export const McpbManifestCompatibilitySchema = z .object({ @@ -47,31 +55,37 @@ export const McpbManifestCompatibilitySchema = z }) .passthrough(); -export const McpbManifestToolSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}); +export const McpbManifestToolSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + }) + .passthrough(); -export const McpbManifestPromptSchema = z.object({ - name: z.string(), - description: z.string().optional(), - arguments: z.array(z.string()).optional(), - text: z.string(), -}); +export const McpbManifestPromptSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + arguments: z.array(z.string()).optional(), + text: z.string(), + }) + .passthrough(); -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 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(), + }) + .passthrough(); export const McpbManifestSchema = z .object({ diff --git a/src/schemas_loose/0.3.ts b/src/schemas_loose/0.3.ts index a96894b..301189e 100644 --- a/src/schemas_loose/0.3.ts +++ b/src/schemas_loose/0.3.ts @@ -6,22 +6,28 @@ 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 McpServerConfigSchema = z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); -export const McpbManifestAuthorSchema = z.object({ - name: z.string(), - email: z.string().email().optional(), - url: z.string().url().optional(), -}); +export const McpbManifestAuthorSchema = z + .object({ + name: z.string(), + email: z.string().email().optional(), + url: z.string().url().optional(), + }) + .passthrough(); -export const McpbManifestRepositorySchema = z.object({ - type: z.string(), - url: z.string().url(), -}); +export const McpbManifestRepositorySchema = z + .object({ + type: z.string(), + url: z.string().url(), + }) + .passthrough(); export const McpbManifestPlatformOverrideSchema = McpServerConfigSchema.partial(); @@ -32,11 +38,13 @@ export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({ .optional(), }); -export const McpbManifestServerSchema = z.object({ - type: z.enum(["python", "node", "binary"]), - entry_point: z.string(), - mcp_config: McpbManifestMcpConfigSchema, -}); +export const McpbManifestServerSchema = z + .object({ + type: z.enum(["python", "node", "binary"]), + entry_point: z.string(), + mcp_config: McpbManifestMcpConfigSchema, + }) + .passthrough(); export const McpbManifestCompatibilitySchema = z .object({ @@ -51,31 +59,37 @@ export const McpbManifestCompatibilitySchema = z }) .passthrough(); -export const McpbManifestToolSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}); +export const McpbManifestToolSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + }) + .passthrough(); -export const McpbManifestPromptSchema = z.object({ - name: z.string(), - description: z.string().optional(), - arguments: z.array(z.string()).optional(), - text: z.string(), -}); +export const McpbManifestPromptSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + arguments: z.array(z.string()).optional(), + text: z.string(), + }) + .passthrough(); -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 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(), + }) + .passthrough(); export const McpbManifestLocalizationSchema = z .object({ diff --git a/src/schemas_loose/0.4.ts b/src/schemas_loose/0.4.ts index 395fd60..3ae9f7c 100644 --- a/src/schemas_loose/0.4.ts +++ b/src/schemas_loose/0.4.ts @@ -7,22 +7,28 @@ 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 McpServerConfigSchema = z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); -export const McpbManifestAuthorSchema = z.object({ - name: z.string(), - email: z.string().email().optional(), - url: z.string().url().optional(), -}); +export const McpbManifestAuthorSchema = z + .object({ + name: z.string(), + email: z.string().email().optional(), + url: z.string().url().optional(), + }) + .passthrough(); -export const McpbManifestRepositorySchema = z.object({ - type: z.string(), - url: z.string().url(), -}); +export const McpbManifestRepositorySchema = z + .object({ + type: z.string(), + url: z.string().url(), + }) + .passthrough(); export const McpbManifestPlatformOverrideSchema = McpServerConfigSchema.partial(); @@ -33,12 +39,14 @@ export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({ .optional(), }); -export const McpbManifestServerSchema = z.object({ - type: z.enum(["python", "node", "binary", "uv"]), - entry_point: z.string(), - // mcp_config is optional for UV type (UV handles execution) - mcp_config: McpbManifestMcpConfigSchema.optional(), -}); +export const McpbManifestServerSchema = z + .object({ + type: z.enum(["python", "node", "binary", "uv"]), + entry_point: z.string(), + // mcp_config is optional for UV type (UV handles execution) + mcp_config: McpbManifestMcpConfigSchema.optional(), + }) + .passthrough(); export const McpbManifestCompatibilitySchema = z .object({ @@ -53,31 +61,37 @@ export const McpbManifestCompatibilitySchema = z }) .passthrough(); -export const McpbManifestToolSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}); +export const McpbManifestToolSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + }) + .passthrough(); -export const McpbManifestPromptSchema = z.object({ - name: z.string(), - description: z.string().optional(), - arguments: z.array(z.string()).optional(), - text: z.string(), -}); +export const McpbManifestPromptSchema = z + .object({ + name: z.string(), + description: z.string().optional(), + arguments: z.array(z.string()).optional(), + text: z.string(), + }) + .passthrough(); -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 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(), + }) + .passthrough(); export const McpbManifestLocalizationSchema = z .object({ diff --git a/test/schemas.test.ts b/test/schemas.test.ts index 52943e0..b3b37cf 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "fs"; import { join } from "path"; import { v0_2, v0_3 } from "../src/schemas/index.js"; +import { v0_4 as loose0_4 } from "../src/schemas_loose/index.js"; describe("McpbManifestSchema", () => { it("should validate a valid manifest", () => { @@ -335,4 +336,44 @@ describe("McpbManifestSchema", () => { expect(result.success).toBe(true); }); }); + + describe("loose schema preserves unknown nested keys (cleanMcpb)", () => { + it("keeps unknown keys nested inside author, server, and mcp_config", () => { + const manifest = { + manifest_version: "0.4", + name: "ext", + version: "1.0.0", + description: "desc", + author: { name: "A", twitter: "@a" }, + server: { + type: "node", + entry_point: "server.js", + custom_server_field: "keep-me", + mcp_config: { + command: "node", + args: ["server.js"], + extra_exec_opt: "keep-me-too", + }, + }, + top_level_unknown: "kept", + }; + + const result = loose0_4.McpbManifestSchema.safeParse(manifest); + expect(result.success).toBe(true); + if (result.success) { + const data = result.data as unknown as { + top_level_unknown: unknown; + author: { twitter: unknown }; + server: { + custom_server_field: unknown; + mcp_config: { extra_exec_opt: unknown }; + }; + }; + expect(data.top_level_unknown).toBe("kept"); + expect(data.author.twitter).toBe("@a"); + expect(data.server.custom_server_field).toBe("keep-me"); + expect(data.server.mcp_config.extra_exec_opt).toBe("keep-me-too"); + } + }); + }); });