Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/design/feishu-tools.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
summary: "飞书 MCP 工具集:doc/wiki/drive/bitable/chat/calendar/contact/task"
summary: "飞书 MCP 工具集:doc/wiki/drive/bitable/chat/calendar/contact/task + 主聊天/图片发送/消息下载"
related_paths:
- src/feishu/tools/**
last_updated: "2026-04-02"
last_updated: "2026-06-14"
---

# 飞书 MCP 工具集
Expand All @@ -25,10 +25,17 @@ last_updated: "2026-04-02"
| `feishu_calendar` | `calendar.ts` | 日历操作 |
| `feishu_contact` | `contact.ts` | 通讯录查询 |
| `feishu_task` | `task.ts` | 任务管理 |
| `feishu_send_to_chat` | `main-chat.ts` | 话题内将消息发送到群主聊天(仅 chatId 存在时注册) |
| `feishu_send_image` | `send-image.ts` | 把工作区本地图片发到当前会话/话题(仅 chatId 存在时注册) |
| `feishu_download_message_file` | `message.ts` | 按需下载历史消息中的文件(始终注册) |
| `feishu_download_message_image` | `image.ts` | 按需下载历史消息中的图片(始终注册) |

> 说明:上表前 8 个工具受 `config.feishu.tools.*` 子开关控制;后 4 个不走子开关——`feishu_send_to_chat`/`feishu_send_image` 在 `chatId` 存在时注册,两个下载工具始终注册。

### 关键设计决策

- **Action-based dispatch**:每个工具内含多个 action(read/write/create/...),用 Zod discriminatedUnion 定义
- **图片发送落点**:`feishu_send_image` 上传图片拿 `image_key` 后,依据闭包绑定的 `threadReplyMsgId` 决定回复到话题(`replyImageInThread`)或发送到会话(`sendImage`);`threadReplyMsgId` 由 executor 透传 `ExecuteInput.threadRootMessageId`。注意 `im.image.create` 直接返回已解包的 `{ image_key }`,与其它 message 接口的 `{ code, msg, data }` 结构不同
- **权限控制**:`permissions.ts` 按 read-only 模式过滤写操作
- **参数校验**:`validation.ts` 统一校验 token 格式
- **Markdown 转换**:`markdown-to-blocks.ts` 将 Markdown 转为飞书 Block 结构
2 changes: 1 addition & 1 deletion src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ export class ClaudeExecutor {
const keyParts = sessionKey.split(':');
const chatId = keyParts.length >= 4 ? keyParts[2] : keyParts[0] || undefined;
const userId = keyParts.length >= 4 ? keyParts[3] : keyParts[1] || undefined;
const feishuMcp = createFeishuToolsMcpServer(chatId, userId);
const feishuMcp = createFeishuToolsMcpServer(chatId, userId, input.threadRootMessageId);
if (feishuMcp) {
mcpServers['feishu-tools'] = feishuMcp;
}
Expand Down
78 changes: 78 additions & 0 deletions src/feishu/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as lark from '@larksuiteoapi/node-sdk';
import type { ReadStream } from 'node:fs';

import { logger } from '../utils/logger.js';
import { formatMergeForwardSubMessage } from './message-parser.js';
Expand Down Expand Up @@ -494,6 +495,83 @@ export class FeishuClient {
* 下载消息中的图片资源
* 使用 im.messageResource.get API(支持下载用户发送的图片)
*/
/**
* 上传图片到飞书,返回 image_key(用于发送图片消息)
*
* @param image 图片文件流(如 fs.createReadStream(path))或 Buffer。
* 注意:im.image.create 直接返回已解包的 { image_key },
* 而非其它 message 接口的 { code, msg, data } 结构。
*/
async uploadImage(image: Buffer | ReadStream): Promise<string | undefined> {
try {
const resp = await this.client.im.image.create({
data: { image_type: 'message', image },
});

if (!resp?.image_key) {
logger.error({ resp }, 'Failed to upload image (no image_key returned)');
return undefined;
}

return resp.image_key;
} catch (err) {
logger.error({ err }, 'Error uploading image');
return undefined;
}
}

/**
* 发送图片消息到指定群/会话
*/
async sendImage(chatId: string, imageKey: string): Promise<string | undefined> {
try {
const resp = await this.client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'image',
content: JSON.stringify({ image_key: imageKey }),
},
});

if (resp.code !== 0) {
logger.error({ code: resp.code, msg: resp.msg }, 'Failed to send image message');
return undefined;
}

return resp.data?.message_id;
} catch (err) {
logger.error({ err }, 'Error sending image message');
return undefined;
}
}

/**
* 在话题中回复图片(通过回复话题内的消息)
*/
async replyImageInThread(threadMessageId: string, imageKey: string): Promise<string | undefined> {
try {
const resp = await this.client.im.message.reply({
path: { message_id: threadMessageId },
data: {
msg_type: 'image',
content: JSON.stringify({ image_key: imageKey }),
reply_in_thread: true,
},
});

if (resp.code !== 0) {
logger.error({ code: resp.code, msg: resp.msg }, 'Failed to reply image in thread');
return undefined;
}

return resp.data?.message_id;
} catch (err) {
logger.error({ err }, 'Error replying image in thread');
return undefined;
}
}

async downloadMessageImage(messageId: string, imageKey: string): Promise<Buffer> {
try {
const resp = await this.client.im.messageResource.get({
Expand Down
16 changes: 12 additions & 4 deletions src/feishu/tools/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// vi.hoisted runs before vi.mock factories — safe to reference in factory
const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, mockChatTool, mockContactTool, mockTaskTool, mockCalendarTool, mockMainChatTool, mockMessageFileTool, mockMessageImageTool, mockCreateSdkMcpServer } = vi.hoisted(() => {
const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, mockChatTool, mockContactTool, mockTaskTool, mockCalendarTool, mockMainChatTool, mockSendImageTool, mockMessageFileTool, mockMessageImageTool, mockCreateSdkMcpServer } = vi.hoisted(() => {
const mockConfig = {
feishu: {
tools: {
Expand All @@ -29,6 +29,7 @@ const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, m
mockTaskTool: vi.fn(() => ({ name: 'feishu_task' })),
mockCalendarTool: vi.fn(() => ({ name: 'feishu_calendar' })),
mockMainChatTool: vi.fn(() => ({ name: 'feishu_send_to_chat' })),
mockSendImageTool: vi.fn(() => ({ name: 'feishu_send_image' })),
mockMessageFileTool: vi.fn(() => ({ name: 'feishu_download_message_file' })),
mockMessageImageTool: vi.fn(() => ({ name: 'feishu_download_message_image' })),
mockCreateSdkMcpServer: vi.fn((opts: unknown) => ({ ...(opts as object), type: 'mcp-server' })),
Expand All @@ -46,6 +47,7 @@ vi.mock('../contact.js', () => ({ feishuContactTool: () => mockContactTool() }))
vi.mock('../task.js', () => ({ feishuTaskTool: () => mockTaskTool() }));
vi.mock('../calendar.js', () => ({ feishuCalendarTool: () => mockCalendarTool() }));
vi.mock('../main-chat.js', () => ({ feishuMainChatTool: () => mockMainChatTool() }));
vi.mock('../send-image.js', () => ({ feishuSendImageTool: () => mockSendImageTool() }));
vi.mock('../message.js', () => ({ feishuMessageFileTool: () => mockMessageFileTool() }));
vi.mock('../image.js', () => ({ feishuMessageImageTool: () => mockMessageImageTool() }));
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
Expand Down Expand Up @@ -122,13 +124,19 @@ describe('createFeishuToolsMcpServer', () => {
expect(mockMainChatTool).not.toHaveBeenCalled();
});

it('should include main-chat tool when chatId is provided', () => {
it('should include main-chat and send-image tools when chatId is provided', () => {
const result = createFeishuToolsMcpServer('chat_123');
expect(result).toBeDefined();
const call = mockCreateSdkMcpServer.mock.calls[0][0];
// 8 config-gated + 1 main-chat + 1 message file + 1 message image = 11
expect(call.tools).toHaveLength(11);
// 8 config-gated + 1 main-chat + 1 send-image + 1 message file + 1 message image = 12
expect(call.tools).toHaveLength(12);
expect(mockMainChatTool).toHaveBeenCalledTimes(1);
expect(mockSendImageTool).toHaveBeenCalledTimes(1);
});

it('should not include send-image tool when chatId is undefined', () => {
createFeishuToolsMcpServer(undefined);
expect(mockSendImageTool).not.toHaveBeenCalled();
});

it('should not include task tool when task switch is false', () => {
Expand Down
172 changes: 172 additions & 0 deletions src/feishu/tools/__tests__/send-image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// @ts-nocheck — test file, vitest uses esbuild transform
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ============================================================
// Mocks
// ============================================================

vi.mock('../../../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

const mockUploadImage = vi.fn();
const mockSendImage = vi.fn();
const mockReplyImageInThread = vi.fn();

vi.mock('../../client.js', () => ({
feishuClient: {
uploadImage: (...args: unknown[]) => mockUploadImage(...args),
sendImage: (...args: unknown[]) => mockSendImage(...args),
replyImageInThread: (...args: unknown[]) => mockReplyImageInThread(...args),
},
}));

const mockExistsSync = vi.fn();
const mockStatSync = vi.fn();
const mockOpenSync = vi.fn(() => 3);
const mockReadSync = vi.fn();
const mockCloseSync = vi.fn();
const mockCreateReadStream = vi.fn(() => ({ __fakeStream: true }));

vi.mock('node:fs', () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
statSync: (...args: unknown[]) => mockStatSync(...args),
openSync: (...args: unknown[]) => mockOpenSync(...args),
readSync: (...args: unknown[]) => mockReadSync(...args),
closeSync: (...args: unknown[]) => mockCloseSync(...args),
createReadStream: (...args: unknown[]) => mockCreateReadStream(...args),
}));

// Mock the SDK tool() — capture the handler function for direct testing
let capturedHandler: (args: Record<string, unknown>) => Promise<unknown>;
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
tool: (_name: string, _desc: string, _schema: unknown, handler: unknown) => {
capturedHandler = handler as typeof capturedHandler;
return { name: _name, handler };
},
}));

import { feishuSendImageTool } from '../send-image.js';

// PNG magic bytes for the happy path
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);

function writeHeader(sig: Buffer) {
mockReadSync.mockImplementation((_fd: number, buf: Buffer) => {
sig.copy(buf);
return sig.length;
});
}

// ============================================================
// Tests
// ============================================================

describe('feishu_send_image tool', () => {
beforeEach(() => {
vi.clearAllMocks();
// default happy-path file system state: existing 2KB PNG
mockExistsSync.mockReturnValue(true);
mockStatSync.mockReturnValue({ isFile: () => true, size: 2048 });
writeHeader(PNG_SIG);
mockUploadImage.mockResolvedValue('img_key_1');
mockSendImage.mockResolvedValue('msg_1');
mockReplyImageInThread.mockResolvedValue('thread_msg_1');
});

it('should return error when chatId is undefined', async () => {
feishuSendImageTool();
const result = await capturedHandler({ file_path: '/abs/out.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('当前不在会话中');
expect(mockUploadImage).not.toHaveBeenCalled();
});

it('should upload and send image to chat when no thread', async () => {
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/out.png' }) as any;
expect(result.isError).toBeUndefined();
expect(mockUploadImage).toHaveBeenCalledWith({ __fakeStream: true });
expect(mockSendImage).toHaveBeenCalledWith('chat_123', 'img_key_1');
expect(mockReplyImageInThread).not.toHaveBeenCalled();
expect(result.content[0].text).toContain('会话');
});

it('should reply image in thread when threadReplyMsgId provided', async () => {
feishuSendImageTool('chat_123', 'thread_root_1');
const result = await capturedHandler({ file_path: '/abs/out.png' }) as any;
expect(result.isError).toBeUndefined();
expect(mockReplyImageInThread).toHaveBeenCalledWith('thread_root_1', 'img_key_1');
expect(mockSendImage).not.toHaveBeenCalled();
expect(result.content[0].text).toContain('话题');
});

it('should return error when file does not exist', async () => {
mockExistsSync.mockReturnValue(false);
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/missing.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('不存在');
expect(mockUploadImage).not.toHaveBeenCalled();
});

it('should return error when file is too large', async () => {
mockStatSync.mockReturnValue({ isFile: () => true, size: 11 * 1024 * 1024 });
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/huge.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('过大');
expect(mockUploadImage).not.toHaveBeenCalled();
});

it('should return error when file is empty', async () => {
mockStatSync.mockReturnValue({ isFile: () => true, size: 0 });
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/empty.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('为空');
expect(mockUploadImage).not.toHaveBeenCalled();
});

it('should return error for unsupported (non-image) format', async () => {
writeHeader(Buffer.from([0x00, 0x01, 0x02, 0x03])); // not an image
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/data.bin' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('无法识别');
expect(mockUploadImage).not.toHaveBeenCalled();
});

it('should return error when upload returns no image_key', async () => {
mockUploadImage.mockResolvedValue(undefined);
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/out.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('image_key');
expect(mockSendImage).not.toHaveBeenCalled();
});

it('should return error when send returns no message_id', async () => {
mockSendImage.mockResolvedValue(undefined);
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/out.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('发送失败');
});

it('should return error when upload throws', async () => {
mockUploadImage.mockRejectedValue(new Error('network down'));
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/out.png' }) as any;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('network down');
});

it('should accept jpg via magic bytes', async () => {
writeHeader(Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]));
feishuSendImageTool('chat_123');
const result = await capturedHandler({ file_path: '/abs/photo.jpg' }) as any;
expect(result.isError).toBeUndefined();
expect(mockSendImage).toHaveBeenCalledWith('chat_123', 'img_key_1');
});
});
11 changes: 8 additions & 3 deletions src/feishu/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { feishuTaskTool } from './task.js';
import { feishuContactTool } from './contact.js';
import { feishuCalendarTool } from './calendar.js';
import { feishuMainChatTool } from './main-chat.js';
import { feishuSendImageTool } from './send-image.js';
import { feishuMessageFileTool } from './message.js';
import { feishuMessageImageTool } from './image.js';
import { getValidUserToken } from '../oauth.js';
Expand All @@ -18,10 +19,11 @@ import { getValidUserToken } from '../oauth.js';
*
* 根据配置子开关组装工具列表,始终包含消息文件按需下载工具。
*
* @param chatId 当前会话的群 chat_id,用于创建文档后自动授权群成员
* @param userId 当前用户的 open_id,用于获取 user_access_token
* @param chatId 当前会话的群 chat_id,用于创建文档后自动授权群成员
* @param userId 当前用户的 open_id,用于获取 user_access_token
* @param threadReplyMsgId 话题根消息 ID,传入时 feishu_send_image 会把图片回复到话题内
*/
export function createFeishuToolsMcpServer(chatId?: string, userId?: string) {
export function createFeishuToolsMcpServer(chatId?: string, userId?: string, threadReplyMsgId?: string) {
const tools = [];
if (config.feishu.tools.doc) tools.push(feishuDocTool(chatId));
if (config.feishu.tools.wiki) tools.push(feishuWikiTool());
Expand All @@ -38,6 +40,9 @@ export function createFeishuToolsMcpServer(chatId?: string, userId?: string) {
// 主聊天发送工具:agent 在话题内时可自主决定将重要结果发到群主聊天
if (chatId) tools.push(feishuMainChatTool(chatId));

// 图片发送工具:agent 把工作区里的本地图片发回当前会话/话题
if (chatId) tools.push(feishuSendImageTool(chatId, threadReplyMsgId));

// 消息文件按需下载工具:配合 lazy loading,agent 可按需获取历史消息中的文件
tools.push(feishuMessageFileTool());
// 消息图片按需下载工具:父群历史图片 / 工作区切换后落盘图片皆通过此工具按需读取
Expand Down
Loading
Loading