From 261f973fa2f4737f8381ae8b8f240061b2c5af16 Mon Sep 17 00:00:00 2001 From: Dennis Dornon Date: Thu, 16 Jul 2026 09:12:15 -0400 Subject: [PATCH] Sanitize hostile ability input schemas before tool conversion A PHP dashboard serializing an empty properties map as [] (seen live on two experiment-branch abilities) invalidated the entire tools/list response for spec-compliant MCP clients: the official SDK rejects the payload, leaving the server connected with zero usable tools. Coerce non-object properties to {} and drop malformed required entries, per the treat-remote-schemas-as-hostile rule. Claude-Session: https://claude.ai/code/session_01NnJiXybDffmpEsdwE1cnYT --- src/tool-schema.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++ src/tool-schema.ts | 15 ++++++- 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/tool-schema.test.ts diff --git a/src/tool-schema.test.ts b/src/tool-schema.test.ts new file mode 100644 index 0000000..e1abda7 --- /dev/null +++ b/src/tool-schema.test.ts @@ -0,0 +1,90 @@ +/** + * Tool Schema Conversion Tests + * + * Regression coverage for hostile/malformed remote input schemas. + * PHP dashboards serialize empty associative arrays as JSON arrays, so a + * no-input ability can arrive with `properties: []`. Passing that through + * invalidates the entire tools/list response for spec-compliant MCP clients + * (the official SDK rejects it with a zod error), which leaves the server + * connected but with zero usable tools. + */ + +import { describe, it, expect } from 'vitest'; +import { abilityToTool } from './tool-schema.js'; +import type { Ability } from './abilities.js'; + +function makeAbility(overrides: Partial = {}): Ability { + return { + name: 'mainwp/get-network-snapshot-v1', + label: 'Get Network Snapshot', + description: 'Returns a snapshot of the network.', + category: 'mainwp-sites', + ...overrides, + }; +} + +describe('abilityToTool input schema sanitization', () => { + it('coerces array-typed properties (PHP empty array) to an empty object', () => { + // JSON.parse round-trip mirrors how the payload actually arrives + const ability = makeAbility({ + input_schema: JSON.parse('{"type":"object","properties":[]}') as Record, + }); + + const tool = abilityToTool(ability, 'mainwp'); + + expect(Array.isArray(tool.inputSchema.properties)).toBe(false); + expect(tool.inputSchema.properties).toEqual({}); + expect(tool.inputSchema.required).toEqual([]); + }); + + it('coerces non-object properties to an empty object', () => { + const ability = makeAbility({ + input_schema: { type: 'object', properties: 'bogus' }, + }); + + const tool = abilityToTool(ability, 'mainwp'); + + expect(tool.inputSchema.properties).toEqual({}); + }); + + it('coerces a non-array required field to an empty array', () => { + const ability = makeAbility({ + input_schema: { type: 'object', properties: {}, required: 'site_id' }, + }); + + const tool = abilityToTool(ability, 'mainwp'); + + expect(tool.inputSchema.required).toEqual([]); + }); + + it('drops non-string entries from required', () => { + const ability = makeAbility({ + input_schema: { + type: 'object', + properties: { site_id: { type: 'integer', description: 'Site ID.' } }, + required: ['site_id', 7, null, { bad: true }], + }, + }); + + const tool = abilityToTool(ability, 'mainwp'); + + expect(tool.inputSchema.required).toEqual(['site_id']); + }); + + it('preserves well-formed object properties unchanged', () => { + const ability = makeAbility({ + input_schema: { + type: 'object', + properties: { site_id: { type: 'integer', description: 'Site ID.' } }, + required: ['site_id'], + }, + }); + + const tool = abilityToTool(ability, 'mainwp'); + + expect(tool.inputSchema.properties).toEqual({ + site_id: { type: 'integer', description: 'Site ID.' }, + }); + expect(tool.inputSchema.required).toEqual(['site_id']); + }); +}); diff --git a/src/tool-schema.ts b/src/tool-schema.ts index e2991a2..c3513a3 100644 --- a/src/tool-schema.ts +++ b/src/tool-schema.ts @@ -31,8 +31,19 @@ function convertInputSchema(ability: Ability): Tool['inputSchema'] { // The abilities API uses JSON Schema, which is compatible with MCP // We just need to ensure it has the required structure // Cast to the expected MCP SDK type - const properties = (schema.properties || {}) as { [key: string]: Record }; - const required = (schema.required as string[]) || []; + // + // PHP dashboards serialize empty associative arrays as [] — an array-typed + // `properties` (or malformed `required`) invalidates the whole tools/list + // response for spec-compliant clients, so coerce anything non-conforming. + const rawProperties = schema.properties; + const properties = ( + rawProperties && typeof rawProperties === 'object' && !Array.isArray(rawProperties) + ? rawProperties + : {} + ) as { [key: string]: Record }; + const required = Array.isArray(schema.required) + ? schema.required.filter((entry): entry is string => typeof entry === 'string') + : []; // Backfill missing descriptions from parameter names. // Some upstream abilities omit descriptions; LLMs need them for accurate tool use.