feishu-user-plugin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,697 @@
1
+ #!/usr/bin/env node
2
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
3
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
4
+ const {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ } = require('@modelcontextprotocol/sdk/types.js');
8
+ const path = require('path');
9
+ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
10
+ const { LarkUserClient } = require('./client');
11
+ const { LarkOfficialClient } = require('./official');
12
+
13
+ // --- Chat ID Mapper ---
14
+
15
+ class ChatIdMapper {
16
+ constructor() {
17
+ this.nameCache = new Map(); // oc_id → chat name
18
+ this.lastRefresh = 0;
19
+ this.TTL = 5 * 60 * 1000; // 5 min cache
20
+ }
21
+
22
+ async _refresh(official) {
23
+ if (Date.now() - this.lastRefresh < this.TTL) return;
24
+ try {
25
+ const chats = await official.listAllChats();
26
+ this.nameCache.clear();
27
+ for (const chat of chats) {
28
+ this.nameCache.set(chat.chat_id, chat.name || '');
29
+ }
30
+ this.lastRefresh = Date.now();
31
+ } catch (e) {
32
+ console.error('[feishu-user-plugin] ChatIdMapper refresh failed:', e.message);
33
+ }
34
+ }
35
+
36
+ async findByName(name, official) {
37
+ await this._refresh(official);
38
+ // Exact match first
39
+ for (const [ocId, chatName] of this.nameCache) {
40
+ if (chatName === name) return ocId;
41
+ }
42
+ // Partial match
43
+ for (const [ocId, chatName] of this.nameCache) {
44
+ if (chatName && chatName.includes(name)) return ocId;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ async resolveToOcId(chatIdOrName, official) {
50
+ if (chatIdOrName.startsWith('oc_')) return chatIdOrName;
51
+ // Try as chat name
52
+ return this.findByName(chatIdOrName, official);
53
+ }
54
+ }
55
+
56
+ // --- Client Singletons ---
57
+
58
+ let userClient = null;
59
+ let officialClient = null;
60
+ const chatIdMapper = new ChatIdMapper();
61
+
62
+ async function getUserClient() {
63
+ if (userClient) return userClient;
64
+ const cookie = process.env.LARK_COOKIE;
65
+ if (!cookie) throw new Error(
66
+ 'LARK_COOKIE not set. To fix:\n' +
67
+ '1. Open https://www.feishu.cn/messenger/ and log in\n' +
68
+ '2. DevTools → Network tab → Disable cache → Reload → Click first request → Request Headers → Cookie → Copy value\n' +
69
+ ' (Do NOT use document.cookie or Application→Cookies — they miss HttpOnly cookies like session/sl_session)\n' +
70
+ '3. Paste the cookie string into your .mcp.json env LARK_COOKIE field, then restart Claude Code\n' +
71
+ 'If Playwright MCP is available: navigate to feishu.cn/messenger/, let user log in, then use context.cookies() to get the full cookie string including HttpOnly cookies.'
72
+ );
73
+ userClient = new LarkUserClient(cookie);
74
+ await userClient.init();
75
+ return userClient;
76
+ }
77
+
78
+ function getOfficialClient() {
79
+ if (officialClient) return officialClient;
80
+ const appId = process.env.LARK_APP_ID;
81
+ const appSecret = process.env.LARK_APP_SECRET;
82
+ if (!appId || !appSecret) throw new Error(
83
+ 'LARK_APP_ID and LARK_APP_SECRET not set.\n' +
84
+ 'For team members: these should be pre-filled in your .mcp.json. Check that the config was copied correctly from the team-skills README.\n' +
85
+ 'For external users: create a Custom App at https://open.feishu.cn/app, get the App ID and App Secret, add them to your .mcp.json env.'
86
+ );
87
+ officialClient = new LarkOfficialClient(appId, appSecret);
88
+ officialClient.loadUAT();
89
+ return officialClient;
90
+ }
91
+
92
+ // --- Tool Definitions ---
93
+
94
+ const TOOLS = [
95
+ // ========== User Identity — Send Messages ==========
96
+ {
97
+ name: 'send_as_user',
98
+ description: '[User Identity] Send a text message as the logged-in Feishu user. Supports reply threading.',
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ chat_id: { type: 'string', description: 'Target chat ID (numeric)' },
103
+ text: { type: 'string', description: 'Message text' },
104
+ root_id: { type: 'string', description: 'Thread root message ID (for reply, optional)' },
105
+ parent_id: { type: 'string', description: 'Parent message ID (for nested reply, optional)' },
106
+ },
107
+ required: ['chat_id', 'text'],
108
+ },
109
+ },
110
+ {
111
+ name: 'send_to_user',
112
+ description: '[User Identity] Search user by name → create P2P chat → send text message. All in one step.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ user_name: { type: 'string', description: 'Recipient name (Chinese or English)' },
117
+ text: { type: 'string', description: 'Message text' },
118
+ },
119
+ required: ['user_name', 'text'],
120
+ },
121
+ },
122
+ {
123
+ name: 'send_to_group',
124
+ description: '[User Identity] Search group by name → send text message. All in one step.',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ group_name: { type: 'string', description: 'Group chat name' },
129
+ text: { type: 'string', description: 'Message text' },
130
+ },
131
+ required: ['group_name', 'text'],
132
+ },
133
+ },
134
+ {
135
+ name: 'send_image_as_user',
136
+ description: '[User Identity] Send an image as the logged-in user. Requires image_key (upload via Official API first).',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ chat_id: { type: 'string', description: 'Target chat ID' },
141
+ image_key: { type: 'string', description: 'Image key from upload (img_v2_xxx or img_v3_xxx)' },
142
+ root_id: { type: 'string', description: 'Thread root message ID (optional)' },
143
+ },
144
+ required: ['chat_id', 'image_key'],
145
+ },
146
+ },
147
+ {
148
+ name: 'send_file_as_user',
149
+ description: '[User Identity] Send a file as the logged-in user. Requires file_key (upload via Official API first).',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: {
153
+ chat_id: { type: 'string', description: 'Target chat ID' },
154
+ file_key: { type: 'string', description: 'File key from upload' },
155
+ file_name: { type: 'string', description: 'Display file name' },
156
+ root_id: { type: 'string', description: 'Thread root message ID (optional)' },
157
+ },
158
+ required: ['chat_id', 'file_key', 'file_name'],
159
+ },
160
+ },
161
+ {
162
+ name: 'send_sticker_as_user',
163
+ description: '[User Identity] Send a sticker/emoji as the logged-in user.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ chat_id: { type: 'string', description: 'Target chat ID' },
168
+ sticker_id: { type: 'string', description: 'Sticker ID' },
169
+ sticker_set_id: { type: 'string', description: 'Sticker set ID' },
170
+ },
171
+ required: ['chat_id', 'sticker_id', 'sticker_set_id'],
172
+ },
173
+ },
174
+ {
175
+ name: 'send_post_as_user',
176
+ description: '[User Identity] Send a rich text (POST) message with title and formatted paragraphs.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ chat_id: { type: 'string', description: 'Target chat ID' },
181
+ title: { type: 'string', description: 'Post title (optional)' },
182
+ paragraphs: {
183
+ type: 'array',
184
+ description: 'Array of paragraphs. Each paragraph is an array of elements: {tag:"text",text:"..."} or {tag:"a",href:"...",text:"..."} or {tag:"at",userId:"..."}',
185
+ items: { type: 'array', items: { type: 'object' } },
186
+ },
187
+ root_id: { type: 'string', description: 'Thread root message ID (optional)' },
188
+ },
189
+ required: ['chat_id', 'paragraphs'],
190
+ },
191
+ },
192
+ {
193
+ name: 'send_audio_as_user',
194
+ description: '[User Identity] Send an audio message as the logged-in user. Requires audio_key.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ chat_id: { type: 'string', description: 'Target chat ID' },
199
+ audio_key: { type: 'string', description: 'Audio key from upload' },
200
+ },
201
+ required: ['chat_id', 'audio_key'],
202
+ },
203
+ },
204
+
205
+ // ========== User Identity — Contacts & Info ==========
206
+ {
207
+ name: 'search_contacts',
208
+ description: '[User Identity] Search Feishu users, bots, or group chats by name. Returns IDs.',
209
+ inputSchema: {
210
+ type: 'object',
211
+ properties: { query: { type: 'string', description: 'Search keyword' } },
212
+ required: ['query'],
213
+ },
214
+ },
215
+ {
216
+ name: 'create_p2p_chat',
217
+ description: '[User Identity] Create or get a P2P (direct message) chat. Returns numeric chat_id.',
218
+ inputSchema: {
219
+ type: 'object',
220
+ properties: { user_id: { type: 'string', description: 'Target user ID from search_contacts' } },
221
+ required: ['user_id'],
222
+ },
223
+ },
224
+ {
225
+ name: 'get_chat_info',
226
+ description: '[User Identity] Get chat details: name, description, member count, owner.',
227
+ inputSchema: {
228
+ type: 'object',
229
+ properties: { chat_id: { type: 'string', description: 'Chat ID' } },
230
+ required: ['chat_id'],
231
+ },
232
+ },
233
+ {
234
+ name: 'get_user_info',
235
+ description: '[User Identity] Look up a user\'s display name by user ID.',
236
+ inputSchema: {
237
+ type: 'object',
238
+ properties: {
239
+ user_id: { type: 'string', description: 'User ID' },
240
+ chat_id: { type: 'string', description: 'Chat context (optional)' },
241
+ },
242
+ required: ['user_id'],
243
+ },
244
+ },
245
+ {
246
+ name: 'get_login_status',
247
+ description: 'Check cookie session validity and app credentials status. Also refreshes session.',
248
+ inputSchema: { type: 'object', properties: {} },
249
+ },
250
+
251
+ // ========== IM — Official API (User Identity via UAT) ==========
252
+ {
253
+ name: 'read_p2p_messages',
254
+ description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Requires OAuth setup: run "node src/oauth.js" first.',
255
+ inputSchema: {
256
+ type: 'object',
257
+ properties: {
258
+ chat_id: { type: 'string', description: 'Chat ID (oc_xxx). Use list_user_chats to find P2P chat IDs.' },
259
+ page_size: { type: 'number', description: 'Messages to fetch (default 20, max 50)' },
260
+ start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
261
+ end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
262
+ },
263
+ required: ['chat_id'],
264
+ },
265
+ },
266
+ {
267
+ name: 'list_user_chats',
268
+ description: '[User UAT] List group chats the user is in. Note: only returns groups, not P2P. For P2P chats, use search_contacts → create_p2p_chat → read_p2p_messages. Requires OAuth setup.',
269
+ inputSchema: {
270
+ type: 'object',
271
+ properties: {
272
+ page_size: { type: 'number', description: 'Items per page (default 20)' },
273
+ page_token: { type: 'string', description: 'Pagination token' },
274
+ },
275
+ },
276
+ },
277
+
278
+ // ========== IM — Official API (Bot Identity) ==========
279
+ {
280
+ name: 'list_chats',
281
+ description: '[Official API] List all chats the bot has joined. Returns chat_id, name, type.',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ page_size: { type: 'number', description: 'Items per page (default 20, max 100)' },
286
+ page_token: { type: 'string', description: 'Pagination token' },
287
+ },
288
+ },
289
+ },
290
+ {
291
+ name: 'read_messages',
292
+ description: '[Official API] Read message history. Accepts oc_xxx ID or chat name (auto-resolved).',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ chat_id: { type: 'string', description: 'Chat ID (oc_xxx) or chat name (auto-searched)' },
297
+ page_size: { type: 'number', description: 'Messages to fetch (default 20, max 50)' },
298
+ start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
299
+ end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
300
+ },
301
+ required: ['chat_id'],
302
+ },
303
+ },
304
+ {
305
+ name: 'reply_message',
306
+ description: '[Official API] Reply to a specific message by message_id (as bot). Only works for text messages; other types return error 230054.',
307
+ inputSchema: {
308
+ type: 'object',
309
+ properties: {
310
+ message_id: { type: 'string', description: 'Message ID to reply to (om_xxx)' },
311
+ text: { type: 'string', description: 'Reply text' },
312
+ },
313
+ required: ['message_id', 'text'],
314
+ },
315
+ },
316
+ {
317
+ name: 'forward_message',
318
+ description: '[Official API] Forward a message to another chat.',
319
+ inputSchema: {
320
+ type: 'object',
321
+ properties: {
322
+ message_id: { type: 'string', description: 'Message ID to forward' },
323
+ receive_id: { type: 'string', description: 'Target chat_id or open_id' },
324
+ },
325
+ required: ['message_id', 'receive_id'],
326
+ },
327
+ },
328
+
329
+ // ========== Docs — Official API ==========
330
+ {
331
+ name: 'search_docs',
332
+ description: '[Official API] Search Feishu documents by keyword.',
333
+ inputSchema: {
334
+ type: 'object',
335
+ properties: { query: { type: 'string', description: 'Search keyword' } },
336
+ required: ['query'],
337
+ },
338
+ },
339
+ {
340
+ name: 'read_doc',
341
+ description: '[Official API] Read the raw text content of a Feishu document.',
342
+ inputSchema: {
343
+ type: 'object',
344
+ properties: { document_id: { type: 'string', description: 'Document ID or token' } },
345
+ required: ['document_id'],
346
+ },
347
+ },
348
+ {
349
+ name: 'create_doc',
350
+ description: '[Official API] Create a new Feishu document.',
351
+ inputSchema: {
352
+ type: 'object',
353
+ properties: {
354
+ title: { type: 'string', description: 'Document title' },
355
+ folder_id: { type: 'string', description: 'Parent folder token (optional)' },
356
+ },
357
+ required: ['title'],
358
+ },
359
+ },
360
+
361
+ // ========== Bitable — Official API ==========
362
+ {
363
+ name: 'list_bitable_tables',
364
+ description: '[Official API] List all tables in a Bitable app.',
365
+ inputSchema: {
366
+ type: 'object',
367
+ properties: { app_token: { type: 'string', description: 'Bitable app token' } },
368
+ required: ['app_token'],
369
+ },
370
+ },
371
+ {
372
+ name: 'list_bitable_fields',
373
+ description: '[Official API] List all fields (columns) in a Bitable table.',
374
+ inputSchema: {
375
+ type: 'object',
376
+ properties: {
377
+ app_token: { type: 'string', description: 'Bitable app token' },
378
+ table_id: { type: 'string', description: 'Table ID' },
379
+ },
380
+ required: ['app_token', 'table_id'],
381
+ },
382
+ },
383
+ {
384
+ name: 'search_bitable_records',
385
+ description: '[Official API] Search/query records in a Bitable table.',
386
+ inputSchema: {
387
+ type: 'object',
388
+ properties: {
389
+ app_token: { type: 'string', description: 'Bitable app token' },
390
+ table_id: { type: 'string', description: 'Table ID' },
391
+ filter: { type: 'object', description: 'Filter conditions (optional)' },
392
+ sort: { type: 'array', description: 'Sort conditions (optional)' },
393
+ page_size: { type: 'number', description: 'Results per page (default 20)' },
394
+ },
395
+ required: ['app_token', 'table_id'],
396
+ },
397
+ },
398
+ {
399
+ name: 'create_bitable_record',
400
+ description: '[Official API] Create a new record (row) in a Bitable table.',
401
+ inputSchema: {
402
+ type: 'object',
403
+ properties: {
404
+ app_token: { type: 'string', description: 'Bitable app token' },
405
+ table_id: { type: 'string', description: 'Table ID' },
406
+ fields: { type: 'object', description: 'Field name → value mapping' },
407
+ },
408
+ required: ['app_token', 'table_id', 'fields'],
409
+ },
410
+ },
411
+ {
412
+ name: 'update_bitable_record',
413
+ description: '[Official API] Update an existing record in a Bitable table.',
414
+ inputSchema: {
415
+ type: 'object',
416
+ properties: {
417
+ app_token: { type: 'string', description: 'Bitable app token' },
418
+ table_id: { type: 'string', description: 'Table ID' },
419
+ record_id: { type: 'string', description: 'Record ID to update' },
420
+ fields: { type: 'object', description: 'Field name → new value mapping' },
421
+ },
422
+ required: ['app_token', 'table_id', 'record_id', 'fields'],
423
+ },
424
+ },
425
+
426
+ // ========== Wiki — Official API ==========
427
+ {
428
+ name: 'list_wiki_spaces',
429
+ description: '[Official API] List all accessible Wiki spaces.',
430
+ inputSchema: { type: 'object', properties: {} },
431
+ },
432
+ {
433
+ name: 'search_wiki',
434
+ description: '[Official API] Search Wiki nodes by keyword.',
435
+ inputSchema: {
436
+ type: 'object',
437
+ properties: { query: { type: 'string', description: 'Search keyword' } },
438
+ required: ['query'],
439
+ },
440
+ },
441
+ {
442
+ name: 'list_wiki_nodes',
443
+ description: '[Official API] List nodes in a Wiki space.',
444
+ inputSchema: {
445
+ type: 'object',
446
+ properties: {
447
+ space_id: { type: 'string', description: 'Wiki space ID' },
448
+ parent_node_token: { type: 'string', description: 'Parent node token (optional)' },
449
+ },
450
+ required: ['space_id'],
451
+ },
452
+ },
453
+
454
+ // ========== Drive — Official API ==========
455
+ {
456
+ name: 'list_files',
457
+ description: '[Official API] List files in a Drive folder.',
458
+ inputSchema: {
459
+ type: 'object',
460
+ properties: { folder_token: { type: 'string', description: 'Folder token (empty for root)' } },
461
+ },
462
+ },
463
+ {
464
+ name: 'create_folder',
465
+ description: '[Official API] Create a new folder in Drive.',
466
+ inputSchema: {
467
+ type: 'object',
468
+ properties: {
469
+ name: { type: 'string', description: 'Folder name' },
470
+ parent_token: { type: 'string', description: 'Parent folder token (optional)' },
471
+ },
472
+ required: ['name'],
473
+ },
474
+ },
475
+
476
+ // ========== Contact — Official API ==========
477
+ {
478
+ name: 'find_user',
479
+ description: '[Official API] Find a Feishu user by email or mobile number.',
480
+ inputSchema: {
481
+ type: 'object',
482
+ properties: {
483
+ email: { type: 'string', description: 'User email (optional)' },
484
+ mobile: { type: 'string', description: 'User mobile with country code like +86xxx (optional)' },
485
+ },
486
+ },
487
+ },
488
+ ];
489
+
490
+ // --- Server ---
491
+
492
+ const server = new Server(
493
+ { name: 'feishu-user-plugin', version: '1.0.0' },
494
+ { capabilities: { tools: {} } }
495
+ );
496
+
497
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
498
+
499
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
500
+ const { name, arguments: args } = request.params;
501
+ try {
502
+ return await handleTool(name, args || {});
503
+ } catch (err) {
504
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
505
+ }
506
+ });
507
+
508
+ const text = (s) => ({ content: [{ type: 'text', text: s }] });
509
+ const json = (o) => text(JSON.stringify(o, null, 2));
510
+ const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
511
+
512
+ async function handleTool(name, args) {
513
+
514
+ switch (name) {
515
+ // --- User Identity: Text Messaging ---
516
+
517
+ case 'send_as_user': {
518
+ const c = await getUserClient();
519
+ const r = await c.sendMessage(args.chat_id, args.text, { rootId: args.root_id, parentId: args.parent_id });
520
+ return sendResult(r, `Text sent as user to ${args.chat_id}`);
521
+ }
522
+ case 'send_to_user': {
523
+ const c = await getUserClient();
524
+ const results = await c.search(args.user_name);
525
+ const user = results.find(r => r.type === 'user');
526
+ if (!user) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
527
+ const chatId = await c.createChat(user.id);
528
+ if (!chatId) return text(`Failed to create chat with ${user.title}`);
529
+ const r = await c.sendMessage(chatId, args.text);
530
+ return sendResult(r, `Text sent to ${user.title} (chat: ${chatId})`);
531
+ }
532
+ case 'send_to_group': {
533
+ const c = await getUserClient();
534
+ const results = await c.search(args.group_name);
535
+ const group = results.find(r => r.type === 'group');
536
+ if (!group) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
537
+ const r = await c.sendMessage(group.id, args.text);
538
+ return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
539
+ }
540
+
541
+ // --- User Identity: Rich Message Types ---
542
+
543
+ case 'send_image_as_user': {
544
+ const c = await getUserClient();
545
+ const r = await c.sendImage(args.chat_id, args.image_key, { rootId: args.root_id });
546
+ return sendResult(r, `Image sent to ${args.chat_id}`);
547
+ }
548
+ case 'send_file_as_user': {
549
+ const c = await getUserClient();
550
+ const r = await c.sendFile(args.chat_id, args.file_key, args.file_name, { rootId: args.root_id });
551
+ return sendResult(r, `File "${args.file_name}" sent to ${args.chat_id}`);
552
+ }
553
+ case 'send_sticker_as_user': {
554
+ const c = await getUserClient();
555
+ const r = await c.sendSticker(args.chat_id, args.sticker_id, args.sticker_set_id);
556
+ return sendResult(r, `Sticker sent to ${args.chat_id}`);
557
+ }
558
+ case 'send_post_as_user': {
559
+ const c = await getUserClient();
560
+ const r = await c.sendPost(args.chat_id, args.title || '', args.paragraphs, { rootId: args.root_id });
561
+ return sendResult(r, `Post sent to ${args.chat_id}`);
562
+ }
563
+ case 'send_audio_as_user': {
564
+ const c = await getUserClient();
565
+ const r = await c.sendAudio(args.chat_id, args.audio_key);
566
+ return sendResult(r, `Audio sent to ${args.chat_id}`);
567
+ }
568
+
569
+ // --- User Identity: Contacts & Info ---
570
+
571
+ case 'search_contacts': {
572
+ const c = await getUserClient();
573
+ return json(await c.search(args.query));
574
+ }
575
+ case 'create_p2p_chat': {
576
+ const c = await getUserClient();
577
+ const chatId = await c.createChat(args.user_id);
578
+ return text(chatId ? `P2P chat: ${chatId}` : 'Failed to create P2P chat');
579
+ }
580
+ case 'get_chat_info': {
581
+ const c = await getUserClient();
582
+ const info = await c.getGroupInfo(args.chat_id);
583
+ return info ? json(info) : text(`No info for chat ${args.chat_id}`);
584
+ }
585
+ case 'get_user_info': {
586
+ const c = await getUserClient();
587
+ // Try name cache first; if miss, do a search to populate cache
588
+ let n = await c.getUserName(args.user_id);
589
+ if (!n && args.user_id) {
590
+ // Try searching to populate the cache
591
+ await c.search(args.user_id);
592
+ n = await c.getUserName(args.user_id);
593
+ }
594
+ return text(n ? `User ${args.user_id}: ${n}` : `Could not resolve user ${args.user_id}. Try search_contacts with the user's name instead.`);
595
+ }
596
+ case 'get_login_status': {
597
+ const parts = [];
598
+ try {
599
+ const c = await getUserClient();
600
+ const status = await c.checkSession();
601
+ parts.push(`Cookie: ${status.valid ? 'Active' : 'Expired'} (${status.userName || status.userId || 'unknown'})`);
602
+ parts.push(` ${status.message}`);
603
+ } catch (e) { parts.push(`Cookie: ${e.message}`); }
604
+ const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
605
+ parts.push(`App credentials: ${hasApp ? 'Configured' : 'Not set'}`);
606
+ const official = hasApp ? getOfficialClient() : null;
607
+ parts.push(`User access token: ${official?.hasUAT ? 'Configured (P2P reading enabled)' : 'Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)'}`);
608
+ return text(parts.join('\n'));
609
+ }
610
+
611
+ // --- User UAT: IM ---
612
+
613
+ case 'read_p2p_messages': {
614
+ const official = getOfficialClient();
615
+ return json(await official.readMessagesAsUser(args.chat_id, {
616
+ pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
617
+ }));
618
+ }
619
+ case 'list_user_chats':
620
+ return json(await getOfficialClient().listChatsAsUser({ pageSize: args.page_size, pageToken: args.page_token }));
621
+
622
+ // --- Official API: IM ---
623
+
624
+ case 'list_chats':
625
+ return json(await getOfficialClient().listChats({ pageSize: args.page_size, pageToken: args.page_token }));
626
+ case 'read_messages': {
627
+ const official = getOfficialClient();
628
+ const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
629
+ if (!resolvedChatId) {
630
+ return text(`Cannot resolve "${args.chat_id}" to oc_ ID. Use list_chats to find the correct ID, or provide chat name.`);
631
+ }
632
+ return json(await official.readMessages(resolvedChatId, {
633
+ pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
634
+ }));
635
+ }
636
+ case 'reply_message':
637
+ return text(`Reply sent: ${(await getOfficialClient().replyMessage(args.message_id, args.text)).messageId}`);
638
+ case 'forward_message':
639
+ return text(`Forwarded: ${(await getOfficialClient().forwardMessage(args.message_id, args.receive_id)).messageId}`);
640
+
641
+ // --- Official API: Docs ---
642
+
643
+ case 'search_docs':
644
+ return json(await getOfficialClient().searchDocs(args.query));
645
+ case 'read_doc':
646
+ return json(await getOfficialClient().readDoc(args.document_id));
647
+ case 'create_doc':
648
+ return text(`Document created: ${(await getOfficialClient().createDoc(args.title, args.folder_id)).documentId}`);
649
+
650
+ // --- Official API: Bitable ---
651
+
652
+ case 'list_bitable_tables':
653
+ return json(await getOfficialClient().listBitableTables(args.app_token));
654
+ case 'list_bitable_fields':
655
+ return json(await getOfficialClient().listBitableFields(args.app_token, args.table_id));
656
+ case 'search_bitable_records':
657
+ return json(await getOfficialClient().searchBitableRecords(args.app_token, args.table_id, {
658
+ filter: args.filter, sort: args.sort, pageSize: args.page_size,
659
+ }));
660
+ case 'create_bitable_record':
661
+ return text(`Record created: ${(await getOfficialClient().createBitableRecord(args.app_token, args.table_id, args.fields)).recordId}`);
662
+ case 'update_bitable_record':
663
+ return text(`Record updated: ${(await getOfficialClient().updateBitableRecord(args.app_token, args.table_id, args.record_id, args.fields)).recordId}`);
664
+
665
+ // --- Official API: Wiki ---
666
+
667
+ case 'list_wiki_spaces':
668
+ return json(await getOfficialClient().listWikiSpaces());
669
+ case 'search_wiki':
670
+ return json(await getOfficialClient().searchWiki(args.query));
671
+ case 'list_wiki_nodes':
672
+ return json(await getOfficialClient().listWikiNodes(args.space_id, { parentNodeToken: args.parent_node_token }));
673
+
674
+ // --- Official API: Drive ---
675
+
676
+ case 'list_files':
677
+ return json(await getOfficialClient().listFiles(args.folder_token));
678
+ case 'create_folder':
679
+ return text(`Folder created: ${(await getOfficialClient().createFolder(args.name, args.parent_token)).token}`);
680
+
681
+ // --- Official API: Contact ---
682
+
683
+ case 'find_user':
684
+ return json(await getOfficialClient().findUserByIdentity({ emails: args.email, mobiles: args.mobile }));
685
+
686
+ default:
687
+ return text(`Unknown tool: ${name}`);
688
+ }
689
+ }
690
+
691
+ async function main() {
692
+ const transport = new StdioServerTransport();
693
+ await server.connect(transport);
694
+ console.error('[feishu-user-plugin] MCP Server v1.0.0 — %d tools available', TOOLS.length);
695
+ }
696
+
697
+ main().catch(console.error);