/**
* Spreadsheet tools for Feishu MCP Server
* 电子表格操作工具
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getApiClient } from '../services/api.js'
// Schemas
const CreateSpreadsheetSchema = z.object({
title: z.string().min(1).max(255).describe('电子表格标题'),
folder_token: z.string().optional().describe('目标文件夹 Token,不指定则创建在根目录'),
})
const WriteSpreadsheetSchema = z.object({
spreadsheet_token: z.string().min(1).describe('电子表格 Token 或完整 URL'),
sheet_id: z.string().optional().describe('工作表 ID,不指定则使用第一个工作表'),
range: z.string().optional().describe('写入范围,如 A1:C10,不指定则从 A1 开始'),
data: z.array(z.array(z.string())).describe('二维数组数据,如 [["姓名","分数"],["张三","95"]]'),
})
const ReadSpreadsheetSchema = z.object({
spreadsheet_token: z.string().min(1).describe('电子表格 Token 或完整 URL'),
sheet_id: z.string().optional().describe('工作表 ID,不指定则使用第一个工作表'),
range: z.string().optional().describe('读取范围,如 A1:C10,不指定则读取全部'),
})
type CreateSpreadsheetArgs = z.infer<typeof CreateSpreadsheetSchema>
type WriteSpreadsheetArgs = z.infer<typeof WriteSpreadsheetSchema>
type ReadSpreadsheetArgs = z.infer<typeof ReadSpreadsheetSchema>
/**
* Extract spreadsheet token from URL or return as-is
*/
function extractSpreadsheetToken(input: string): string {
const urlMatch = input.match(/\/sheets\/([a-zA-Z0-9]+)/)
if (urlMatch) return urlMatch[1]
return input
}
/**
* Register spreadsheet tools
*/
export function registerSpreadsheetTools(server: McpServer): void {
const api = getApiClient()
// feishu_create_spreadsheet
server.tool(
'feishu_create_spreadsheet',
'创建新的飞书电子表格。返回电子表格的 Token 和 URL。',
CreateSpreadsheetSchema.shape,
async (args: CreateSpreadsheetArgs) => {
try {
const { title, folder_token } = args
const body: Record<string, string> = { title }
if (folder_token) body.folder_token = folder_token
const result = await api.request<{
spreadsheet: { spreadsheet_token: string; title: string; url: string }
}>('POST', '/sheets/v3/spreadsheets', body)
const spreadsheet = result.spreadsheet
return {
content: [{
type: 'text' as const,
text: `电子表格创建成功!\n\n📊 标题: ${spreadsheet.title}\n🔗 链接: ${spreadsheet.url}\n📋 Token: ${spreadsheet.spreadsheet_token}`,
}],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text' as const, text: `创建电子表格失败: ${message}` }], isError: true }
}
}
)
// feishu_write_spreadsheet
server.tool(
'feishu_write_spreadsheet',
'向飞书电子表格写入数据。支持指定工作表和范围。',
WriteSpreadsheetSchema.shape,
async (args: WriteSpreadsheetArgs) => {
try {
const { spreadsheet_token, sheet_id, range, data } = args
const token = extractSpreadsheetToken(spreadsheet_token)
let targetSheetId = sheet_id
if (!targetSheetId) {
const sheetsResult = await api.request<{
sheets: Array<{ sheet_id: string; title: string }>
}>('GET', `/sheets/v3/spreadsheets/${token}/sheets/query`)
targetSheetId = sheetsResult.sheets[0].sheet_id
}
const rows = data.length
const cols = data[0]?.length || 1
const endCol = String.fromCharCode(64 + cols)
const targetRange = range || `A1:${endCol}${rows}`
const result = await api.request<{
updatedCells: number; updatedRows: number; updatedColumns: number; updatedRange: string
}>('PUT', `/sheets/v2/spreadsheets/${token}/values`, {
valueRange: { range: `${targetSheetId}!${targetRange}`, values: data }
})
return {
content: [{
type: 'text' as const,
text: `数据写入成功!\n\n📊 更新范围: ${result.updatedRange}\n📝 更新行数: ${result.updatedRows}\n📝 更新列数: ${result.updatedColumns}\n📝 更新单元格: ${result.updatedCells}`,
}],
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text' as const, text: `写入数据失败: ${message}` }], isError: true }
}
}
)
// feishu_read_spreadsheet
server.tool(
'feishu_read_spreadsheet',
'从飞书电子表格读取数据。返回指定范围的数据。',
ReadSpreadsheetSchema.shape,
async (args: ReadSpreadsheetArgs) => {
try {
const { spreadsheet_token, sheet_id, range } = args
const token = extractSpreadsheetToken(spreadsheet_token)
let targetSheetId = sheet_id
if (!targetSheetId) {
const sheetsResult = await api.request<{
sheets: Array<{ sheet_id: string; title: string }>
}>('GET', `/sheets/v3/spreadsheets/${token}/sheets/query`)
targetSheetId = sheetsResult.sheets[0].sheet_id
}
const targetRange = range || 'A1:Z100'
const result = await api.request<{
valueRange: { range: string; values: string[][] }
}>('GET', `/sheets/v2/spreadsheets/${token}/values/${targetSheetId}!${targetRange}`)
const values = result.valueRange?.values || []
let markdown = ''
if (values.length > 0) {
markdown += '| ' + values[0].join(' | ') + ' |\n'
markdown += '| ' + values[0].map(() => '---').join(' | ') + ' |\n'
for (let i = 1; i < values.length; i++) {
if (values[i] && values[i].some((v) => v)) {
markdown += '| ' + values[i].join(' | ') + ' |\n'
}
}
}
return { content: [{ type: 'text' as const, text: markdown || '(空表格)' }] }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text' as const, text: `读取数据失败: ${message}` }], isError: true }
}
}
)
}