From 83cb0b38cda2a555c1b5d4c2f9cd679f68f12294 Mon Sep 17 00:00:00 2001 From: jettwang Date: Mon, 26 Oct 2026 00:49:37 +0800 Subject: [PATCH] fix: export homogeneous tuple schemas for ChatGPT tool discovery --- apps/api/src/authoring-mcp.ts | 31 ++++++++- docs/mcp-discovery.md | 8 +++ docs/roadmap.md | 2 + package-lock.json | 1 + package.json | 1 + tests/e2e/17-mcp-discovery-transport.test.ts | 70 +++++++++++++++++++- 6 files changed, 110 insertions(+), 3 deletions(-) diff --git a/apps/api/src/authoring-mcp.ts b/apps/api/src/authoring-mcp.ts index 794073b..10575b9 100644 --- a/apps/api/src/authoring-mcp.ts +++ b/apps/api/src/authoring-mcp.ts @@ -11,6 +11,33 @@ import { } from "@video-compose/protocol"; import type { JsonRpcRequest, McpToolDefinition } from "./mcp.ts"; +/** ChatGPT's tool importer requires a schema object in array.items. + * Fixed homogeneous tuples have an equivalent homogeneous array schema. + * Keep the authoritative protocol/resource schema and domain validation intact. + */ +function toolSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map(toolSchema); + if (!value || typeof value !== "object") return value; + const schema = Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, toolSchema(child)]), + ); + if (schema.type === "array" && Array.isArray(schema.items)) { + const items = schema.items; + if ( + !items.length || + schema.minItems !== items.length || + schema.maxItems !== items.length || + items.some((item) => JSON.stringify(item) !== JSON.stringify(items[0])) + ) + throw new Error( + "MCP tuple inputs must be fixed-length and homogeneous before projection.", + ); + schema.items = items[0]; + } + return schema; +} +const materialToolSchema = toolSchema(MATERIAL_DOCUMENT_JSON_SCHEMA); + const project = { type: "string", description: "Authorized project ID" }; const workflowContext = { type: "object", @@ -85,7 +112,7 @@ export const AUTHORING_TOOLS: McpToolDefinition[] = [ "Create a new immutable candidate revision after reading authoring_workflow_get. Full typed manifest; reviews supplied through CRUD are ignored. Images/crops must already be uploaded.", inputSchema: { type: "object", - properties: { ...mutations, document: MATERIAL_DOCUMENT_JSON_SCHEMA }, + properties: { ...mutations, document: materialToolSchema }, required: [ "projectId", "document", @@ -154,7 +181,7 @@ export const AUTHORING_TOOLS: McpToolDefinition[] = [ type: "object", properties: { projectId: project, - document: MATERIAL_DOCUMENT_JSON_SCHEMA, + document: materialToolSchema, }, required: ["projectId", "document"], }, diff --git a/docs/mcp-discovery.md b/docs/mcp-discovery.md index e9440ad..f0c0687 100644 --- a/docs/mcp-discovery.md +++ b/docs/mcp-discovery.md @@ -34,3 +34,11 @@ ## 发布与回退 本改动不需要修改项目/编排文档或素材。当前工作分支同时包含账号隔离与回收站及其 0004 迁移,不能直接全量发布并声称只有发现修复。若单独发布兼容补丁,应从当前线上 SHA 分离仅 MCP 传输/发现与相关测试、文档,再执行独立构建与发布验收;若合并发布,则执行账号功能既定的迁移与归属核对方案。此记录不代表发布已执行。 + +## ChatGPT 导入校验回归(2026-10-26) + +已安装的 vflow 插件在管理页刷新工具时返回 HTTP 400,具体错误为 `mcp_validation: Invalid MCP tool schema for tool 'authoring_document_put'`。不能以通用 JSON Schema 编译通过或官方 SDK 能列举工具替代 ChatGPT 导入验收。 + +资料 Schema 中 `bounds` 使用固定四元素数字元组,Draft-07 导出为数组形式的 `items`。MCP 适配层现将固定、同构元组等价投影为对象形式的 `items`,保留 `minItems: 4`、`maxItems: 4` 及每项 0–1 约束。只影响 `authoring_document_put` / `reference_plan_validate` 的工具描述;协议 Schema、资源中的完整 Schema、REST 和领域校验保持原状。不同类型或非固定元组拒绝自动投影,必须明确设计兼容描述。 + +E2E17 通过 HTTP tools/list 检查全部工具数组项格式,并用 Schema 验证器对比投影前后的合法值、错误长度、错误类型及越界数字。发布后必须再次通过 ChatGPT 管理页“刷新工具”,并在插件详情中确认可用工具实际出现。 diff --git a/docs/roadmap.md b/docs/roadmap.md index d1d8af0..254a9b5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -95,3 +95,5 @@ Issue #1 的协议、共享领域服务、MCP 指导/工具兜底、REST/client | 能力 | 状态 | 正常路径 | 失败与权限 | 验收 | | --- | --- | --- | --- | --- | | MCP 两代动作发现 | 本地实现,待发布与 ChatGPT 回读 | 2025 initialize 与 2026 server/discover;工具/模板/资源发现共用定义 | 空 202、405、非法信封 400;版本/名称头校验;匿名拒绝 | E2E17 两代官方 SDK;详见 mcp-discovery.md | + +MCP 动作发现验收补充:ChatGPT 工具导入拒绝的固定同构元组须在适配层等价转换为对象式 `items`;E2E17 比较投影前后约束,最终验收为已安装插件的“刷新工具”成功与详情列表可见,不能只以 SDK 列表通过标记完成。 diff --git a/package-lock.json b/package-lock.json index a3e23d1..874e9ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^22.13.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "ajv": "6.15.0", "eslint": "^9.21.0", "jsdom": "^26.0.0", "typescript": "^5.8.2", diff --git a/package.json b/package.json index 1c3e57e..4e52dc3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/node": "^22.13.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "ajv": "6.15.0", "eslint": "^9.21.0", "jsdom": "^26.0.0", "typescript": "^5.8.2", diff --git a/tests/e2e/17-mcp-discovery-transport.test.ts b/tests/e2e/17-mcp-discovery-transport.test.ts index 9aac746..16d5c68 100644 --- a/tests/e2e/17-mcp-discovery-transport.test.ts +++ b/tests/e2e/17-mcp-discovery-transport.test.ts @@ -5,7 +5,9 @@ import { StreamableHTTPClientTransport as ModernTransport, } from "@modelcontextprotocol/client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { createApiApp } from "@video-compose/api"; +import { createApiApp, MCP_TOOLS_MANIFEST } from "@video-compose/api"; +import Ajv from "ajv"; +import { MATERIAL_DOCUMENT_JSON_SCHEMA } from "@video-compose/protocol"; import { TEST_AUTH_CONFIG, WRITER_AUTH } from "./auth-fixture.ts"; function fixture() { @@ -166,6 +168,72 @@ describe("E2E 17 — strict MCP action discovery and Streamable HTTP transport", }); expect(origin.status).toBe(403); }); + it("exports importer-compatible array items and preserves exact storyboard bounds validation", async () => { + const { app } = fixture(); + const response = await app.request("/mcp", { + method: "POST", + headers: { ...WRITER_AUTH, "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "schema-regression", + method: "tools/list", + }), + }); + const { result } = (await response.json()) as { + result: { tools: typeof MCP_TOOLS_MANIFEST }; + }; + const visit = (value: unknown) => { + if (!value || typeof value !== "object") return; + const node = value as Record; + if (node.type === "array") expect(node.items).toBeDefined(); + expect(Array.isArray(node.items)).toBe(false); + for (const child of Object.values(node)) + if (typeof child === "object") visit(child); + }; + for (const tool of result.tools) visit(tool.inputSchema); + type MaterialSchema = { + anyOf: Array<{ + properties: { + panels?: { + items: { + properties: { + entities: { items: { properties: { bounds: object } } }; + }; + }; + }; + }; + }>; + }; + const original = ( + MATERIAL_DOCUMENT_JSON_SCHEMA as unknown as MaterialSchema + ).anyOf[3].properties.panels!.items.properties.entities.items.properties + .bounds; + const ajv = new Ajv({ allErrors: true }); + const originalValidate = ajv.compile(original); + for (const name of ["authoring_document_put", "reference_plan_validate"]) { + const schema = result.tools.find((t) => t.name === name)!.inputSchema + .properties.document as MaterialSchema; + const bounds = + schema.anyOf[3].properties.panels!.items.properties.entities.items + .properties.bounds; + expect(bounds).toMatchObject({ + type: "array", + minItems: 4, + maxItems: 4, + items: { type: "number", minimum: 0, maximum: 1 }, + }); + const validate = ajv.compile(bounds); + for (const value of [ + [0, 0, 0.5, 1], + [0, 0, 1], + [0, 0, 1, 1, 1], + [-1, 0, 1, 1], + [0, 0, 2, 1], + [0, 0, "1", 1], + ]) + expect(validate(value)).toBe(originalValidate(value)); + } + }); it("acknowledges notifications and client responses with empty 202, never malformed JSON-RPC", async () => { const { app } = fixture(); for (const message of [