diff --git a/crates/op-host-services/src/mcp_serve/schemas.rs b/crates/op-host-services/src/mcp_serve/schemas.rs index 61331f25e..37214079b 100644 --- a/crates/op-host-services/src/mcp_serve/schemas.rs +++ b/crates/op-host-services/src/mcp_serve/schemas.rs @@ -93,8 +93,8 @@ pub const TOOL_SCHEMAS: &[&str] = &[ r##"{"name":"set_variable_color","description":"Set a Color-kind variable's value.","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"hex":{"type":"string","description":"#rgb / #rrggbb / #rrggbbaa"}},"required":["name","hex"]}}"##, r#"{"name":"batch_design","description":"Insert or refine design content. Accepts nodes_json, I(parent,nodeJson) insert operations, multi-line I/U/C/R/G/M/D programs, or a sandboxed JavaScript program via script. Transactional: if any operation fails, NONE of the batch is applied (the document is unchanged) and errors[] lists every failing line — fix them and resend the whole batch. Keep each call to <=25 operations; split large screens into logical section batches.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"nodes_json":{"type":"string","description":"JSON array of simple leaf descriptors"},"operations":{"type":"string","description":"TS batch_design DSL, e.g. root=I(null,{...}), U(\"n1\",{\"x\":10}), D(\"n1\"), M(\"n1\",null), copy=C(root,null,{...}), img=G(\"image-slot-id\",\"search\",\"prompt\") requires an existing EMPTY target, while img=G(\"rail-id\",\"search\",\"prompt\",\"append\") is accepted only when the parent declares horizontal/vertical layout and should be followed by U(img,{\"width\":120,\"height\":90}); null/populated slot targets and layout-none/omitted append targets are rejected"},"script":{"type":"string","description":"JavaScript program run in a sandboxed QuickJS: build nodes by calling I(parent, obj) or K(kitId,parent,overrides), with loops and data arrays allowed. C/U/D/M/R/G are rejected here with an instruction to use operations; console is a no-op. Expands to the operations DSL. Mutually exclusive with nodes_json/operations. Limits: 256 KiB source, 4096 inserts, 2s, 64 MiB, 8 MiB recorded output."},"postProcess":{"type":"boolean"},"canvasWidth":{"type":"number"},"pageId":{"type":"string"}}}}"#, r#"{"name":"get_design_prompt","description":"Get OpenPencil design-generation prompt knowledge. Pass section for a focused subset; omit it for all sections. style and design-md are derived from the live document's design.md when present.","inputSchema":{"type":"object","properties":{"section":{"type":"string","description":"Prompt section name, e.g. all, layout, style, design-md, elements, codegen-react"},"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"}},"required":[]}}"#, - r#"{"name":"design_skeleton","description":"Layered design workflow phase 1: create a root frame plus section frames. Accepts TS-style rootFrame/sections plus optional canvasWidth/pageId; legacy nodes_json/operations payloads remain accepted for compatibility.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"rootFrame":{"type":"object","description":"Root frame definition: name, width, height, layout, gap, fill, padding"},"sections":{"type":"array","description":"Section frame definitions; each item needs name and may include height, layout, padding, gap, fill, role, justifyContent, alignItems"},"styleGuide":{"type":"object","description":"Optional style guide metadata"},"canvasWidth":{"type":"number","description":"Canvas width for section contentWidth estimates"},"pageId":{"type":"string","description":"Target page ID or index"},"nodes_json":{"type":"string","description":"Legacy JSON array of simple leaf descriptors"},"operations":{"type":"string","description":"Legacy TS batch_design DSL"}},"required":["rootFrame","sections"]}}"#, - r#"{"name":"design_content","description":"Layered design workflow phase 2: fill child nodes into a section frame created by design_skeleton. Accepts TS-style sectionId/children plus optional postProcess/canvasWidth/pageId; legacy nodes_json/operations payloads remain accepted for compatibility.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"sectionId":{"type":"string","description":"ID of the section frame from design_skeleton"},"children":{"type":"array","description":"Child node definitions to insert under the section"},"postProcess":{"type":"boolean","description":"Apply post-processing after insertion; default true in the TS MCP"},"canvasWidth":{"type":"number","description":"Canvas width for post-processing; default 1200"},"pageId":{"type":"string","description":"Target page ID or index"},"nodes_json":{"type":"string","description":"Legacy JSON array of simple leaf descriptors"},"operations":{"type":"string","description":"Legacy TS batch_design DSL with I(parent,nodeJson) inserts or single U/D/M refine op"}},"required":["sectionId","children"]}}"#, + r#"{"name":"design_skeleton","description":"Layered design workflow phase 1: create a root frame plus section frames. Accepts TS-style rootFrame/sections plus optional canvasWidth/pageId; legacy nodes_json/operations payloads remain accepted for compatibility.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"rootFrame":{"type":"object","description":"Root frame definition: name, width, height, layout, gap, fill, padding"},"sections":{"type":"array","items":{"type":"object"},"description":"Section frame definitions; each item needs name and may include height, layout, padding, gap, fill, role, justifyContent, alignItems"},"styleGuide":{"type":"object","description":"Optional style guide metadata"},"canvasWidth":{"type":"number","description":"Canvas width for section contentWidth estimates"},"pageId":{"type":"string","description":"Target page ID or index"},"nodes_json":{"type":"string","description":"Legacy JSON array of simple leaf descriptors"},"operations":{"type":"string","description":"Legacy TS batch_design DSL"}},"required":["rootFrame","sections"]}}"#, + r#"{"name":"design_content","description":"Layered design workflow phase 2: fill child nodes into a section frame created by design_skeleton. Accepts TS-style sectionId/children plus optional postProcess/canvasWidth/pageId; legacy nodes_json/operations payloads remain accepted for compatibility.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"sectionId":{"type":"string","description":"ID of the section frame from design_skeleton"},"children":{"type":"array","items":{"type":"object"},"description":"Child node definitions to insert under the section"},"postProcess":{"type":"boolean","description":"Apply post-processing after insertion; default true in the TS MCP"},"canvasWidth":{"type":"number","description":"Canvas width for post-processing; default 1200"},"pageId":{"type":"string","description":"Target page ID or index"},"nodes_json":{"type":"string","description":"Legacy JSON array of simple leaf descriptors"},"operations":{"type":"string","description":"Legacy TS batch_design DSL with I(parent,nodeJson) inserts or single U/D/M refine op"}},"required":["sectionId","children"]}}"#, r#"{"name":"design_refine","description":"Layered design workflow phase 3: polish an existing design root. Accepts rootId plus optional canvasWidth/pageId and runs deterministic cleanup; legacy nodes_json/operations payloads remain accepted for compatibility.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"rootId":{"type":"string","description":"ID of the root frame to refine"},"canvasWidth":{"type":"number","description":"Canvas width for cleanup heuristics"},"pageId":{"type":"string","description":"Target page ID or index"},"nodes_json":{"type":"string","description":"Legacy JSON array of simple leaf descriptors"},"operations":{"type":"string","description":"Legacy TS batch_design DSL"}}}}"#, r#"{"name":"set_variable_number","description":"Set a Number-kind variable's value (decimal, may be negative or fractional).","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]}}"#, r#"{"name":"set_variable_string","description":"Set a String-kind variable's value (free-form text).","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]}}"#, @@ -139,6 +139,40 @@ pub const DEBUG_TOOL_SCHEMAS: &[&str] = &[ #[cfg(test)] mod tests { use super::TOOL_SCHEMAS; + #[cfg(feature = "mcp-debug-tools")] + use super::DEBUG_TOOL_SCHEMAS; + + #[test] + fn all_array_properties_declare_items() { + // MCP clients (e.g. the VS Code extension) reject a tool whose + // inputSchema contains `"type":"array"` without an `items` keyword + // ("tool parameters array type must have items"). Enumerate every + // schema property and assert every array declares `items`. + let catalogs = [ + TOOL_SCHEMAS, + #[cfg(feature = "mcp-debug-tools")] + DEBUG_TOOL_SCHEMAS, + ]; + for catalog in catalogs { + for raw in catalog { + let value: serde_json::Value = + serde_json::from_str(raw).expect("tool schema must be valid JSON"); + let properties = value["inputSchema"]["properties"] + .as_object() + .unwrap_or_else(|| panic!("tool {} must declare inputSchema.properties", value["name"])); + for (key, prop) in properties { + if prop.get("type").and_then(|t| t.as_str()) == Some("array") { + assert!( + prop.get("items").is_some(), + "tool {} property `{key}` is an array but missing `items` — MCP clients \ + reject schemas without it (tool parameters array type must have items)", + value["name"] + ); + } + } + } + } + } #[test] fn codegen_schemas_describe_blocking_and_retryable_plan_retention() {