feishu-user-plugin 1.3.2 → 1.3.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.2",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats, manage docs/tables/wiki. 66 tools + 9 skills, 3 auth layers.",
3
+ "version": "1.3.3",
4
+ "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats, manage docs/tables/wiki. 67 tools + 9 skills, 3 auth layers.",
5
5
  "author": {
6
6
  "name": "EthanQC"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.3.3] - 2026-04-20
8
+
9
+ ### Fixed
10
+ - **MCP mid-session disconnect (root fix)**: All raw `fetch` calls to Feishu now go through `fetchWithTimeout` (AbortController, 30s default). A stalled connection used to hang a tool handler indefinitely; the MCP client would time out and some clients tore down the stdio transport — observed as "MCP 中途掉线" on v1.3.2. This was the real cause, not just the v1.3.1 stdout pollution.
11
+ - **stdout pollution (defense-in-depth)**: `src/index.js` now globally redirects `console.log` / `console.info` to stderr at startup, before any other `require`. Any current or future dependency that accidentally writes to stdout can no longer corrupt the JSON-RPC channel. (v1.3.1's Lark-SDK-specific logger override stays as-is.)
12
+ - **`(as user)` label lied for docs/bitable/folder creation**: `create_doc` / `create_bitable` / `create_folder` previously labeled every successful call `(as user)` whenever `LARK_USER_ACCESS_TOKEN` was set, even when the UAT call actually failed and silently fell back to app identity. `_asUserOrApp` now threads a real `_viaUser` flag through; failures show `(as app — UAT unavailable or failed; <resource> owned by the app, not you)`.
13
+
14
+ ### Added
15
+ - **APP_ID startup validation**: MCP server probes `/auth/v3/app_access_token/internal` at boot. Invalid `LARK_APP_ID` / `LARK_APP_SECRET` (wrong-tenant, stale, or hallucinated by an autoinstall) now produce a clear stderr error pointing at the team-skills install prompt. Non-blocking — users running cookie-only workflows are unaffected.
16
+ - **`get_login_status` shows app identity**: Now returns the actual `app_id` plus fetched app name, so users can immediately spot "this isn't my team's app" scenarios.
17
+ - **`download_image` tool**: Download an image embedded in a message by `message_id` + `image_key`, returned as MCP image content so the model can see the pixels (not just the key string). Tries UAT first (works for any chat the user is in); falls back to app token (requires the bot to be in the chat).
18
+
19
+ ### Changed
20
+ - Tool count 66 → **67** (added `download_image`).
21
+ - README tool badge corrected from 76 → 67 (previous 76 was stale and never matched the actual export).
22
+
7
23
  ## [1.1.3] - 2026-03-11
8
24
 
9
25
  ### Fixed
package/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io)
6
- [![Tools](https://img.shields.io/badge/Tools-76-orange.svg)](#tools)
6
+ [![Tools](https://img.shields.io/badge/Tools-67-orange.svg)](#tools)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
8
8
 
9
- **All-in-one Feishu/Lark MCP Server -- 76 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, and more.**
9
+ **All-in-one Feishu/Lark MCP Server -- 67 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, and more.**
10
10
 
11
11
  The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API. Works with Claude Code, Cursor, Windsurf, OpenClaw, and any MCP-compatible client.
12
12
 
@@ -337,7 +337,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
337
337
  }
338
338
  ```
339
339
 
340
- ## Tools (76 total)
340
+ ## Tools (67 total)
341
341
 
342
342
  ### User Identity -- Messaging (8 tools, cookie auth)
343
343
 
@@ -390,6 +390,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
390
390
  | `add_members` | Add users to a group |
391
391
  | `remove_members` | Remove users from a group |
392
392
  | `upload_image` / `upload_file` | Upload image/file, returns key for sending |
393
+ | `download_image` | Download an image from a message by message_id + image_key, returned as MCP image content so the model can see the pixels |
393
394
 
394
395
  ### Official API -- Documents (7 tools)
395
396
 
@@ -508,7 +509,7 @@ feishu-user-plugin/
508
509
  │ ├── SKILL.md # Main skill definition (trigger, tools, auth)
509
510
  │ └── references/ # 8 skill reference docs + CLAUDE.md
510
511
  ├── src/
511
- │ ├── index.js # MCP server entry point (76 tools)
512
+ │ ├── index.js # MCP server entry point (67 tools)
512
513
  │ ├── client.js # User identity client (Protobuf gateway)
513
514
  │ ├── official.js # Official API client (REST, UAT)
514
515
  │ ├── utils.js # ID generators, cookie parser
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.2",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs, bitable, wiki, drive. 66 tools + 9 skills, 3 auth layers.",
3
+ "version": "1.3.3",
4
+ "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs, bitable, wiki, drive. 67 tools + 9 skills, 3 auth layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "feishu-user-plugin": "src/cli.js"
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.1.3"
4
- description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki. Replaces and extends the official Feishu MCP."
5
- allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, list_files, create_folder, find_user
3
+ version: "1.3.3"
4
+ description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki, download images. Replaces and extends the official Feishu MCP."
5
+ allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, list_files, create_folder, find_user, download_image
6
6
  user_invocable: true
7
7
  ---
8
8
 
@@ -6,7 +6,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
6
6
  - **Official API** (app credentials): Read group messages, docs, tables, wiki, drive, contacts, upload files
7
7
  - **User OAuth UAT** (user_access_token): Read P2P chat history, list all user's chats
8
8
 
9
- ## Tool Categories (66 tools)
9
+ ## Tool Categories (67 tools)
10
10
 
11
11
  ### User Identity — Messaging (reverse-engineered, cookie-based)
12
12
  - `send_to_user` — Search user + send text (one step, most common). Returns candidates if multiple matches.
@@ -52,6 +52,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
52
52
  - `list_files` / `create_folder` — Drive
53
53
  - `copy_file` / `move_file` / `delete_file` — Drive file operations (copy, move, delete)
54
54
  - `upload_image` / `upload_file` — Upload image/file, returns key for send_image/send_file
55
+ - `download_image` — Download an image from a message (needs message_id + image_key from read_messages) and return it as MCP image content so the model can **see the pixels**, not just the key. Tries UAT first, falls back to app token (app path requires the bot to be in the chat).
55
56
  - `find_user` — Contact lookup by email/mobile
56
57
 
57
58
  ## Usage Patterns
@@ -233,9 +234,21 @@ Tell user to restart Claude Code. Only ONE restart should be needed.
233
234
  ## Troubleshooting Guide
234
235
 
235
236
  ### If MCP disconnects mid-session
236
- - **Root cause** (fixed in v1.3.1): `@larksuiteoapi/node-sdk`'s `defaultLogger.error` uses `console.log` (stdout). MCP protocol uses stdout for JSON-RPC, so SDK error logs corrupt the transport and cause immediate disconnect.
237
- - **Fix**: Custom logger redirects all SDK output to stderr. Already applied in `src/official.js`.
238
- - If still happening: check for any `console.log` calls in server code (only `console.error` is safe in MCP servers).
237
+ Two known root causes, both fixed in v1.3.3:
238
+
239
+ 1. **stdout pollution** (partial fix in v1.3.1, fully closed in v1.3.3):
240
+ - `@larksuiteoapi/node-sdk`'s `defaultLogger.error` uses `console.log` (stdout). MCP uses stdout for JSON-RPC, so any stray write corrupts the transport and disconnects the client.
241
+ - v1.3.1 replaced the SDK's logger. v1.3.3 also globally redirects `console.log` / `console.info` → `console.error` at the top of `src/index.js` as defense-in-depth against ANY future dependency leaking to stdout.
242
+
243
+ 2. **unbounded fetch hangs** (fixed in v1.3.3):
244
+ - All raw `fetch` calls to `feishu.cn` / `internal-api-lark-api.feishu.cn` used to have no timeout. A stalled connection (ECONNRESET, slow DNS, upstream hang) would block a tool handler indefinitely; the MCP client times out the request, which some clients handle by tearing down the stdio transport — observed as "mid-session disconnect".
245
+ - Fix: `utils.js::fetchWithTimeout` with `AbortController`, 30s default. All `client.js` + `official.js` fetches go through it.
246
+ - If still happening: check for any `console.log` calls in server code (only `console.error` is safe), and grep for raw `await fetch(` — every one must go through `fetchWithTimeout`.
247
+
248
+ ### If Official API tools return 401 / "token invalid" every time
249
+ - **Likely cause**: `LARK_APP_ID` is wrong or stale. Observed in production: Claude Code auto-installed the plugin and guessed/copied a wrong APP_ID that doesn't match the team's real app (e.g. from an unrelated app, from someone else's machine, or hallucinated).
250
+ - **Diagnosis**: `get_login_status` now reports `App credentials: INVALID — app_id=<x> rejected by Feishu (<code>: <msg>)`. MCP startup logs `[feishu-user-plugin] ERROR: LARK_APP_ID=<x> was REJECTED by Feishu` on stderr when this happens.
251
+ - **Fix**: Re-run the canonical install prompt from `team-skills/plugins/feishu-user-plugin/README.md` which contains the correct APP_ID/SECRET, and restart Claude Code.
239
252
 
240
253
  ### If MCP tools are not available
241
254
  1. Check `~/.claude.json` — config must be in **top-level** `mcpServers`, not inside `projects[*]`
package/src/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const path = require('path');
2
2
  const protobuf = require('protobufjs');
3
- const { generateRequestId, generateCid, parseCookie, formatCookie } = require('./utils');
3
+ const { generateRequestId, generateCid, parseCookie, formatCookie, fetchWithTimeout } = require('./utils');
4
4
 
5
5
  const GATEWAY_URL = 'https://internal-api-lark-api.feishu.cn/im/gateway/';
6
6
  const CSRF_URL = 'https://internal-api-lark-api.feishu.cn/accounts/csrf';
@@ -47,7 +47,7 @@ class LarkUserClient {
47
47
  // --- Auth ---
48
48
 
49
49
  async _getCsrfToken() {
50
- const res = await fetch(`${CSRF_URL}?_t=${Date.now()}`, {
50
+ const res = await fetchWithTimeout(`${CSRF_URL}?_t=${Date.now()}`, {
51
51
  method: 'POST',
52
52
  headers: {
53
53
  ...this._jsonHeaders(),
@@ -68,7 +68,7 @@ class LarkUserClient {
68
68
  }
69
69
 
70
70
  async _getUserInfo() {
71
- const res = await fetch(`${USER_INFO_URL}?app_id=12&_t=${Date.now()}`, {
71
+ const res = await fetchWithTimeout(`${USER_INFO_URL}?app_id=12&_t=${Date.now()}`, {
72
72
  headers: {
73
73
  ...this._jsonHeaders(),
74
74
  'x-csrf-token': this.csrfToken || '',
@@ -179,7 +179,7 @@ class LarkUserClient {
179
179
  cid: generateRequestId(),
180
180
  payload: reqBuf,
181
181
  });
182
- const res = await fetch(GATEWAY_URL, {
182
+ const res = await fetchWithTimeout(GATEWAY_URL, {
183
183
  method: 'POST',
184
184
  headers: this._protoHeaders(cmd, cmdVersion),
185
185
  body: packetBuf,
package/src/index.js CHANGED
@@ -1,4 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ // MCP stdio protocol uses stdout for JSON-RPC. ANY accidental stdout write from
3
+ // this process or its dependencies will corrupt the transport and disconnect the
4
+ // client. v1.3.1 patched the Lark SDK's defaultLogger via a custom logger, but
5
+ // that only covers one dependency. This global redirect is defense-in-depth:
6
+ // any present or future module that calls console.log (or console.info) goes to
7
+ // stderr instead, so MCP stdio stays clean no matter what.
8
+ // Do this BEFORE any other require() so even early log calls are captured.
9
+ console.log = (...args) => console.error(...args);
10
+ console.info = (...args) => console.error(...args);
11
+
2
12
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
3
13
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
4
14
  const {
@@ -992,6 +1002,20 @@ const TOOLS = [
992
1002
  },
993
1003
  },
994
1004
 
1005
+ // ========== Message Resources (Image/File Download) ==========
1006
+ {
1007
+ name: 'download_image',
1008
+ description: '[User Identity / Official API] Download an image embedded in a message so the model can actually see it. Pass the message_id and image_key returned by read_messages / read_p2p_messages. Tries user identity first (works for any chat the user sees), falls back to app identity (requires the bot to be in the same chat).',
1009
+ inputSchema: {
1010
+ type: 'object',
1011
+ properties: {
1012
+ message_id: { type: 'string', description: 'The message_id (om_xxx) that contains the image — from read_messages / read_p2p_messages' },
1013
+ image_key: { type: 'string', description: 'The image_key from the message content (img_xxx)' },
1014
+ },
1015
+ required: ['message_id', 'image_key'],
1016
+ },
1017
+ },
1018
+
995
1019
  ];
996
1020
 
997
1021
  // --- Server ---
@@ -1139,9 +1163,20 @@ async function handleTool(name, args) {
1139
1163
  parts.push(` ${status.message}`);
1140
1164
  } catch (e) { parts.push(`Cookie: ${e.message}`); }
1141
1165
  const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
1142
- parts.push(`App credentials: ${hasApp ? 'Configured' : 'Not set'}`);
1143
- const official = hasApp ? getOfficialClient() : null;
1144
- 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)'}`);
1166
+ if (!hasApp) {
1167
+ parts.push(`App credentials: Not set`);
1168
+ } else {
1169
+ const official = getOfficialClient();
1170
+ const probe = await official.verifyApp();
1171
+ if (probe.valid) {
1172
+ const nameBit = probe.appName ? ` "${probe.appName}"` : '';
1173
+ parts.push(`App credentials: Valid — app_id=${probe.appId}${nameBit}`);
1174
+ } else {
1175
+ parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
1176
+ parts.push(` → Likely wrong/stale APP_ID. Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.`);
1177
+ }
1178
+ 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)'}`);
1179
+ }
1145
1180
  return text(parts.join('\n'));
1146
1181
  }
1147
1182
 
@@ -1236,17 +1271,16 @@ async function handleTool(name, args) {
1236
1271
  case 'get_doc_blocks':
1237
1272
  return json(await getOfficialClient().getDocBlocks(args.document_id));
1238
1273
  case 'create_doc': {
1239
- const official = getOfficialClient();
1240
- const ownership = official.hasUAT ? ' (as user)' : '';
1241
- return text(`Document created${ownership}: ${(await official.createDoc(args.title, args.folder_id)).documentId}`);
1274
+ const r = await getOfficialClient().createDoc(args.title, args.folder_id);
1275
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
1276
+ return text(`Document created${ownership}: ${r.documentId}`);
1242
1277
  }
1243
1278
 
1244
1279
  // --- Official API: Bitable ---
1245
1280
 
1246
1281
  case 'create_bitable': {
1247
- const official = getOfficialClient();
1248
- const ownership = official.hasUAT ? ' (as user)' : '';
1249
- const r = await official.createBitable(args.name, args.folder_id);
1282
+ const r = await getOfficialClient().createBitable(args.name, args.folder_id);
1283
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; bitable owned by the app, not you)';
1250
1284
  return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}`);
1251
1285
  }
1252
1286
  case 'list_bitable_tables':
@@ -1298,9 +1332,9 @@ async function handleTool(name, args) {
1298
1332
  case 'list_files':
1299
1333
  return json(await getOfficialClient().listFiles(args.folder_token));
1300
1334
  case 'create_folder': {
1301
- const official = getOfficialClient();
1302
- const ownership = official.hasUAT ? ' (as user)' : '';
1303
- return text(`Folder created${ownership}: ${(await official.createFolder(args.name, args.parent_token)).token}`);
1335
+ const r = await getOfficialClient().createFolder(args.name, args.parent_token);
1336
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
1337
+ return text(`Folder created${ownership}: ${r.token}`);
1304
1338
  }
1305
1339
 
1306
1340
  // --- Official API: Contact ---
@@ -1393,6 +1427,18 @@ async function handleTool(name, args) {
1393
1427
  case 'delete_file':
1394
1428
  return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
1395
1429
 
1430
+ case 'download_image': {
1431
+ const r = await getOfficialClient().downloadMessageResource(args.message_id, args.image_key, 'image');
1432
+ // Return as MCP image content so the model sees the pixels directly.
1433
+ // Also include a tiny text preamble so the via-identity + size are visible in transcript.
1434
+ return {
1435
+ content: [
1436
+ { type: 'text', text: `Image downloaded (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1437
+ { type: 'image', data: r.base64, mimeType: r.mimeType },
1438
+ ],
1439
+ };
1440
+ }
1441
+
1396
1442
  default:
1397
1443
  return text(`Unknown tool: ${name}`);
1398
1444
  }
@@ -1422,6 +1468,27 @@ async function main() {
1422
1468
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
1423
1469
  if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
1424
1470
  if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
1471
+
1472
+ // Validate APP_ID/SECRET against Feishu before serving any tool calls.
1473
+ // Catches the "Claude filled in a wrong/stale APP_ID during install" failure mode
1474
+ // that otherwise surfaces as cryptic 401s on every Official API call (looks like
1475
+ // "MCP 掉线" to the user). Non-blocking — we warn but still serve, because the
1476
+ // user may only need user-identity (cookie) tools.
1477
+ if (hasApp) {
1478
+ try {
1479
+ const probe = await getOfficialClient().verifyApp();
1480
+ if (probe.valid) {
1481
+ const nameBit = probe.appName ? ` "${probe.appName}"` : '';
1482
+ console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
1483
+ } else {
1484
+ console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
1485
+ console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
1486
+ console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
1487
+ }
1488
+ } catch (e) {
1489
+ console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
1490
+ }
1491
+ }
1425
1492
  }
1426
1493
 
1427
1494
  main().catch(console.error);
package/src/official.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const lark = require('@larksuiteoapi/node-sdk');
2
+ const { fetchWithTimeout } = require('./utils');
2
3
 
3
4
  // Redirect all Lark SDK logs to stderr.
4
5
  // The SDK's defaultLogger.error uses console.log (stdout), which corrupts
@@ -39,6 +40,50 @@ class LarkOfficialClient {
39
40
  return !!this._uat;
40
41
  }
41
42
 
43
+ // Fetches (and caches) an app_access_token directly via the internal endpoint.
44
+ // Avoids relying on SDK-internal token-manager APIs that may change across versions.
45
+ async _getAppToken() {
46
+ const now = Math.floor(Date.now() / 1000);
47
+ if (this._appToken && this._appTokenExpires > now + 60) return this._appToken;
48
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
52
+ timeoutMs: 10000,
53
+ });
54
+ const data = await res.json();
55
+ if (data.code !== 0 || !data.app_access_token) {
56
+ throw new Error(`app_access_token failed: ${data.code}: ${data.msg || 'unknown'}`);
57
+ }
58
+ this._appToken = data.app_access_token;
59
+ this._appTokenExpires = now + (typeof data.expire === 'number' ? data.expire : 7200);
60
+ return this._appToken;
61
+ }
62
+
63
+ // Probe APP_ID/SECRET validity by requesting a tenant access token.
64
+ // Catches the common "user's Claude filled in a wrong/stale APP_ID" failure mode
65
+ // (observed in production: 周宇's machine ran with an APP_ID nobody recognized,
66
+ // causing all Official API calls to 401 with cryptic messages that looked like
67
+ // MCP "掉线" to the user). Returns { valid, appId, appName?, error? }.
68
+ async verifyApp() {
69
+ try {
70
+ const token = await this._getAppToken();
71
+ // Try to fetch app display name (best-effort; requires application scope)
72
+ let appName = null;
73
+ try {
74
+ const infoRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${this.appId}?lang=zh_cn`, {
75
+ headers: { 'Authorization': `Bearer ${token}` },
76
+ timeoutMs: 10000,
77
+ });
78
+ const info = await infoRes.json();
79
+ if (info.code === 0) appName = info.data?.app?.app_name || null;
80
+ } catch (_) { /* name is best-effort; valid creds still matter most */ }
81
+ return { valid: true, appId: this.appId, appName };
82
+ } catch (e) {
83
+ return { valid: false, appId: this.appId, error: e.message };
84
+ }
85
+ }
86
+
42
87
  async _getValidUAT() {
43
88
  if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
44
89
 
@@ -53,7 +98,7 @@ class LarkOfficialClient {
53
98
  async _refreshUAT() {
54
99
  if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
55
100
 
56
- const res = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
101
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
57
102
  method: 'POST',
58
103
  headers: { 'content-type': 'application/json' },
59
104
  body: JSON.stringify({
@@ -112,31 +157,38 @@ class LarkOfficialClient {
112
157
  headers['content-type'] = 'application/json';
113
158
  init.body = JSON.stringify(body);
114
159
  }
115
- const res = await fetch(url, init);
160
+ const res = await fetchWithTimeout(url, init);
116
161
  return res.json();
117
162
  });
118
163
  }
119
164
 
120
165
  // Try UAT first (for resources likely owned by the user), fall back to app SDK on failure.
121
- // Returns SDK-shaped {code, msg, data}. Both paths yield the same shape.
166
+ // Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
167
+ // callers can surface this to distinguish "created by user" vs "created by app" for resources
168
+ // whose ownership matters (docs, bitables, folders).
122
169
  async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
123
170
  if (this.hasUAT) {
124
171
  try {
125
172
  const data = await this._uatREST(method, uatPath, { body, query });
126
- if (data.code === 0) return data;
173
+ if (data.code === 0) {
174
+ data._viaUser = true;
175
+ return data;
176
+ }
127
177
  console.error(`[feishu-user-plugin] ${label} as user failed (${data.code}: ${data.msg}), retrying as app`);
128
178
  } catch (err) {
129
179
  console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
130
180
  }
131
181
  }
132
- return this._safeSDKCall(sdkFn, label);
182
+ const appData = await this._safeSDKCall(sdkFn, label);
183
+ if (appData && typeof appData === 'object') appData._viaUser = false;
184
+ return appData;
133
185
  }
134
186
 
135
187
  async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
136
188
  const params = new URLSearchParams({ page_size: String(pageSize) });
137
189
  if (pageToken) params.set('page_token', pageToken);
138
190
  const data = await this._withUAT(async (uat) => {
139
- const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
191
+ const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
140
192
  headers: { 'Authorization': `Bearer ${uat}` },
141
193
  });
142
194
  return res.json();
@@ -158,7 +210,7 @@ class LarkOfficialClient {
158
210
  if (endTime) params.set('end_time', endTime);
159
211
  if (pageToken) params.set('page_token', pageToken);
160
212
  const data = await this._withUAT(async (uat) => {
161
- const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
213
+ const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
162
214
  headers: { 'Authorization': `Bearer ${uat}` },
163
215
  });
164
216
  return res.json();
@@ -198,6 +250,57 @@ class LarkOfficialClient {
198
250
  return this._formatMessage(res.data);
199
251
  }
200
252
 
253
+ // Download a resource (image/file) attached to a message.
254
+ // Tries UAT first (works for any chat the user is in), falls back to app token
255
+ // (requires the bot to be in the same chat — Feishu restriction).
256
+ // resourceType: 'image' | 'file'. Returns { base64, mimeType, viaUser }.
257
+ async downloadMessageResource(messageId, fileKey, resourceType = 'image') {
258
+ const path = `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=${encodeURIComponent(resourceType)}`;
259
+ const url = 'https://open.feishu.cn' + path;
260
+
261
+ // Attempt 1: user identity
262
+ if (this.hasUAT) {
263
+ try {
264
+ const uat = await this._getValidUAT();
265
+ const res = await fetchWithTimeout(url, {
266
+ headers: { 'Authorization': `Bearer ${uat}` },
267
+ timeoutMs: 60000,
268
+ });
269
+ if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
270
+ const buf = Buffer.from(await res.arrayBuffer());
271
+ return {
272
+ base64: buf.toString('base64'),
273
+ mimeType: res.headers.get('content-type') || 'application/octet-stream',
274
+ bytes: buf.length,
275
+ viaUser: true,
276
+ };
277
+ }
278
+ const errJson = await res.json().catch(() => null);
279
+ console.error(`[feishu-user-plugin] downloadMessageResource as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
280
+ } catch (e) {
281
+ console.error(`[feishu-user-plugin] downloadMessageResource as user threw (${e.message}), retrying as app`);
282
+ }
283
+ }
284
+
285
+ // Attempt 2: app identity
286
+ const token = await this._getAppToken();
287
+ const res = await fetchWithTimeout(url, {
288
+ headers: { 'Authorization': `Bearer ${token}` },
289
+ timeoutMs: 60000,
290
+ });
291
+ if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
292
+ const errJson = await res.json().catch(() => null);
293
+ throw new Error(`downloadMessageResource failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires the bot to be in the same chat.`);
294
+ }
295
+ const buf = Buffer.from(await res.arrayBuffer());
296
+ return {
297
+ base64: buf.toString('base64'),
298
+ mimeType: res.headers.get('content-type') || 'application/octet-stream',
299
+ bytes: buf.length,
300
+ viaUser: false,
301
+ };
302
+ }
303
+
201
304
  async replyMessage(messageId, text, msgType = 'text') {
202
305
  const content = msgType === 'text' ? JSON.stringify({ text }) : text;
203
306
  const res = await this._safeSDKCall(
@@ -419,7 +522,7 @@ class LarkOfficialClient {
419
522
  sdkFn: () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
420
523
  label: 'createDoc',
421
524
  });
422
- return { documentId: res.data.document?.document_id };
525
+ return { documentId: res.data.document?.document_id, viaUser: !!res._viaUser };
423
526
  }
424
527
 
425
528
  async getDocBlocks(documentId) {
@@ -499,7 +602,7 @@ class LarkOfficialClient {
499
602
  sdkFn: () => this.client.bitable.app.create({ data }),
500
603
  label: 'createBitable',
501
604
  });
502
- return { appToken: res.data.app?.app_token, name: res.data.app?.name, url: res.data.app?.url };
605
+ return { appToken: res.data.app?.app_token, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser };
503
606
  }
504
607
 
505
608
  async listBitableTables(appToken) {
@@ -785,7 +888,7 @@ class LarkOfficialClient {
785
888
  sdkFn: () => this.client.drive.file.createFolder({ data: body }),
786
889
  label: 'createFolder',
787
890
  });
788
- return { token: res.data.token };
891
+ return { token: res.data.token, viaUser: !!res._viaUser };
789
892
  }
790
893
 
791
894
  // --- Drive: File Operations ---
package/src/utils.js CHANGED
@@ -31,9 +31,22 @@ function formatCookie(cookieObj) {
31
31
  .join('; ');
32
32
  }
33
33
 
34
+ // Wraps global fetch with an AbortController-based timeout. A stalled network
35
+ // connection to feishu.cn can otherwise block an MCP tool handler indefinitely,
36
+ // causing the client to time out and (in some clients) tear down the stdio
37
+ // transport — observed as "MCP 中途掉线" by v1.3.2 users.
38
+ // Default 30s; pass `timeoutMs` in init to override per-call.
39
+ function fetchWithTimeout(url, init = {}) {
40
+ const { timeoutMs = 30000, ...rest } = init;
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(new Error(`fetch timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
43
+ return fetch(url, { ...rest, signal: rest.signal || controller.signal }).finally(() => clearTimeout(timer));
44
+ }
45
+
34
46
  module.exports = {
35
47
  generateRequestId,
36
48
  generateCid,
37
49
  parseCookie,
38
50
  formatCookie,
51
+ fetchWithTimeout,
39
52
  };