/**
* Feishu Table Tools
*
* MCP tools for inserting tables into Feishu documents.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { getApiClient } from '../services/api.js'
import { extractDocumentId } from '../constants.js'
import { InsertTableSchema } from '../schemas/feishu.js'
import type { ParsedTable, Block } from '../types.js'
// Helper to add delay between API calls
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Calculate adaptive column widths based on content
* Uses character count to estimate pixel width
*/
export function calculateColumnWidths(headers: string[], rows: string[][]): number[] {
const columnCount = headers.length
const minWidth = 80 // Minimum column width
const maxWidth = 400 // Maximum column width
const charWidth = 14 // Average pixel width per character (Chinese chars are wider)
const padding = 24 // Cell padding
// Calculate max content length for each column
const maxLengths: number[] = new Array(columnCount).fill(0)
// Check headers
for (let i = 0; i < columnCount; i++) {
const headerLength = getDisplayLength(headers[i])
maxLengths[i] = Math.max(maxLengths[i], headerLength)
}
// Check all rows
for (const row of rows) {
for (let i = 0; i < columnCount && i < row.length; i++) {
const cellLength = getDisplayLength(row[i])
maxLengths[i] = Math.max(maxLengths[i], cellLength)
}
}
// Convert to pixel widths
return maxLengths.map(len => {
const estimatedWidth = len * charWidth + padding
return Math.min(maxWidth, Math.max(minWidth, estimatedWidth))
})
}
/**
* Get display length of text (Chinese characters count as 2)
*/
function getDisplayLength(text: string): number {
if (!text) return 0
let length = 0
for (const char of text) {
// Chinese and other wide characters
if (char.match(/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/)) {
length += 2
} else {
length += 1
}
}
return length
}
/**
* Parse a Markdown table string into structured data
*/
export function parseMarkdownTable(markdown: string): ParsedTable | null {
const lines = markdown
.trim()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (lines.length < 2) {
return null
}
// Parse header row
const headerLine = lines[0]
const headers = parseTableRow(headerLine)
if (headers.length === 0) {
return null
}
// Skip separator line (|---|---|)
let dataStartIndex = 1
if (lines[1] && lines[1].match(/^\|?[\s-:|]+\|?$/)) {
dataStartIndex = 2
}
// Parse data rows
const rows: string[][] = []
for (let i = dataStartIndex; i < lines.length; i++) {
const row = parseTableRow(lines[i])
if (row.length > 0) {
// Pad or truncate row to match header length
while (row.length < headers.length) {
row.push('')
}
rows.push(row.slice(0, headers.length))
}
}
return { headers, rows }
}
/**
* Parse a single table row
*/
function parseTableRow(line: string): string[] {
// Remove leading/trailing pipes and split
let cleanLine = line.trim()
if (cleanLine.startsWith('|')) {
cleanLine = cleanLine.slice(1)
}
if (cleanLine.endsWith('|')) {
cleanLine = cleanLine.slice(0, -1)
}
return cleanLine.split('|').map((cell) => cell.trim())
}
export function registerTableTools(server: McpServer): void {
const api = getApiClient()
// ============================================================================
// feishu_insert_table - Insert a table into document
// ============================================================================
server.tool(
'feishu_insert_table',
'在飞书文档中插入表格。支持 Markdown 表格语法或结构化数据输入。',
InsertTableSchema.shape,
async (args) => {
try {
const {
document_id,
markdown_table,
headers: inputHeaders,
rows: inputRows,
header_row = true,
column_width,
block_id,
} = args
const docId = extractDocumentId(document_id)
// Parse table data
let headers: string[] = []
let rows: string[][] = []
if (markdown_table) {
const parsed = parseMarkdownTable(markdown_table)
if (!parsed) {
return {
content: [
{
type: 'text' as const,
text: '解析 Markdown 表格失败。请检查格式是否正确。\n\n示例格式:\n| 列1 | 列2 |\n|---|---|\n| 值1 | 值2 |',
},
],
isError: true,
}
}
headers = parsed.headers
rows = parsed.rows
} else if (inputHeaders && inputRows) {
headers = inputHeaders
rows = inputRows
} else {
return {
content: [
{
type: 'text' as const,
text: '请提供 markdown_table 或同时提供 headers 和 rows 参数',
},
],
isError: true,
}
}
// Validate table data
const columnCount = headers.length
const rowCount = rows.length + 1 // +1 for header row
if (columnCount === 0) {
return {
content: [
{
type: 'text' as const,
text: '表格必须至少有一列',
},
],
isError: true,
}
}
// 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) {
const targetBlock = blocks.find((b) => b.block_id === block_id)
if (targetBlock) {
parentBlockId = targetBlock.parent_id
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
}
}
}
// Calculate column widths - use adaptive width based on content
const colWidths = column_width && column_width.length === columnCount
? column_width
: calculateColumnWidths(headers, rows)
// Build all cell contents as simple text array
const allRows = [headers, ...rows]
const cellTexts = allRows.flatMap((row) => row)
// Feishu API limit: max 9 rows per table creation
const MAX_ROWS_PER_BATCH = 9
const actualRowCount = Math.min(rowCount, MAX_ROWS_PER_BATCH)
// Step 1: Create table structure (limited to 9 rows initially)
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), // Only first batch
},
}
const createResult = await api.createBlocks(docId, parentBlockId, [tableBlock], insertIndex)
// Step 2: Get the created table's cell IDs and fill content
// The API returns the created table block with cell IDs
if (createResult && createResult.length > 0) {
const createdTable = createResult[0] as unknown as { table?: { cells?: string[] }; children?: string[] }
const cellIds = createdTable.table?.cells || createdTable.children || []
// Fill each cell with text block
// Add small delay between requests to avoid version conflicts
for (let i = 0; i < cellIds.length && i < cellTexts.length; i++) {
const cellId = cellIds[i]
const text = cellTexts[i]
if (cellId && text) {
// Wait 50ms between cell updates to avoid API conflicts
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) {
// Log but continue with other cells
console.error(`Failed to fill cell ${i}: ${cellError}`)
}
}
}
}
// Build response message
let message = `✅ 表格插入成功!\n\n📊 表格信息:\n- 行数:${actualRowCount}(含表头)\n- 列数:${columnCount}\n- 表头:${headers.join(' | ')}`
if (rowCount > MAX_ROWS_PER_BATCH) {
message += `\n\n⚠️ 注意:原表格有 ${rowCount} 行,飞书 API 限制单次最多创建 9 行,已插入前 ${actualRowCount} 行。`
}
return {
content: [
{
type: 'text' as const,
text: message,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: 'text' as const,
text: `插入表格失败: ${message}`,
},
],
isError: true,
}
}
}
)
}