/**
* Feishu Block Tools
*
* MCP tools for working with document blocks, including Mermaid diagrams.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { getApiClient } from '../services/api.js'
import { extractDocumentId } from '../constants.js'
import {
InsertDiagramSchema,
ListBlocksSchema,
DeleteBlockSchema,
} from '../schemas/feishu.js'
// 飞书「文本绘图」组件 ID
const DIAGRAM_COMPONENT_TYPE_ID = 'blk_631fefbbae02400430b8f9f4'
export function registerBlockTools(server: McpServer): void {
const api = getApiClient()
// ============================================================================
// feishu_insert_diagram - Insert Mermaid diagram as 「文本绘图」
// ============================================================================
server.tool(
'feishu_insert_diagram',
'在飞书文档中插入 Mermaid 架构图(使用飞书「文本绘图」功能)。支持 flowchart、sequenceDiagram、classDiagram、graph、mindmap、timeline 等类型。',
InsertDiagramSchema.shape,
async (args) => {
try {
const { document_id, mermaid_code, block_id } = args
const docId = extractDocumentId(document_id)
// Get all blocks to find insertion point
const blocks = await api.getAllBlocks(docId)
const pageBlock = blocks.find((b) => b.block_type === 1)
if (!pageBlock) {
throw new Error('无法找到文档根节点')
}
// Determine insertion position
let parentBlockId = pageBlock.block_id
let insertIndex: number | undefined
if (block_id) {
// Find the specified block and insert after it
const targetBlock = blocks.find((b) => b.block_id === block_id)
if (targetBlock) {
parentBlockId = targetBlock.parent_id
// Find index of target block among siblings
const siblings = blocks.filter((b) => b.parent_id === parentBlockId)
const targetIndex = siblings.findIndex((b) => b.block_id === block_id)
if (targetIndex !== -1) {
insertIndex = targetIndex + 1
}
}
}
// Create a 「文本绘图」block (block_type = 40, add_ons component)
// This is the correct way to create Mermaid diagrams in Feishu
const diagramBlock = {
block_type: 40, // add_ons component block
add_ons: {
component_id: '',
component_type_id: DIAGRAM_COMPONENT_TYPE_ID,
record: JSON.stringify({
data: mermaid_code.trim(),
theme: 'default',
view: 'chart',
}),
},
}
await api.createBlocks(docId, parentBlockId, [diagramBlock], insertIndex)
return {
content: [
{
type: 'text' as const,
text: `Mermaid 图表已插入文档。飞书将自动渲染为可视化图表。\n\n提示:在飞书中打开文档,代码块会显示为「文本绘图」,可以看到渲染后的图表。`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `插入图表失败: ${message}`,
},
],
isError: true,
}
}
}
)
// ============================================================================
// feishu_list_blocks - List all blocks in document
// ============================================================================
server.tool(
'feishu_list_blocks',
'列出飞书文档中的所有 Block,用于了解文档结构或获取特定 Block ID 进行后续操作。',
ListBlocksSchema.shape,
async (args) => {
try {
const { document_id } = args
const docId = extractDocumentId(document_id)
const blocks = await api.getAllBlocks(docId)
// Format blocks for display
const blockSummary = blocks.map((block) => ({
block_id: block.block_id,
block_type: block.block_type,
type_name: getBlockTypeName(block.block_type),
parent_id: block.parent_id,
has_children: block.children && block.children.length > 0,
preview: getBlockPreview(block),
}))
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
total: blocks.length,
blocks: blockSummary,
},
null,
2
),
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `列出 Block 失败: ${message}`,
},
],
isError: true,
}
}
}
)
// ============================================================================
// feishu_delete_block - Delete a block (with user confirmation)
// NOTE: Feishu docx API may not support direct block deletion
// ============================================================================
server.tool(
'feishu_delete_block',
'删除飞书文档中的指定 Block。⚠️ 注意:当前飞书 API 可能不支持直接删除文档块,此功能可能无法使用。建议通过编辑将块内容清空作为替代方案。',
DeleteBlockSchema.shape,
async (args) => {
try {
const { document_id, block_id, confirm } = args
const docId = extractDocumentId(document_id)
// SAFETY CHECK: Require explicit confirmation
if (confirm !== true) {
// Return a warning instead of executing deletion
return {
content: [
{
type: 'text' as const,
text: `⚠️ 【安全确认】删除操作需要用户明确确认
您即将删除以下内容:
- 文档 ID: ${docId}
- Block ID: ${block_id}
⚠️ 警告:
1. 删除操作不可恢复!
2. 当前飞书 docx API 可能不支持直接删除文档块(可能返回 404 错误)
3. 如需删除内容,建议使用 feishu_update_content 将内容替换为空
如果用户仍然确认要尝试删除,请设置 confirm=true。`,
},
],
}
}
// User has confirmed, attempt deletion
await api.deleteBlock(docId, block_id)
return {
content: [
{
type: 'text' as const,
text: `✅ Block ${block_id} 删除请求已发送(用户已确认)`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// Provide helpful error message for 404
if (message.includes('404')) {
return {
content: [
{
type: 'text' as const,
text: `删除 Block 失败: 飞书 API 返回 404\n\n这可能是因为飞书当前不支持通过 API 删除单个文档块。\n\n替代方案:使用 feishu_update_content 工具将块内容替换为空。`,
},
],
isError: true,
}
}
return {
content: [
{
type: 'text' as const,
text: `删除 Block 失败: ${message}`,
},
],
isError: true,
}
}
}
)
}
// ============================================================================
// Helper Functions
// ============================================================================
function getBlockTypeName(blockType: number): string {
const typeNames: Record<number, string> = {
1: 'Page',
2: 'Text',
3: 'Heading1',
4: 'Heading2',
5: 'Heading3',
6: 'Heading4',
7: 'Heading5',
8: 'Heading6',
9: 'Heading7',
10: 'Heading8',
11: 'Heading9',
12: 'Bullet',
13: 'Ordered',
14: 'Code',
15: 'Quote',
17: 'Todo',
22: 'Divider',
27: 'Image',
31: 'Table',
37: 'Diagram',
}
return typeNames[blockType] || `Unknown(${blockType})`
}
function getBlockPreview(block: {
block_type: number
text?: { elements?: Array<{ text_run?: { content: string } }> }
heading1?: { elements?: Array<{ text_run?: { content: string } }> }
heading2?: { elements?: Array<{ text_run?: { content: string } }> }
heading3?: { elements?: Array<{ text_run?: { content: string } }> }
bullet?: { elements?: Array<{ text_run?: { content: string } }> }
ordered?: { elements?: Array<{ text_run?: { content: string } }> }
code?: { elements?: Array<{ text_run?: { content: string } }> }
quote?: { elements?: Array<{ text_run?: { content: string } }> }
}): string {
const getContent = (
elements?: Array<{ text_run?: { content: string } }>
): string => {
if (!elements) return ''
const text = elements
.map((el) => el.text_run?.content || '')
.join('')
.slice(0, 50)
return text.length === 50 ? text + '...' : text
}
switch (block.block_type) {
case 1:
return '[Document Root]'
case 2:
return getContent(block.text?.elements)
case 3:
case 4:
case 5:
case 6:
case 7:
case 8:
case 9:
case 10:
case 11:
return getContent(
block.heading1?.elements ||
block.heading2?.elements ||
block.heading3?.elements
)
case 12:
return `• ${getContent(block.bullet?.elements)}`
case 13:
return `1. ${getContent(block.ordered?.elements)}`
case 14:
return `[Code] ${getContent(block.code?.elements)}`
case 15:
return `> ${getContent(block.quote?.elements)}`
case 22:
return '---'
case 27:
return '[Image]'
default:
return ''
}
}