/**
* Feishu Document Tools
*
* MCP tools for reading, creating, and editing Feishu documents.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { getApiClient } from '../services/api.js'
import { extractDocumentId } from '../constants.js'
import {
ReadDocumentSchema,
CreateDocumentSchema,
AppendContentSchema,
UpdateContentSchema,
GetDocumentInfoSchema,
} from '../schemas/feishu.js'
import { parseMarkdownTable, calculateColumnWidths } from './table.js'
// Helper to add delay between API calls
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// Content segment type
interface ContentSegment {
type: 'text' | 'table'
content: string
}
/**
* Split markdown content into segments, separating tables from regular content
* This allows tables to be handled with the Table API while other content uses regular blocks
*/
function splitContentWithTables(content: string): ContentSegment[] {
const lines = content.split('\n')
const segments: ContentSegment[] = []
let currentText: string[] = []
let currentTable: string[] = []
let inTable = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const isTableLine = line.trim().startsWith('|') && line.trim().endsWith('|')
const isSeparatorLine = line.trim().match(/^\|[\s\-:|]+\|$/)
if (isTableLine || isSeparatorLine) {
// Start or continue table
if (!inTable && currentText.length > 0) {
// Flush accumulated text
segments.push({ type: 'text', content: currentText.join('\n') })
currentText = []
}
inTable = true
currentTable.push(line)
} else {
// Not a table line
if (inTable && currentTable.length > 0) {
// Flush accumulated table (only if it's a valid table with 2+ lines)
if (currentTable.length >= 2 && parseMarkdownTable(currentTable.join('\n'))) {
segments.push({ type: 'table', content: currentTable.join('\n') })
} else {
// Not a valid table, treat as text
currentText.push(...currentTable)
}
currentTable = []
inTable = false
}
currentText.push(line)
}
}
// Flush remaining content
if (inTable && currentTable.length > 0) {
if (currentTable.length >= 2 && parseMarkdownTable(currentTable.join('\n'))) {
segments.push({ type: 'table', content: currentTable.join('\n') })
} else {
currentText.push(...currentTable)
}
}
if (currentText.length > 0) {
segments.push({ type: 'text', content: currentText.join('\n') })
}
return segments
}
export function registerDocumentTools(server: McpServer): void {
const api = getApiClient()
// ============================================================================
// feishu_read_document - Read document content
// ============================================================================
server.tool(
'feishu_read_document',
'读取飞书文档内容。支持输入文档 ID 或完整 URL,返回 Markdown 或 JSON 格式的文档内容。',
ReadDocumentSchema.shape,
async (args) => {
try {
const { document_id, format } = args
const docId = extractDocumentId(document_id)
// Get document info first
const docInfo = await api.getDocument(docId)
// Get all blocks
const blocks = await api.getAllBlocks(docId)
if (format === 'json') {
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
title: docInfo.title,
document_id: docInfo.document_id,
blocks: blocks,
},
null,
2
),
},
],
}
}
// Convert to markdown
const markdown = api.blocksToMarkdown(blocks)
const result = `# ${docInfo.title}\n\n${markdown}`
return {
content: [
{
type: 'text' as const,
text: result,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `读取文档失败: ${message}`,
},
],
isError: true,
}
}
}
)
// ============================================================================
// feishu_create_document - Create new document
// ============================================================================
server.tool(
'feishu_create_document',
'创建新的飞书文档。可以指定标题和 Markdown 格式的内容,返回新文档的 URL。',
CreateDocumentSchema.shape,
async (args) => {
try {
const { title, content, folder_token } = args
// Create the document
const { documentId, url } = await api.createDocument(title, folder_token)
// If content provided, add it
if (content && content.trim()) {
// Get the page block (root block)
const blocks = await api.getAllBlocks(documentId)
const pageBlock = blocks.find((b) => b.block_type === 1)
if (pageBlock) {
// Parse markdown to blocks
const contentBlocks = api.markdownToBlocks(content)
// Create blocks as children of page
if (contentBlocks.length > 0) {
await api.createBlocks(documentId, pageBlock.block_id, contentBlocks)
}
}
}
return {
content: [
{
type: 'text' as const,
text: `文档创建成功!\n\n📄 标题: ${title}\n🔗 链接: ${url}\n📋 ID: ${documentId}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `创建文档失败: ${message}`,
},
],
isError: true,
}
}
}
)
// ============================================================================
// feishu_append_content - Append content to document
// ============================================================================
server.tool(
'feishu_append_content',
'向飞书文档末尾追加内容。支持 Markdown 格式,包括标题、列表、代码块、表格等。',
AppendContentSchema.shape,
async (args) => {
try {
const { document_id, content } = args
const docId = extractDocumentId(document_id)
// Get the page block
const blocks = await api.getAllBlocks(docId)
const pageBlock = blocks.find((b) => b.block_type === 1)
if (!pageBlock) {
throw new Error('无法找到文档根节点')
}
// Split content into segments: regular content and tables
const segments = splitContentWithTables(content)
let totalBlocks = 0
let tableCount = 0
for (const segment of segments) {
if (segment.type === 'table') {
// Handle table separately using Table API
const parsed = parseMarkdownTable(segment.content)
if (parsed) {
const { headers, rows } = parsed
const rowCount = rows.length + 1
const columnCount = headers.length
const allRows = [headers, ...rows]
const cellTexts = allRows.flatMap((row) => row)
// Calculate adaptive column widths based on content
const colWidths = calculateColumnWidths(headers, rows)
// Feishu API limit: max 9 rows per table creation
const MAX_ROWS = 9
const actualRowCount = Math.min(rowCount, MAX_ROWS)
const tableBlock = {
block_type: 31, // Table block type
table: {
property: {
row_size: actualRowCount,
column_size: columnCount,
column_width: colWidths, // Set column widths
},
cells: cellTexts.slice(0, actualRowCount * columnCount),
},
}
const createResult = await api.createBlocks(docId, pageBlock.block_id, [tableBlock])
// Fill table cells with content
if (createResult && createResult.length > 0) {
const createdTable = createResult[0] as unknown as { table?: { cells?: string[] }; children?: string[] }
const cellIds = createdTable.table?.cells || createdTable.children || []
for (let i = 0; i < cellIds.length && i < cellTexts.length; i++) {
const cellId = cellIds[i]
const text = cellTexts[i]
if (cellId && text) {
if (i > 0) {
await sleep(50)
}
try {
await api.createBlocks(docId, cellId, [{
block_type: 2, // Text block
text: {
elements: [{ text_run: { content: text } }]
}
}])
} catch (cellError) {
console.error(`Failed to fill cell ${i}: ${cellError}`)
}
}
}
}
tableCount++
totalBlocks++
}
} else {
// Regular content
const contentBlocks = api.markdownToBlocks(segment.content)
if (contentBlocks.length > 0) {
await api.createBlocks(docId, pageBlock.block_id, contentBlocks)
totalBlocks += contentBlocks.length
}
}
}
if (totalBlocks === 0) {
return {
content: [
{
type: 'text' as const,
text: '内容为空,未做任何更改',
},
],
}
}
const tableInfo = tableCount > 0 ? `(包含 ${tableCount} 个表格)` : ''
return {
content: [
{
type: 'text' as const,
text: `成功追加 ${totalBlocks} 个内容块到文档${tableInfo}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `追加内容失败: ${message}`,
},
],
isError: true,
}
}
}
)
// ============================================================================
// feishu_update_content - Replace document content
// ============================================================================
server.tool(
'feishu_update_content',
'替换飞书文档的全部内容(保留标题)。用于完全重写文档内容。',
UpdateContentSchema.shape,
async (args) => {
try {
const { document_id, content } = args
const docId = extractDocumentId(document_id)
// Get all blocks
const blocks = await api.getAllBlocks(docId)
const pageBlock = blocks.find((b) => b.block_type === 1)
if (!pageBlock) {
throw new Error('无法找到文档根节点')
}
// Delete all non-page blocks
const blocksToDelete = blocks.filter((b) => b.block_type !== 1)
for (const block of blocksToDelete) {
try {
await api.deleteBlock(docId, block.block_id)
} catch {
// Ignore deletion errors (block may already be deleted as child)
}
}
// Parse and add new content
const contentBlocks = api.markdownToBlocks(content)
if (contentBlocks.length > 0) {
await api.createBlocks(docId, pageBlock.block_id, contentBlocks)
}
return {
content: [
{
type: 'text' as const,
text: `文档内容已更新,共 ${contentBlocks.length} 个内容块`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `更新内容失败: ${message}`,
},
],
isError: true,
}
}
}
)
// ============================================================================
// feishu_get_document_info - Get document metadata
// ============================================================================
server.tool(
'feishu_get_document_info',
'获取飞书文档的基本信息,包括标题、ID、版本号等。',
GetDocumentInfoSchema.shape,
async (args) => {
try {
const { document_id } = args
const docId = extractDocumentId(document_id)
const info = await api.getDocument(docId)
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
title: info.title,
document_id: info.document_id,
revision_id: info.revision_id,
url: `https://feishu.cn/docx/${info.document_id}`,
},
null,
2
),
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `获取文档信息失败: ${message}`,
},
],
isError: true,
}
}
}
)
}