From 146637d60eaaae29b13be1dc3601e30db34cdcf9 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 30 Sep 2026 22:51:08 +0800 Subject: [PATCH] feat(mcp): create and edit structured table schemas --- README.ja.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- README.zh-TW.md | 2 +- apps/api/src/mcp-management-tools.test.mjs | 155 +++++++++++++++ apps/api/src/mcp-routes.test.mjs | 2 + apps/api/src/mcp-tool-service.ts | 220 ++++++++++++++++++++- apps/api/src/mcp-tools.ts | 55 +++++- 8 files changed, 434 insertions(+), 6 deletions(-) diff --git a/README.ja.md b/README.ja.md index 71d9d8bd..94cfee39 100644 --- a/README.ja.md +++ b/README.ja.md @@ -244,7 +244,7 @@ content_text 検索、要約、索引 ## MCP -**プロフィール** -> **MCP 設定** で API トークンを作り、AI Agent に渡してください。Agent はアカウントの権限の範囲で知識ベースを扱えます。テキストノートと図のノート(マインドマップ、フローチャート、アーキテクチャ図)の CRUD に対応し、既存の多次元テーブルノートではレコードの読み取り、追加、更新、削除もできます。ノートテンプレートと AI 指示も管理できます。 +**プロフィール** -> **MCP 設定** で API トークンを作り、AI Agent に渡してください。Agent はアカウントの権限の範囲で知識ベースを扱えます。テキストノートと図のノート(マインドマップ、フローチャート、アーキテクチャ図)の CRUD に対応します。フィールドの構成から多次元テーブルノートを作成し、フィールドの編集やレコードの読み取り、追加、更新、削除もできます。ノートテンプレートと AI 指示も管理できます。 > 💡 **着想:** > AI を知識の整理役と創作の相棒にしてください。構想をインタラクティブなマインドマップやアーキテクチャ図へすぐ落とし、Agent に私有の文脈を渡せます。EdgeEver のリッチテキストと体裁と組み合わせると、AI が手伝った内容は、整った、配布できる知識になります。 diff --git a/README.md b/README.md index 65c2424f..c0ab87df 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ Open **Profile** -> **Import and export** to export or import an EdgeEver ZIP. I ## MCP -Create an API token in **Profile** -> **MCP settings** and give it to your AI Agent. The Agent can then securely manage your knowledge base within your account permissions. It supports both text notes and diagram notes (including mind maps, flowcharts, and architecture diagrams) with full CRUD capabilities. For an existing structured table note, the Agent can read, add, update, and delete records. The Agent can also manage note templates and AI instructions. +Create an API token in **Profile** -> **MCP settings** and give it to your AI Agent. The Agent can then securely manage your knowledge base within your account permissions. It supports both text notes and diagram notes (including mind maps, flowcharts, and architecture diagrams) with full CRUD capabilities. The Agent can create a structured table note from a field plan, edit its fields, and read, add, update, or delete its records. The Agent can also manage note templates and AI instructions. > 💡 **Inspiration:** > Make AI your true knowledge orchestrator and creative co-pilot—instantly turn concepts into interactive mind maps and architecture diagrams, while supplying private context to your AI Agents. Paired with EdgeEver’s powerful rich-text editing and elegant typography, AI-assisted content becomes beautifully structured, polished, and publication-ready knowledge assets. diff --git a/README.zh-CN.md b/README.zh-CN.md index fac34e45..e669755c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -249,7 +249,7 @@ content_text 搜索、摘要和索引使用 ## MCP -在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地管理你的知识库。系统同时支持文本笔记与图表笔记(涵盖思维导图、流程图和架构图三种),支持对这些笔记进行完整的增删改查;对于已有的多维表格笔记,还可读取、新增、修改和删除记录,同时管理笔记模板与 AI 指令。 +在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地管理你的知识库。系统同时支持文本笔记与图表笔记(涵盖思维导图、流程图和架构图三种),支持对这些笔记进行完整的增删改查;Agent 还可根据字段方案创建多维表格笔记、编辑字段,并读取、新增、修改和删除表格记录,同时管理笔记模板与 AI 指令。 > 💡 **场景启发:** > 让 AI 真正成为你的知识管家与创作外脑——不仅能将方案秒级生成为可交互的思维导图与架构图,还能为 AI Agent 提供私有上下文。依托 EdgeEver 强大的富文本编辑与精美排版能力,AI 协同沉淀的不再是冰冷文本,而是结构工整、排版优雅、随时可一键分发的高品质知识资产。 diff --git a/README.zh-TW.md b/README.zh-TW.md index f8626951..b2afbf96 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -247,7 +247,7 @@ content_text 搜尋、摘要和索引使用 ## MCP -在 **個人中心** -> **MCP 設定** 中建立 API Token 並交給 AI Agent,即可讓 Agent 在帳號授權範圍內安全地管理你的知識庫。系統同時支援文字筆記與圖表筆記(涵蓋心智圖、流程圖和架構圖三種),支援對這些筆記進行完整的增刪改查;對於現有的多維表格筆記,還可讀取、新增、修改和刪除記錄,同時管理筆記範本與 AI 指令。 +在 **個人中心** -> **MCP 設定** 中建立 API Token 並交給 AI Agent,即可讓 Agent 在帳號授權範圍內安全地管理你的知識庫。系統同時支援文字筆記與圖表筆記(涵蓋心智圖、流程圖和架構圖三種),支援對這些筆記進行完整的增刪改查;Agent 還可根據欄位方案建立多維表格筆記、編輯欄位,並讀取、新增、修改和刪除表格記錄,同時管理筆記範本與 AI 指令。 > 💡 **情境啟發:** > 讓 AI 真正成為你的知識管家與創作外腦——不僅能將方案秒級產生為可互動的心智圖與架構圖,還能為 AI Agent 提供私有脈絡。憑藉 EdgeEver 強大的富文字編輯與精美排版能力,AI 協同沉澱的不再是冰冷文字,而是結構工整、排版優雅、隨時可一鍵分發的高品質知識資產。 diff --git a/apps/api/src/mcp-management-tools.test.mjs b/apps/api/src/mcp-management-tools.test.mjs index e5bd4f30..4681845a 100644 --- a/apps/api/src/mcp-management-tools.test.mjs +++ b/apps/api/src/mcp-management-tools.test.mjs @@ -321,6 +321,161 @@ describe("MCP template and AI instruction management", () => { expect(sqlite.query("SELECT is_deleted FROM resources WHERE id = 'res_owned'").get().is_deleted).toBe(1); }); + test("creates a table from a field plan and edits its schema while preserving records", async () => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_tables", "ws_mcp", "Tables"); + const created = await callMcpTool(context, auth, "create_table_memo", { + notebookId: "nb_tables", title: "客户跟进", tags: ["CRM"], + fields: [ + { name: "客户", type: "text" }, + { name: "阶段", type: "select", options: ["线索", "洽谈", "成交"] }, + ], + }); + const memoId = created.memo.id; + const [customer, stage] = created.fields; + expect(created.memo.title).toBe("客户跟进"); + expect(JSON.stringify(created.memo)).not.toContain("edgeever-table-v1"); + expect((await callMcpTool(context, auth, "get_table_records", { memoId })).totalRecords).toBe(0); + const added = await callMcpTool(context, auth, "add_table_record", { + memoId, expectedRevision: created.revision, + cells: { [customer.id]: "明月公司", [stage.id]: "洽谈" }, + }); + + const operations = [ + { op: "update_field", fieldId: customer.id, changes: { name: "客户名称" } }, + { op: "add_field", field: { name: "金额", type: "number" } }, + ]; + const preview = await callMcpTool(context, auth, "update_table_schema", { + memoId, expectedRevision: added.revision, operations, dryRun: true, + }); + expect(preview).toMatchObject({ dryRun: true, changedCellCount: 0, removedAttachmentCount: 0 }); + expect(preview.fields[2].id).toBeNull(); + expect((await callMcpTool(context, auth, "get_table_records", { memoId })).fields).toHaveLength(2); + const changed = await callMcpTool(context, auth, "update_table_schema", { + memoId, expectedRevision: added.revision, operations, + }); + expect(changed.fields.map((field) => field.name)).toEqual(["客户名称", "阶段", "金额"]); + expect(changed.fields[0].id).toBe(customer.id); + expect(changed.fields[2].id).toMatch(/^fld_/); + const after = await callMcpTool(context, auth, "get_table_records", { memoId, recordId: added.recordId }); + expect(after.records[0].cells).toMatchObject({ [customer.id]: "明月公司", [stage.id]: "洽谈" }); + expect(after.records[0].cells[changed.fields[2].id]).toBeNull(); + }); + + test("previews destructive schema edits and requires explicit data-change authorization", async () => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_tables", "ws_mcp", "Tables"); + const created = await callMcpTool(context, auth, "create_table_memo", { + notebookId: "nb_tables", title: "销售线索", + fields: [ + { name: "名称", type: "text" }, + { name: "状态", type: "select", options: ["新建", "完成"] }, + ], + }); + const [nameField, statusField] = created.fields; + const added = await callMcpTool(context, auth, "add_table_record", { + memoId: created.memo.id, expectedRevision: created.revision, + cells: { [nameField.id]: "机会 A", [statusField.id]: "完成" }, + }); + const operations = [{ op: "update_field", fieldId: statusField.id, changes: { options: ["新建"] } }]; + const preview = await callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: added.revision, operations, dryRun: true, + }); + expect(preview.changedCellCount).toBe(1); + await expect(callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: added.revision, operations, + })).rejects.toMatchObject({ code: "table_data_changes_required" }); + expect((await callMcpTool(context, auth, "get_table_records", { memoId: created.memo.id, recordId: added.recordId })) + .records[0].cells[statusField.id]).toBe("完成"); + const changed = await callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: added.revision, operations, allowDataChanges: true, + }); + expect(changed.changedCellCount).toBe(1); + const after = await callMcpTool(context, auth, "get_table_records", { memoId: created.memo.id, recordId: added.recordId }); + expect(after.records[0].cells[statusField.id]).toBe(""); + await expect(callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: added.revision, + operations: [{ op: "remove_field", fieldId: nameField.id }], + })).rejects.toMatchObject({ code: "revision_conflict" }); + await expect(callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: changed.revision, + operations: [{ op: "remove_field", fieldId: nameField.id }], + })).rejects.toMatchObject({ code: "table_data_changes_required" }); + }); + + test("rejects invalid table field plans and cross-workspace schema edits", async () => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_tables", "ws_mcp", "Tables"); + await expect(callMcpTool(context, auth, "create_table_memo", { + notebookId: "nb_tables", title: "Invalid", fields: [{ name: "状态", type: "select" }], + })).rejects.toMatchObject({ code: "invalid_params" }); + await expect(callMcpTool(context, auth, "create_table_memo", { + notebookId: "nb_tables", title: "Invalid", + fields: [{ name: "名称", type: "text" }, { name: "名称", type: "number" }], + })).rejects.toMatchObject({ code: "invalid_params" }); + const created = await callMcpTool(context, auth, "create_table_memo", { + notebookId: "nb_tables", title: "Valid", fields: [{ name: "名称", type: "text" }], + }); + await expect(callMcpTool(context, { ...auth, scopes: ["read:memos"] }, "create_table_memo", { + notebookId: "nb_tables", title: "Denied", fields: [{ name: "名称", type: "text" }], + })).rejects.toMatchObject({ code: "forbidden" }); + await expect(callMcpTool(context, { ...auth, scopes: ["read:memos"] }, "update_table_schema", { + memoId: created.memo.id, expectedRevision: created.revision, + operations: [{ op: "add_field", field: { name: "金额", type: "number" } }], + })).rejects.toMatchObject({ code: "forbidden" }); + await expect(callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: created.revision, + operations: [{ op: "remove_field", fieldId: created.fields[0].id }], + })).rejects.toMatchObject({ code: "invalid_params" }); + await expect(callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "update_table_schema", { + memoId: created.memo.id, expectedRevision: created.revision, + operations: [{ op: "add_field", field: { name: "金额", type: "number" } }], + })).rejects.toMatchObject({ code: "not_found" }); + }); + + test("previews field removal, releases unused attachments, and converts cell values only when allowed", async () => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_tables", "ws_mcp", "Tables"); + const created = await callMcpTool(context, auth, "create_table_memo", { + notebookId: "nb_tables", title: "合同", + fields: [{ name: "预算", type: "text" }, { name: "文件", type: "attachment" }], + }); + const [budget, file] = created.fields; + sqlite.query("INSERT INTO resources (id, memo_id, object_key, kind, filename, mime_type, byte_size) VALUES (?, ?, ?, 'attachment', ?, ?, ?)") + .run("res_contract", created.memo.id, "contract-file", "contract.pdf", "application/pdf", 100); + const added = await callMcpTool(context, auth, "add_table_record", { + memoId: created.memo.id, expectedRevision: created.revision, + cells: { [budget.id]: "1200", [file.id]: ["res_contract"] }, + }); + const changeType = [{ op: "update_field", fieldId: budget.id, changes: { type: "number" } }]; + const typePreview = await callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: added.revision, operations: changeType, dryRun: true, + }); + expect(typePreview.changedCellCount).toBe(1); + const converted = await callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: added.revision, + operations: changeType, allowDataChanges: true, + }); + const row = await callMcpTool(context, auth, "get_table_records", { memoId: created.memo.id, recordId: added.recordId }); + expect(row.records[0].cells[budget.id]).toBe(1200); + + const removeFile = [{ op: "remove_field", fieldId: file.id }]; + const dropPreview = await callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: converted.revision, operations: removeFile, dryRun: true, + }); + expect(dropPreview).toMatchObject({ changedCellCount: 1, removedAttachmentCount: 1 }); + expect(sqlite.query("SELECT is_deleted FROM resources WHERE id = 'res_contract'").get().is_deleted).toBe(0); + await callMcpTool(context, auth, "update_table_schema", { + memoId: created.memo.id, expectedRevision: converted.revision, + operations: removeFile, allowDataChanges: true, + }); + expect(sqlite.query("SELECT is_deleted FROM resources WHERE id = 'res_contract'").get().is_deleted).toBe(1); + }); + test("keeps mind-map hierarchy edges consistent when a node is reparented", async () => { const { sqlite, auth, context } = createFixture(); sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") diff --git a/apps/api/src/mcp-routes.test.mjs b/apps/api/src/mcp-routes.test.mjs index 714dc446..c736816f 100644 --- a/apps/api/src/mcp-routes.test.mjs +++ b/apps/api/src/mcp-routes.test.mjs @@ -139,6 +139,8 @@ describe("MCP HTTP routes", () => { expect.objectContaining({ name: "add_table_record" }), expect.objectContaining({ name: "update_table_record" }), expect.objectContaining({ name: "delete_table_record" }), + expect.objectContaining({ name: "create_table_memo" }), + expect.objectContaining({ name: "update_table_schema" }), ])); }); diff --git a/apps/api/src/mcp-tool-service.ts b/apps/api/src/mcp-tool-service.ts index b7576ca6..26f2e979 100644 --- a/apps/api/src/mcp-tool-service.ts +++ b/apps/api/src/mcp-tool-service.ts @@ -6,20 +6,27 @@ import { TemplateUpdateSchema, docToText, markdownToDoc, + addTableField, addTableRecord, createTableId, getTableSummary, TABLE_ATTACHMENT_LIMIT, + TABLE_FIELD_LIMIT, + TABLE_FIELD_TYPES, TABLE_RECORD_LIMIT, + TABLE_SCHEMA_VERSION, hasTableDocumentMarker, parseDiagramDocument, parseTableDocument, + listTableAttachmentResourceIds, + removeTableField, removeTableRecord, serializeDiagramDocument, serializeTableDocument, stripDiagramDocumentMarker, stripTableDocumentMarker, tableFallbackMarkdown, + updateTableField, updateTableCell, type DiagramDocument, type DiagramNodeShape, @@ -27,6 +34,8 @@ import { type MemoSummary, type MemoUpdateInput, type TableDocument, + type TableField, + type TableFieldType, } from "@edgeever/shared"; import type { DiagramIr, DiagramIrNodeType } from "@edgeever/shared/diagram-layout"; import { audit, auditStatement } from "./audit"; @@ -92,7 +101,7 @@ export type McpToolDependencies = { createMemoRecord: ( database: DatabaseAdapter, workspaceId: string, - input: { notebookId: string; title?: string; contentMarkdown?: string; tags?: string[]; createdAt?: string; updatedAt?: string }, + input: { notebookId: string; title?: string; contentJson?: unknown; contentMarkdown?: string; tags?: string[]; createdAt?: string; updatedAt?: string }, actor: AuditActor, actorLabel: string, ) => Promise; @@ -475,6 +484,157 @@ const tableCellPatch = async ( return normalized; }; +const tableObject = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AppError("invalid_params", `${label} must be an object`, 400); + } + return value as Record; +}; + +const tableFieldName = (value: unknown) => { + const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; + if (!name || name.length > 80) { + throw new AppError("invalid_params", "Field name must have 1–80 characters", 400); + } + return name; +}; + +const tableFieldType = (value: unknown): TableFieldType => { + if (typeof value !== "string" || !TABLE_FIELD_TYPES.includes(value as TableFieldType)) { + throw new AppError("invalid_params", "Unsupported table field type", 400); + } + return value as TableFieldType; +}; + +const tableFieldOptions = (value: unknown) => { + if (!Array.isArray(value) || value.length < 1 || value.length > 40) { + throw new AppError("invalid_params", "Select fields require 1–40 options", 400); + } + const options = value.map((option) => tableFieldName(option)); + if (new Set(options.map((option) => option.toLocaleLowerCase())).size !== options.length) { + throw new AppError("invalid_params", "Select options must be unique", 400); + } + return options; +}; + +const tableFieldInput = (value: unknown): TableField => { + const input = tableObject(value, "field"); + if (Object.keys(input).some((key) => !["name", "type", "options"].includes(key))) { + throw new AppError("invalid_params", "field must include only name, type, or options", 400); + } + const type = tableFieldType(input.type); + if (type !== "select" && input.options !== undefined) { + throw new AppError("invalid_params", "Only select fields can have options", 400); + } + return { + id: createTableId("fld"), + name: tableFieldName(input.name), + type, + ...(type === "select" ? { options: tableFieldOptions(input.options) } : {}), + }; +}; + +const ensureUniqueTableFieldName = (document: TableDocument, name: string, exceptId?: string) => { + if (document.fields.some((field) => field.id !== exceptId && field.name.toLocaleLowerCase() === name.toLocaleLowerCase())) { + throw new AppError("invalid_params", `Duplicate table field name: ${name}`, 400); + } +}; + +const tableFieldPatch = (current: TableField, value: unknown): Partial> => { + const changes = tableObject(value, "changes"); + if (!Object.keys(changes).length || Object.keys(changes).some((key) => !["name", "type", "options"].includes(key))) { + throw new AppError("invalid_params", "changes must include only name, type, or options", 400); + } + const type = changes.type === undefined ? current.type : tableFieldType(changes.type); + if (type !== "select" && changes.options !== undefined) { + throw new AppError("invalid_params", "Only select fields can have options", 400); + } + if (type === "select" && current.type !== "select" && changes.options === undefined) { + throw new AppError("invalid_params", "Changing to select requires options", 400); + } + return { + ...(changes.name === undefined ? {} : { name: tableFieldName(changes.name) }), + ...(changes.type === undefined ? {} : { type }), + ...(type === "select" && changes.options !== undefined ? { options: tableFieldOptions(changes.options) } : {}), + }; +}; + +const validTableValueForField = (field: TableField, value: unknown) => { + if (value === null || value === "") return true; + if (field.type === "select") return typeof value === "string" && Boolean(field.options?.includes(value)); + if (field.type === "date") return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); + if (field.type === "url") return typeof value === "string" && /^https?:\/\//i.test(value); + return true; +}; + +const applyTableSchemaOperations = (document: TableDocument, value: unknown) => { + if (!Array.isArray(value) || value.length < 1 || value.length > 40) { + throw new AppError("invalid_params", "operations must include 1–40 changes", 400); + } + let next = document; + for (const valueItem of value) { + const item = tableObject(valueItem, "operation"); + if (item.op === "add_field") { + if (next.fields.length >= TABLE_FIELD_LIMIT) throw new AppError("table_field_limit", "Table field limit reached", 409); + const field = tableFieldInput(item.field); + ensureUniqueTableFieldName(next, field.name); + next = addTableField(next, field); + } else if (item.op === "update_field") { + const fieldId = getRequiredString(item.fieldId, "fieldId"); + const current = next.fields.find((field) => field.id === fieldId); + if (!current) throw new AppError("not_found", `Table field not found: ${fieldId}`, 404); + const patch = tableFieldPatch(current, item.changes); + if (patch.name !== undefined) ensureUniqueTableFieldName(next, patch.name, fieldId); + next = updateTableField(next, fieldId, patch); + const updated = next.fields.find((field) => field.id === fieldId)!; + if (updated.type !== "select" && updated.options !== undefined) { + next = { ...next, fields: next.fields.map((field) => field.id === fieldId ? { id: field.id, name: field.name, type: field.type } : field) }; + } + const finalField = next.fields.find((field) => field.id === fieldId)!; + if ((patch.type !== undefined || patch.options !== undefined) && ["select", "date", "url"].includes(finalField.type)) { + next = { + ...next, + records: next.records.map((record) => validTableValueForField(finalField, record.cells[fieldId]) + ? record + : { ...record, cells: { ...record.cells, [fieldId]: "" } }), + }; + } + } else if (item.op === "remove_field") { + const fieldId = getRequiredString(item.fieldId, "fieldId"); + if (!next.fields.some((field) => field.id === fieldId)) { + throw new AppError("not_found", `Table field not found: ${fieldId}`, 404); + } + if (next.fields.length === 1) throw new AppError("invalid_params", "A table must keep at least one field", 400); + next = removeTableField(next, fieldId); + } else { + throw new AppError("invalid_params", "Unknown table schema operation", 400); + } + } + if (!parseTableDocument(serializeTableDocument(next))) { + throw new AppError("invalid_params", "Schema changes produced an invalid table", 400); + } + return next; +}; + +const tableSchemaImpact = (before: TableDocument, after: TableDocument) => { + const afterRecords = new Map(after.records.map((record) => [record.id, record])); + let changedCellCount = 0; + for (const record of before.records) { + const nextRecord = afterRecords.get(record.id); + for (const field of before.fields) { + const oldValue = record.cells[field.id]; + const newValue = nextRecord?.cells[field.id]; + const hasValue = Array.isArray(oldValue) ? oldValue.length > 0 : oldValue !== null && oldValue !== ""; + if (hasValue && JSON.stringify(oldValue) !== JSON.stringify(newValue)) changedCellCount += 1; + } + } + const retainedResources = listTableAttachmentResourceIds(after); + return { + changedCellCount, + removedAttachmentCount: [...listTableAttachmentResourceIds(before)].filter((id) => !retainedResources.has(id)).length, + }; +}; + type MutableDiagramEdge = ReturnType["edges"][number]; const applyDiagramOperations = async ( @@ -801,6 +961,64 @@ export const callMcpTool = async ( ...(recordId ? {} : { offset, limit }), }; } + case "create_table_memo": { + assertScope(auth, "write:memos"); + const notebookId = getRequiredString(args.notebookId, "notebookId"); + const title = getRequiredString(args.title, "title"); + if (title.length > 160) throw new AppError("invalid_params", "title must have at most 160 characters", 400); + if (!Array.isArray(args.fields) || args.fields.length < 1 || args.fields.length > TABLE_FIELD_LIMIT) { + throw new AppError("invalid_params", `fields must include 1–${TABLE_FIELD_LIMIT} fields`, 400); + } + const document: TableDocument = { schemaVersion: TABLE_SCHEMA_VERSION, fields: [], records: [], view: { filters: [], sort: null } }; + for (const value of args.fields) { + const field = tableFieldInput(value); + ensureUniqueTableFieldName(document, field.name); + document.fields.push(field); + } + const memo = await createMemoRecord(c.env.storage.db, auth.workspaceId, { + notebookId, + title, + contentMarkdown: serializeTableDocument(document), + contentJson: markdownToDoc(tableFallbackMarkdown(document)), + tags: getOptionalStringArray(args.tags), + }, getAuditActor(c), getActorLabel(c)); + return { memo: memoWithoutTablePayload(memo), fields: document.fields, revision: memo.revision }; + } + case "update_table_schema": { + assertScope(auth, "write:memos"); + const memoId = getRequiredString(args.memoId, "memoId"); + const expectedRevision = requiredTableRevision(args.expectedRevision); + const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, memoId); + if (!memo) throw new AppError("not_found", "Memo not found", 404); + const document = parseTableDocument(memo.contentMarkdown); + if (!document) throw new AppError("not_table", "Memo is not a structured table", 400); + if (memo.revision !== expectedRevision) { + throw new AppError("revision_conflict", "Table changed since it was read. Read it again before writing.", 409); + } + const next = applyTableSchemaOperations(document, args.operations); + const impact = tableSchemaImpact(document, next); + if (args.dryRun === true) { + const oldIds = new Set(document.fields.map((field) => field.id)); + return { + dryRun: true, + memoId, + revision: memo.revision, + fields: next.fields.map((field) => ({ ...field, id: oldIds.has(field.id) ? field.id : null })), + ...impact, + }; + } + if (impact.changedCellCount > 0 && args.allowDataChanges !== true) { + throw new AppError("table_data_changes_required", `${impact.changedCellCount} existing cell values would change. Preview with dryRun, then pass allowDataChanges=true.`, 409); + } + const result = await updateMemoRecord(c.env.storage.db, auth.workspaceId, memoId, { + expectedRevision, + contentMarkdown: serializeTableDocument(next), + contentJson: markdownToDoc(tableFallbackMarkdown(next)), + tags: memo.tags, + }, getAuditActor(c), getActorLabel(c)); + if (!("memo" in result)) throw new AppError(result.error, result.message, result.status ?? 409); + return { memoId, revision: result.memo.revision, fields: next.fields, ...impact }; + } case "add_table_record": case "update_table_record": case "delete_table_record": { diff --git a/apps/api/src/mcp-tools.ts b/apps/api/src/mcp-tools.ts index 2fa281e1..160e2668 100644 --- a/apps/api/src/mcp-tools.ts +++ b/apps/api/src/mcp-tools.ts @@ -1,4 +1,4 @@ -import { ARCHITECTURE_RESOURCE_ICONS } from "@edgeever/shared"; +import { ARCHITECTURE_RESOURCE_ICONS, TABLE_FIELD_TYPES } from "@edgeever/shared"; const DIAGRAM_IR_NODE_TYPES = [ "topic", @@ -56,6 +56,17 @@ const mutableDiagramEdgeSchema = { }, }; +const tableFieldSchema = { + type: "object", + required: ["name", "type"], + additionalProperties: false, + properties: { + name: { type: "string", minLength: 1, maxLength: 80 }, + type: { type: "string", enum: [...TABLE_FIELD_TYPES] }, + options: { type: "array", maxItems: 40, items: { type: "string", minLength: 1, maxLength: 80 } }, + }, +}; + const MCP_TOOL_DEFINITIONS = [ { name: "get_current_user", @@ -145,6 +156,47 @@ const MCP_TOOL_DEFINITIONS = [ }, }, }, + { + name: "create_table_memo", + description: "Create an editable structured table memo from a field plan. Infer field names and types from the user's request, then pass explicit fields. The table starts empty; add_table_record can populate it. Select fields require options.", + inputSchema: { + type: "object", required: ["notebookId", "title", "fields"], additionalProperties: false, + properties: { + notebookId: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1, maxLength: 160 }, + tags: { type: "array", maxItems: 100, items: { type: "string" } }, + fields: { type: "array", minItems: 1, maxItems: 40, items: tableFieldSchema }, + }, + }, + }, + { + name: "update_table_schema", + description: "Apply field changes to one structured table memo. First use dryRun to preview changedCellCount and removedAttachmentCount. If existing cell values would change, pass allowDataChanges=true to execute. Operations run in order and use stable field IDs from get_table_records.", + inputSchema: { + type: "object", required: ["memoId", "expectedRevision", "operations"], additionalProperties: false, + properties: { + memoId: { type: "string", minLength: 1 }, + expectedRevision: { type: "integer", minimum: 0 }, + dryRun: { type: "boolean" }, + allowDataChanges: { type: "boolean" }, + operations: { + type: "array", minItems: 1, maxItems: 40, + items: { + type: "object", required: ["op"], additionalProperties: false, + properties: { + op: { type: "string", enum: ["add_field", "update_field", "remove_field"] }, + fieldId: { type: "string", minLength: 1 }, + field: tableFieldSchema, + changes: { + type: "object", minProperties: 1, additionalProperties: false, + properties: tableFieldSchema.properties, + }, + }, + }, + }, + }, + }, + }, { name: "add_table_record", description: "Add one record to an existing structured table memo. cells maps field IDs to values; attachment fields accept arrays of resource IDs already uploaded to this memo. Pass the revision returned by get_table_records.", @@ -837,6 +889,7 @@ const READ_ONLY_MCP_TOOLS = new Set([ ]); const NON_DESTRUCTIVE_MCP_TOOLS = new Set([ "create_memo", + "create_table_memo", "create_diagram_memo", "import_memos", "restore_memos",