Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions apps/api/src/authoring-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -154,7 +181,7 @@ export const AUTHORING_TOOLS: McpToolDefinition[] = [
type: "object",
properties: {
projectId: project,
document: MATERIAL_DOCUMENT_JSON_SCHEMA,
document: materialToolSchema,
},
required: ["projectId", "document"],
},
Expand Down
8 changes: 8 additions & 0 deletions docs/mcp-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 管理页“刷新工具”,并在插件详情中确认可用工具实际出现。
2 changes: 2 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 列表通过标记完成。
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 69 additions & 1 deletion tests/e2e/17-mcp-discovery-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<string, unknown>;
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 [
Expand Down
Loading