feat(mcp): support structured table record CRUD
This commit is contained in:
1 parent
b508ff0974
commit
2d8df49bbd
8 files changed
+315
-5
No files matched your search
+1
-1
@@ -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 が手伝った内容は、整った、配布できる知識になります。
|
||||
|
||||
@@ -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. 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. 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.
|
||||
|
||||
> 💡 **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.
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ content_text 搜索、摘要和索引使用
|
||||
|
||||
## MCP
|
||||
|
||||
在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地管理你的知识库。系统同时支持文本笔记与图表笔记(涵盖思维导图、流程图和架构图三种),支持对这些笔记进行完整的增删改查;同时还可管理笔记模板与 AI 指令。
|
||||
在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地管理你的知识库。系统同时支持文本笔记与图表笔记(涵盖思维导图、流程图和架构图三种),支持对这些笔记进行完整的增删改查;对于已有的多维表格笔记,还可读取、新增、修改和删除记录,同时管理笔记模板与 AI 指令。
|
||||
|
||||
> 💡 **场景启发:**
|
||||
> 让 AI 真正成为你的知识管家与创作外脑——不仅能将方案秒级生成为可交互的思维导图与架构图,还能为 AI Agent 提供私有上下文。依托 EdgeEver 强大的富文本编辑与精美排版能力,AI 协同沉淀的不再是冰冷文本,而是结构工整、排版优雅、随时可一键分发的高品质知识资产。
|
||||
|
||||
+1
-1
@@ -247,7 +247,7 @@ content_text 搜尋、摘要和索引使用
|
||||
|
||||
## MCP
|
||||
|
||||
在 **個人中心** -> **MCP 設定** 中建立 API Token 並交給 AI Agent,即可讓 Agent 在帳號授權範圍內安全地管理你的知識庫。系統同時支援文字筆記與圖表筆記(涵蓋心智圖、流程圖和架構圖三種),支援對這些筆記進行完整的增刪改查;同時還可管理筆記範本與 AI 指令。
|
||||
在 **個人中心** -> **MCP 設定** 中建立 API Token 並交給 AI Agent,即可讓 Agent 在帳號授權範圍內安全地管理你的知識庫。系統同時支援文字筆記與圖表筆記(涵蓋心智圖、流程圖和架構圖三種),支援對這些筆記進行完整的增刪改查;對於現有的多維表格筆記,還可讀取、新增、修改和刪除記錄,同時管理筆記範本與 AI 指令。
|
||||
|
||||
> 💡 **情境啟發:**
|
||||
> 讓 AI 真正成為你的知識管家與創作外腦——不僅能將方案秒級產生為可互動的心智圖與架構圖,還能為 AI Agent 提供私有脈絡。憑藉 EdgeEver 強大的富文字編輯與精美排版能力,AI 協同沉澱的不再是冰冷文字,而是結構工整、排版優雅、隨時可一鍵分發的高品質知識資產。
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { callMcpTool } from "./index.ts";
|
||||
import { parseDiagramDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared";
|
||||
import { addTableField, parseDiagramDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared";
|
||||
|
||||
class SqliteD1PreparedStatement {
|
||||
constructor(db, sql, bindings = []) {
|
||||
@@ -218,6 +218,109 @@ describe("MCP template and AI instruction management", () => {
|
||||
expect(parseTableDocument(stored.content_markdown)?.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reads and mutates records in one structured table without replacing other 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_memo", {
|
||||
notebookId: "nb_tables",
|
||||
title: "阅读清单",
|
||||
contentMarkdown: serializeTableDocument(createDefaultTableDocument()),
|
||||
});
|
||||
const memoId = created.memo.id;
|
||||
const first = await callMcpTool(context, auth, "get_table_records", { memoId, limit: 1 });
|
||||
expect(first).toMatchObject({ memoId, totalRecords: 1, limit: 1, offset: 0 });
|
||||
expect(first.fields.map((field) => field.id)).toEqual(["fld_name", "fld_status", "fld_date"]);
|
||||
expect(first.records.map((record) => record.id)).toEqual(["rec_sample"]);
|
||||
|
||||
const added = await callMcpTool(context, auth, "add_table_record", {
|
||||
memoId, expectedRevision: first.revision,
|
||||
cells: { fld_name: "读一本书", fld_status: "进行中" },
|
||||
});
|
||||
expect(added.record).toMatchObject({ id: added.recordId, cells: { fld_name: "读一本书", fld_status: "进行中" } });
|
||||
const page = await callMcpTool(context, auth, "get_table_records", { memoId, offset: 1, limit: 1 });
|
||||
expect(page.records.map((record) => record.id)).toEqual([added.recordId]);
|
||||
const byId = await callMcpTool(context, auth, "get_table_records", { memoId, recordId: added.recordId });
|
||||
expect(byId.records[0].cells.fld_name).toBe("读一本书");
|
||||
|
||||
const changed = await callMcpTool(context, auth, "update_table_record", {
|
||||
memoId, recordId: added.recordId, expectedRevision: added.revision,
|
||||
cells: { fld_status: "完成", fld_date: "2026-09-30" },
|
||||
});
|
||||
expect(changed.record.cells).toMatchObject({ fld_name: "读一本书", fld_status: "完成", fld_date: "2026-09-30" });
|
||||
const deleted = await callMcpTool(context, auth, "delete_table_record", {
|
||||
memoId, recordId: added.recordId, expectedRevision: changed.revision,
|
||||
});
|
||||
expect(deleted.deleted).toBe(true);
|
||||
const final = await callMcpTool(context, auth, "get_table_records", { memoId });
|
||||
expect(final.records.map((record) => record.id)).toEqual(["rec_sample"]);
|
||||
const stored = sqlite.query("SELECT content_markdown FROM memo_contents WHERE memo_id = ?").get(memoId);
|
||||
expect(parseTableDocument(stored.content_markdown)?.records.map((record) => record.id)).toEqual(["rec_sample"]);
|
||||
});
|
||||
|
||||
test("rejects stale revisions, invalid cells, non-tables, and missing scope for table tools", async () => {
|
||||
const { auth, context } = createFixture();
|
||||
const sqlite = context.env.storage.db.db;
|
||||
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)")
|
||||
.run("nb_tables", "ws_mcp", "Tables");
|
||||
const table = await callMcpTool(context, auth, "create_memo", {
|
||||
notebookId: "nb_tables", contentMarkdown: serializeTableDocument(createDefaultTableDocument()),
|
||||
});
|
||||
const plain = await callMcpTool(context, auth, "create_memo", {
|
||||
notebookId: "nb_tables", contentMarkdown: "Plain note",
|
||||
});
|
||||
const memoId = table.memo.id;
|
||||
const revision = table.memo.revision;
|
||||
await expect(callMcpTool(context, auth, "add_table_record", {
|
||||
memoId, expectedRevision: revision + 1, cells: { fld_name: "stale" },
|
||||
})).rejects.toMatchObject({ code: "revision_conflict" });
|
||||
await expect(callMcpTool(context, auth, "add_table_record", {
|
||||
memoId, expectedRevision: revision, cells: { unknown: "value" },
|
||||
})).rejects.toMatchObject({ code: "invalid_params" });
|
||||
await expect(callMcpTool(context, auth, "update_table_record", {
|
||||
memoId, recordId: "rec_sample", expectedRevision: revision, cells: { fld_status: "not-an-option" },
|
||||
})).rejects.toMatchObject({ code: "invalid_params" });
|
||||
await expect(callMcpTool(context, auth, "get_table_records", { memoId: plain.memo.id }))
|
||||
.rejects.toMatchObject({ code: "not_table" });
|
||||
await expect(callMcpTool(context, { ...auth, scopes: ["read:memos"] }, "delete_table_record", {
|
||||
memoId, recordId: "rec_sample", expectedRevision: revision,
|
||||
})).rejects.toMatchObject({ code: "forbidden" });
|
||||
await expect(callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "get_table_records", { memoId }))
|
||||
.rejects.toMatchObject({ code: "not_found" });
|
||||
});
|
||||
|
||||
test("accepts only attachment resources owned by the target table memo", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)")
|
||||
.run("nb_tables", "ws_mcp", "Tables");
|
||||
const document = addTableField(createDefaultTableDocument(), { id: "fld_files", name: "附件", type: "attachment" });
|
||||
const table = await callMcpTool(context, auth, "create_memo", {
|
||||
notebookId: "nb_tables", contentMarkdown: serializeTableDocument(document),
|
||||
});
|
||||
const other = await callMcpTool(context, auth, "create_memo", {
|
||||
notebookId: "nb_tables", contentMarkdown: "Other note",
|
||||
});
|
||||
sqlite.query("INSERT INTO resources (id, memo_id, object_key, kind, filename, mime_type, byte_size) VALUES (?, ?, ?, 'attachment', ?, ?, ?)")
|
||||
.run("res_owned", table.memo.id, "mcp-owned", "notes.pdf", "application/pdf", 123);
|
||||
sqlite.query("INSERT INTO resources (id, memo_id, object_key, kind, filename, mime_type, byte_size) VALUES (?, ?, ?, 'attachment', ?, ?, ?)")
|
||||
.run("res_other", other.memo.id, "mcp-other", "secret.pdf", "application/pdf", 456);
|
||||
await expect(callMcpTool(context, auth, "add_table_record", {
|
||||
memoId: table.memo.id, expectedRevision: table.memo.revision,
|
||||
cells: { fld_files: ["res_other"] },
|
||||
})).rejects.toMatchObject({ code: "invalid_params" });
|
||||
const added = await callMcpTool(context, auth, "add_table_record", {
|
||||
memoId: table.memo.id, expectedRevision: table.memo.revision,
|
||||
cells: { fld_files: ["res_owned"] },
|
||||
});
|
||||
expect(added.record.cells.fld_files).toEqual([{
|
||||
resourceId: "res_owned", filename: "notes.pdf", mimeType: "application/pdf", byteSize: 123,
|
||||
}]);
|
||||
await callMcpTool(context, auth, "delete_table_record", {
|
||||
memoId: table.memo.id, recordId: added.recordId, expectedRevision: added.revision,
|
||||
});
|
||||
expect(sqlite.query("SELECT is_deleted FROM resources WHERE id = 'res_owned'").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 (?, ?, ?)")
|
||||
|
||||
@@ -134,6 +134,12 @@ describe("MCP HTTP routes", () => {
|
||||
_meta: { "io.modelcontextprotocol/serverInfo": { name: "edgeever" } },
|
||||
});
|
||||
expect(body.result.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: "get_current_user" })]));
|
||||
expect(body.result.tools).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "get_table_records", annotations: expect.objectContaining({ readOnlyHint: true }) }),
|
||||
expect.objectContaining({ name: "add_table_record" }),
|
||||
expect.objectContaining({ name: "update_table_record" }),
|
||||
expect.objectContaining({ name: "delete_table_record" }),
|
||||
]));
|
||||
});
|
||||
|
||||
test("requires matching 2026 transport headers and per-request metadata", async () => {
|
||||
|
||||
@@ -6,18 +6,27 @@ import {
|
||||
TemplateUpdateSchema,
|
||||
docToText,
|
||||
markdownToDoc,
|
||||
addTableRecord,
|
||||
createTableId,
|
||||
getTableSummary,
|
||||
TABLE_ATTACHMENT_LIMIT,
|
||||
TABLE_RECORD_LIMIT,
|
||||
hasTableDocumentMarker,
|
||||
parseDiagramDocument,
|
||||
parseTableDocument,
|
||||
removeTableRecord,
|
||||
serializeDiagramDocument,
|
||||
serializeTableDocument,
|
||||
stripDiagramDocumentMarker,
|
||||
stripTableDocumentMarker,
|
||||
tableFallbackMarkdown,
|
||||
updateTableCell,
|
||||
type DiagramDocument,
|
||||
type DiagramNodeShape,
|
||||
type MemoDetail,
|
||||
type MemoSummary,
|
||||
type MemoUpdateInput,
|
||||
type TableDocument,
|
||||
} from "@edgeever/shared";
|
||||
import type { DiagramIr, DiagramIrNodeType } from "@edgeever/shared/diagram-layout";
|
||||
import { audit, auditStatement } from "./audit";
|
||||
@@ -393,6 +402,79 @@ const memoWithoutTablePayload = (memo: MemoDetail) => {
|
||||
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
|
||||
};
|
||||
|
||||
const requiredTableRevision = (value: unknown) => {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new AppError("invalid_params", "expectedRevision must be a non-negative integer", 400);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
const tableCellPatch = async (
|
||||
db: DatabaseAdapter,
|
||||
memoId: string,
|
||||
document: TableDocument,
|
||||
value: unknown,
|
||||
allowEmpty: boolean,
|
||||
) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AppError("invalid_params", "cells must be an object keyed by field ID", 400);
|
||||
}
|
||||
const cells = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
if (!allowEmpty && Object.keys(cells).length === 0) {
|
||||
throw new AppError("invalid_params", "cells must include at least one field", 400);
|
||||
}
|
||||
const fields = new Map(document.fields.map((field) => [field.id, field]));
|
||||
for (const [fieldId, cell] of Object.entries(cells)) {
|
||||
const field = fields.get(fieldId);
|
||||
if (!field) throw new AppError("invalid_params", `Unknown table field: ${fieldId}`, 400);
|
||||
if (field.type === "attachment") {
|
||||
if (!Array.isArray(cell) || cell.length > TABLE_ATTACHMENT_LIMIT || cell.some((id) => typeof id !== "string")) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an array of up to ${TABLE_ATTACHMENT_LIMIT} resource IDs`, 400);
|
||||
}
|
||||
if (new Set(cell).size !== cell.length) {
|
||||
throw new AppError("invalid_params", `${fieldId} contains duplicate resource IDs`, 400);
|
||||
}
|
||||
const attachments = [];
|
||||
for (const resourceId of cell) {
|
||||
const resource = await db.prepare(
|
||||
`SELECT id, filename, mime_type, byte_size FROM resources
|
||||
WHERE id = ? AND memo_id = ? AND kind = 'attachment' AND is_deleted = 0`,
|
||||
).bind(resourceId, memoId).first<{ id: string; filename: string | null; mime_type: string | null; byte_size: number }>();
|
||||
if (!resource) throw new AppError("invalid_params", `Attachment resource not found in this memo: ${resourceId}`, 400);
|
||||
attachments.push({
|
||||
resourceId: resource.id,
|
||||
filename: resource.filename ?? "attachment",
|
||||
mimeType: resource.mime_type ?? "application/octet-stream",
|
||||
byteSize: resource.byte_size,
|
||||
});
|
||||
}
|
||||
normalized[fieldId] = attachments;
|
||||
continue;
|
||||
}
|
||||
if (field.type === "checkbox") {
|
||||
if (typeof cell !== "boolean") throw new AppError("invalid_params", `${fieldId} requires a boolean`, 400);
|
||||
} else if (field.type === "number") {
|
||||
if (cell !== null && (typeof cell !== "number" || !Number.isFinite(cell))) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires a finite number or null`, 400);
|
||||
}
|
||||
} else if (cell !== null && (typeof cell !== "string" || cell.length > 2000)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires a string of at most 2000 characters or null`, 400);
|
||||
}
|
||||
if (field.type === "select" && cell && !field.options?.includes(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an existing option`, 400);
|
||||
}
|
||||
if (field.type === "date" && cell && !/^\d{4}-\d{2}-\d{2}$/.test(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires YYYY-MM-DD`, 400);
|
||||
}
|
||||
if (field.type === "url" && cell && !/^https?:\/\//i.test(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an http:// or https:// URL`, 400);
|
||||
}
|
||||
normalized[fieldId] = cell;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
type MutableDiagramEdge = ReturnType<typeof diagramSemanticGraph>["edges"][number];
|
||||
|
||||
const applyDiagramOperations = async (
|
||||
@@ -698,6 +780,74 @@ export const callMcpTool = async (
|
||||
if (table) return { memo: memoWithoutTablePayload(memo), structuredTable: getTableSummary(memo.contentMarkdown).tablePreview };
|
||||
return { memo };
|
||||
}
|
||||
case "get_table_records": {
|
||||
assertScope(auth, "read:memos");
|
||||
const memoId = getRequiredString(args.memoId, "memoId");
|
||||
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);
|
||||
const recordId = getOptionalString(args.recordId);
|
||||
const offset = clampNumber(Number(args.offset ?? 0), 0, document.records.length);
|
||||
const limit = clampNumber(Number(args.limit ?? 50), 1, 100);
|
||||
const record = recordId ? document.records.find((item) => item.id === recordId) : undefined;
|
||||
if (recordId && !record) throw new AppError("not_found", "Table record not found", 404);
|
||||
return {
|
||||
memoId,
|
||||
revision: memo.revision,
|
||||
fields: document.fields,
|
||||
records: record ? [record] : document.records.slice(offset, offset + limit),
|
||||
totalRecords: document.records.length,
|
||||
...(recordId ? {} : { offset, limit }),
|
||||
};
|
||||
}
|
||||
case "add_table_record":
|
||||
case "update_table_record":
|
||||
case "delete_table_record": {
|
||||
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);
|
||||
}
|
||||
let next = document;
|
||||
let recordId: string;
|
||||
if (name === "add_table_record") {
|
||||
if (document.records.length >= TABLE_RECORD_LIMIT) throw new AppError("table_record_limit", "Table record limit reached", 409);
|
||||
const cells = await tableCellPatch(c.env.storage.db, memoId, document, args.cells, true);
|
||||
recordId = createTableId("rec");
|
||||
next = addTableRecord(document, recordId);
|
||||
for (const [fieldId, value] of Object.entries(cells)) next = updateTableCell(next, recordId, fieldId, value);
|
||||
} else {
|
||||
recordId = getRequiredString(args.recordId, "recordId");
|
||||
if (!document.records.some((record) => record.id === recordId)) {
|
||||
throw new AppError("not_found", "Table record not found", 404);
|
||||
}
|
||||
if (name === "update_table_record") {
|
||||
const cells = await tableCellPatch(c.env.storage.db, memoId, document, args.cells, false);
|
||||
for (const [fieldId, value] of Object.entries(cells)) next = updateTableCell(next, recordId, fieldId, value);
|
||||
} else {
|
||||
next = removeTableRecord(document, recordId);
|
||||
}
|
||||
}
|
||||
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,
|
||||
recordId,
|
||||
...(name === "delete_table_record" ? { deleted: true } : { record: next.records.find((item) => item.id === recordId) }),
|
||||
};
|
||||
}
|
||||
case "create_memo": {
|
||||
assertScope(auth, "write:memos");
|
||||
const notebookId = getRequiredString(args.notebookId, "notebookId");
|
||||
|
||||
@@ -132,6 +132,56 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_table_records",
|
||||
description: "Read the fields and a page of records from one structured table memo. Use field IDs and record IDs for subsequent writes. Returns the memo revision for optimistic concurrency.",
|
||||
inputSchema: {
|
||||
type: "object", required: ["memoId"], additionalProperties: false,
|
||||
properties: {
|
||||
memoId: { type: "string", minLength: 1 },
|
||||
recordId: { type: "string", minLength: 1 },
|
||||
offset: { type: "integer", minimum: 0 },
|
||||
limit: { type: "integer", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object", required: ["memoId", "expectedRevision", "cells"], additionalProperties: false,
|
||||
properties: {
|
||||
memoId: { type: "string", minLength: 1 },
|
||||
expectedRevision: { type: "integer", minimum: 0 },
|
||||
cells: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_table_record",
|
||||
description: "Update selected cells of one record in a structured table memo. Other cells remain unchanged. Attachment fields accept arrays of resource IDs already uploaded to this memo.",
|
||||
inputSchema: {
|
||||
type: "object", required: ["memoId", "recordId", "expectedRevision", "cells"], additionalProperties: false,
|
||||
properties: {
|
||||
memoId: { type: "string", minLength: 1 },
|
||||
recordId: { type: "string", minLength: 1 },
|
||||
expectedRevision: { type: "integer", minimum: 0 },
|
||||
cells: { type: "object", minProperties: 1, additionalProperties: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_table_record",
|
||||
description: "Delete one record from a structured table memo by record ID. This also releases attachments used only by the removed record.",
|
||||
inputSchema: {
|
||||
type: "object", required: ["memoId", "recordId", "expectedRevision"], additionalProperties: false,
|
||||
properties: {
|
||||
memoId: { type: "string", minLength: 1 },
|
||||
recordId: { type: "string", minLength: 1 },
|
||||
expectedRevision: { type: "integer", minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_diagram_memo",
|
||||
description:
|
||||
@@ -769,6 +819,7 @@ const READ_ONLY_MCP_TOOLS = new Set([
|
||||
"search_memos",
|
||||
"list_memos",
|
||||
"get_memo",
|
||||
"get_table_records",
|
||||
"get_diagram",
|
||||
"list_memo_resources",
|
||||
"list_resources",
|
||||
|
||||
Reference in new issue
Block a user