tabby-bianbu-mcp 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to `tabby-bianbu-mcp` will be documented in this file.
4
4
 
5
+ ## [0.7.0] - 2026-03-23
6
+
7
+ ### Added (Remote MCP Server v1.3.0)
8
+ - **server-side rate limiting**: returns HTTP 429 with `Retry-After` header when concurrent requests exceed `MAX_CONCURRENT_REQUESTS` (default 32), enabling client-side adaptive throttling
9
+ - **session concurrency caps**: configurable limits for shell (`MAX_SHELL_SESSIONS=8`), upload (`MAX_UPLOAD_SESSIONS=16`), and download (`MAX_DOWNLOAD_SESSIONS=16`) sessions — prevents resource exhaustion under parallel transfers
10
+ - **ISO 8601 timestamps**: `list_directory`, `fileStat`, and all file operations now return ISO date strings (e.g. `2026-03-23T12:34:56.789Z`) instead of Unix epoch seconds — directly consumable by the client's `formatDate()`
11
+ - **enhanced health endpoint**: now reports `active_sessions` counts, `concurrency` stats (active/total/throttled requests), `uptime_seconds`, `memory` usage (RSS/heap), `node_version`, and `platform` info
12
+ - **graceful shutdown**: SIGINT/SIGTERM now drain active requests, clean up upload temp directories, and close the HTTP server cleanly before exit
13
+ - **`rate_limiting` and `iso_timestamps` capability flags** in health `supports` object for client feature detection
14
+
15
+ ### Changed (Remote MCP Server v1.3.0)
16
+ - `logical_session_limits` in health response now reports actual configured caps instead of `null`
17
+ - `exec_shell_session` response now includes `session_cwd` for the client to track the working directory
18
+ - removed dead `registerStatefulSession()` function (was never called)
19
+ - script version bumped to 1.3.0, server version bumped to 1.3.0
20
+
21
+ ### Changed (Plugin)
22
+ - bundled remote installer updated to script v1.3.0 / server v1.3.0
23
+
24
+ ## [0.6.1] - 2026-03-23
25
+
26
+ ### Fixed
27
+ - Angular JIT compilation error: moved arrow-function-based transfer status expression from pug template into `activeTransferLabel` getter to avoid `Bindings cannot contain assignments` parser error
28
+
5
29
  ## [0.6.0] - 2026-03-23
6
30
 
7
31
  ### Added
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "fileName": "bianbu_agent_proxy.sh",
3
3
  "sourceFile": "bianbu_agent_proxy.sh",
4
- "scriptVersion": "1.2.2",
5
- "serverVersion": "1.2.0",
6
- "sha256": "729442eb8fa2311e67415ed78d5dbf0c12e58281e075cd3cc43cbb74c595e5b9",
7
- "bytes": 67756,
8
- "generatedAt": "2026-03-23T02:58:26.632Z"
4
+ "scriptVersion": "1.3.0",
5
+ "serverVersion": "1.3.0",
6
+ "sha256": "b6cb60d6796e3e5d6e0a301e80ac17e8f0e9d6d610cf30d7078ca203f9ec2999",
7
+ "bytes": 72162,
8
+ "generatedAt": "2026-03-23T04:06:24.139Z"
9
9
  }
@@ -4,8 +4,8 @@ set -Eeuo pipefail
4
4
  umask 077
5
5
 
6
6
  SCRIPT_NAME="$(basename "$0")"
7
- SCRIPT_VERSION="${SCRIPT_VERSION:-1.2.2}"
8
- SERVER_VERSION="${SERVER_VERSION:-1.2.0}"
7
+ SCRIPT_VERSION="${SCRIPT_VERSION:-1.3.0}"
8
+ SERVER_VERSION="${SERVER_VERSION:-1.3.0}"
9
9
  APP_NAME="bianbu-mcp-server"
10
10
  INSTALL_ROOT="/opt/${APP_NAME}"
11
11
  APP_FILE="${INSTALL_ROOT}/server.mjs"
@@ -25,6 +25,10 @@ ENABLE_PASSWORDLESS_SUDO="${ENABLE_PASSWORDLESS_SUDO:-false}"
25
25
  MAX_FILE_MB="${MAX_FILE_MB:-64}"
26
26
  MAX_COMMAND_OUTPUT_KB="${MAX_COMMAND_OUTPUT_KB:-256}"
27
27
  MAX_REQUEST_BODY_MB="${MAX_REQUEST_BODY_MB:-8}"
28
+ MAX_CONCURRENT_REQUESTS="${MAX_CONCURRENT_REQUESTS:-32}"
29
+ MAX_UPLOAD_SESSIONS="${MAX_UPLOAD_SESSIONS:-16}"
30
+ MAX_DOWNLOAD_SESSIONS="${MAX_DOWNLOAD_SESSIONS:-16}"
31
+ MAX_SHELL_SESSIONS="${MAX_SHELL_SESSIONS:-8}"
28
32
  TLS_CERT_FILE="${TLS_CERT_FILE:-}"
29
33
  TLS_KEY_FILE="${TLS_KEY_FILE:-}"
30
34
  MCP_TRANSPORT_MODE="${MCP_TRANSPORT_MODE:-stateless}"
@@ -102,6 +106,10 @@ MCP tools:
102
106
  MAX_FILE_MB 上传/下载单文件大小限制,默认: ${MAX_FILE_MB} MB
103
107
  MAX_COMMAND_OUTPUT_KB 命令输出截断上限,默认: ${MAX_COMMAND_OUTPUT_KB} KB
104
108
  MAX_REQUEST_BODY_MB HTTP JSON 请求体上限,默认: ${MAX_REQUEST_BODY_MB} MB
109
+ MAX_CONCURRENT_REQUESTS 最大并发 MCP 请求数,超出返回 429,默认: ${MAX_CONCURRENT_REQUESTS}
110
+ MAX_UPLOAD_SESSIONS 最大并发上传会话数,默认: ${MAX_UPLOAD_SESSIONS}
111
+ MAX_DOWNLOAD_SESSIONS 最大并发下载会话数,默认: ${MAX_DOWNLOAD_SESSIONS}
112
+ MAX_SHELL_SESSIONS 最大并发 Shell 会话数,默认: ${MAX_SHELL_SESSIONS}
105
113
  TLS_CERT_FILE 可选,HTTPS 证书路径
106
114
  TLS_KEY_FILE 可选,HTTPS 私钥路径
107
115
 
@@ -371,6 +379,7 @@ write_app() {
371
379
  import { randomBytes, randomUUID } from 'node:crypto';
372
380
  import { exec as execCb } from 'node:child_process';
373
381
  import { promisify } from 'node:util';
382
+ import os from 'node:os';
374
383
  import fs from 'node:fs';
375
384
  import path from 'node:path';
376
385
  import http from 'node:http';
@@ -421,12 +430,20 @@ const EXPRESS_JSON_LIMIT = `${MAX_REQUEST_BODY_MB}mb`;
421
430
  const TLS_CERT_FILE = process.env.TLS_CERT_FILE || '';
422
431
  const TLS_KEY_FILE = process.env.TLS_KEY_FILE || '';
423
432
  const MCP_TRANSPORT_MODE = (process.env.MCP_TRANSPORT_MODE || 'stateless').toLowerCase();
433
+ const MAX_CONCURRENT_REQUESTS = Number(process.env.MAX_CONCURRENT_REQUESTS || '32');
434
+ const MAX_UPLOAD_SESSIONS = Number(process.env.MAX_UPLOAD_SESSIONS || '16');
435
+ const MAX_DOWNLOAD_SESSIONS = Number(process.env.MAX_DOWNLOAD_SESSIONS || '16');
436
+ const MAX_SHELL_SESSIONS = Number(process.env.MAX_SHELL_SESSIONS || '8');
424
437
  const CANONICAL_FILE_ROOT = FILE_ROOT === '/' ? '/' : fs.realpathSync(FILE_ROOT);
425
438
  const HAS_SUDO = fs.existsSync('/usr/bin/sudo') || fs.existsSync('/bin/sudo');
426
439
  const shellSessions = new Map();
427
440
  const uploadSessions = new Map();
428
441
  const downloadSessions = new Map();
429
442
  const SESSION_IDLE_MS = 60 * 60 * 1000;
443
+ const SERVER_START_TIME = Date.now();
444
+ let activeRequests = 0;
445
+ let totalRequests = 0;
446
+ let throttledRequests = 0;
430
447
 
431
448
  if (!['stateless', 'stateful'].includes(MCP_TRANSPORT_MODE)) {
432
449
  throw new Error(`Unsupported MCP_TRANSPORT_MODE: ${MCP_TRANSPORT_MODE}`);
@@ -458,6 +475,7 @@ function shellQuote(value) {
458
475
 
459
476
  function rootHelperScript() {
460
477
  return String.raw`import base64, json, os, shutil, stat, sys, tempfile
478
+ from datetime import datetime, timezone
461
479
  payload = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8'))
462
480
  op = payload['op']
463
481
  target = payload.get('path', '')
@@ -467,7 +485,7 @@ def stat_dict(p):
467
485
  return {
468
486
  'path': p,
469
487
  'size': st.st_size,
470
- 'modified': int(st.st_mtime),
488
+ 'modified': datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
471
489
  'is_dir': stat.S_ISDIR(st.st_mode),
472
490
  'is_file': stat.S_ISREG(st.st_mode),
473
491
  }
@@ -696,7 +714,7 @@ async function fileStat(target) {
696
714
  return {
697
715
  path: target,
698
716
  size: stat.size,
699
- modified: Math.floor(stat.mtimeMs / 1000),
717
+ modified: new Date(stat.mtimeMs).toISOString(),
700
718
  is_dir: stat.isDirectory(),
701
719
  is_file: stat.isFile(),
702
720
  };
@@ -859,7 +877,7 @@ async function readBinaryChunk(target, offset, chunkBytes, asRoot) {
859
877
  return {
860
878
  path: target,
861
879
  size: stat.size,
862
- modified: Math.floor(stat.mtimeMs / 1000),
880
+ modified: new Date(stat.mtimeMs).toISOString(),
863
881
  is_dir: stat.isDirectory(),
864
882
  is_file: stat.isFile(),
865
883
  offset,
@@ -885,6 +903,7 @@ function makeServer() {
885
903
  'health',
886
904
  { description: 'Return basic MCP server health information.' },
887
905
  async () => {
906
+ const mem = process.memoryUsage();
888
907
  const payload = {
889
908
  ok: true,
890
909
  listen: `${HOST}:${PORT}${MCP_PATH}`,
@@ -898,10 +917,29 @@ function makeServer() {
898
917
  passwordless_sudo_expected: ENABLE_PASSWORDLESS_SUDO,
899
918
  session_idle_ms: SESSION_IDLE_MS,
900
919
  logical_session_limits: {
901
- shell: null,
902
- upload: null,
903
- download: null,
920
+ shell: MAX_SHELL_SESSIONS,
921
+ upload: MAX_UPLOAD_SESSIONS,
922
+ download: MAX_DOWNLOAD_SESSIONS,
904
923
  },
924
+ active_sessions: {
925
+ shell: shellSessions.size,
926
+ upload: uploadSessions.size,
927
+ download: downloadSessions.size,
928
+ },
929
+ concurrency: {
930
+ max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
931
+ active_requests: activeRequests,
932
+ total_requests: totalRequests,
933
+ throttled_requests: throttledRequests,
934
+ },
935
+ uptime_seconds: Math.floor((Date.now() - SERVER_START_TIME) / 1000),
936
+ memory: {
937
+ rss_mb: Math.round(mem.rss / 1048576 * 10) / 10,
938
+ heap_used_mb: Math.round(mem.heapUsed / 1048576 * 10) / 10,
939
+ heap_total_mb: Math.round(mem.heapTotal / 1048576 * 10) / 10,
940
+ },
941
+ node_version: process.version,
942
+ platform: `${os.platform()} ${os.release()} ${os.arch()}`,
905
943
  script_version: INSTALLER_SCRIPT_VERSION,
906
944
  server_version: SERVER_VERSION,
907
945
  service_name: 'bianbu-mcp-server',
@@ -911,6 +949,8 @@ function makeServer() {
911
949
  parallel_chunk_offsets: true,
912
950
  rename_path: true,
913
951
  shell_session: true,
952
+ rate_limiting: true,
953
+ iso_timestamps: true,
914
954
  },
915
955
  };
916
956
  return textResult(JSON.stringify(payload, null, 2), payload);
@@ -1186,6 +1226,9 @@ function makeServer() {
1186
1226
  },
1187
1227
  },
1188
1228
  async ({ cwd, as_root }) => {
1229
+ if (shellSessions.size >= MAX_SHELL_SESSIONS) {
1230
+ throw new Error(`shell session limit reached (max ${MAX_SHELL_SESSIONS})`);
1231
+ }
1189
1232
  const workingDirectory = await resolveRequestedPath(cwd, as_root);
1190
1233
  const stat = await fs.promises.stat(workingDirectory).catch(() => null);
1191
1234
  if (!stat || !stat.isDirectory()) {
@@ -1221,6 +1264,7 @@ function makeServer() {
1221
1264
  session.cwd = payload.cwd || session.cwd;
1222
1265
  session.updatedAt = Date.now();
1223
1266
  payload.session_id = session_id;
1267
+ payload.session_cwd = session.cwd;
1224
1268
  return textResult(JSON.stringify(payload, null, 2), payload);
1225
1269
  },
1226
1270
  );
@@ -1253,6 +1297,9 @@ function makeServer() {
1253
1297
  },
1254
1298
  },
1255
1299
  async ({ path: inputPath, overwrite, total_size, chunk_bytes, as_root }) => {
1300
+ if (uploadSessions.size >= MAX_UPLOAD_SESSIONS) {
1301
+ throw new Error(`upload session limit reached (max ${MAX_UPLOAD_SESSIONS})`);
1302
+ }
1256
1303
  const target = await resolveRequestedPath(inputPath, as_root);
1257
1304
  const targetExists = await statAnyPath(target, as_root).then(() => true).catch(() => false);
1258
1305
  if (!overwrite && targetExists) {
@@ -1369,6 +1416,9 @@ function makeServer() {
1369
1416
  },
1370
1417
  },
1371
1418
  async ({ path: inputPath, chunk_bytes, as_root }) => {
1419
+ if (downloadSessions.size >= MAX_DOWNLOAD_SESSIONS) {
1420
+ throw new Error(`download session limit reached (max ${MAX_DOWNLOAD_SESSIONS})`);
1421
+ }
1372
1422
  const target = await resolveRequestedPath(inputPath, as_root);
1373
1423
  const info = await statAnyPath(target, as_root);
1374
1424
  if (!info.is_file) {
@@ -1432,7 +1482,27 @@ app.use(express.json({ limit: EXPRESS_JSON_LIMIT }));
1432
1482
  const transports = new Map();
1433
1483
  const servers = new Map();
1434
1484
 
1485
+ // Concurrency-based rate limiting middleware for MCP endpoint
1486
+ function rateLimitMiddleware(req, res, next) {
1487
+ totalRequests++;
1488
+ if (activeRequests >= MAX_CONCURRENT_REQUESTS) {
1489
+ throttledRequests++;
1490
+ res.setHeader('Retry-After', '1');
1491
+ res.status(429).json({
1492
+ jsonrpc: '2.0',
1493
+ error: { code: -32000, message: `Too many concurrent requests (limit: ${MAX_CONCURRENT_REQUESTS})` },
1494
+ id: null,
1495
+ });
1496
+ return;
1497
+ }
1498
+ activeRequests++;
1499
+ res.on('finish', () => { activeRequests--; });
1500
+ res.on('close', () => { activeRequests = Math.max(0, activeRequests - 1); });
1501
+ next();
1502
+ }
1503
+
1435
1504
  app.get('/health', (_req, res) => {
1505
+ const mem = process.memoryUsage();
1436
1506
  res.json({
1437
1507
  ok: true,
1438
1508
  listen: `${HOST}:${PORT}${MCP_PATH}`,
@@ -1441,10 +1511,28 @@ app.get('/health', (_req, res) => {
1441
1511
  max_request_body_bytes: MAX_REQUEST_BODY_BYTES,
1442
1512
  session_idle_ms: SESSION_IDLE_MS,
1443
1513
  logical_session_limits: {
1444
- shell: null,
1445
- upload: null,
1446
- download: null,
1514
+ shell: MAX_SHELL_SESSIONS,
1515
+ upload: MAX_UPLOAD_SESSIONS,
1516
+ download: MAX_DOWNLOAD_SESSIONS,
1517
+ },
1518
+ active_sessions: {
1519
+ shell: shellSessions.size,
1520
+ upload: uploadSessions.size,
1521
+ download: downloadSessions.size,
1447
1522
  },
1523
+ concurrency: {
1524
+ max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
1525
+ active_requests: activeRequests,
1526
+ total_requests: totalRequests,
1527
+ throttled_requests: throttledRequests,
1528
+ },
1529
+ uptime_seconds: Math.floor((Date.now() - SERVER_START_TIME) / 1000),
1530
+ memory: {
1531
+ rss_mb: Math.round(mem.rss / 1048576 * 10) / 10,
1532
+ heap_used_mb: Math.round(mem.heapUsed / 1048576 * 10) / 10,
1533
+ },
1534
+ node_version: process.version,
1535
+ platform: `${os.platform()} ${os.release()} ${os.arch()}`,
1448
1536
  script_version: INSTALLER_SCRIPT_VERSION,
1449
1537
  server_version: SERVER_VERSION,
1450
1538
  tools: SUPPORTED_TOOLS,
@@ -1453,36 +1541,13 @@ app.get('/health', (_req, res) => {
1453
1541
  parallel_chunk_offsets: true,
1454
1542
  rename_path: true,
1455
1543
  shell_session: true,
1544
+ rate_limiting: true,
1545
+ iso_timestamps: true,
1456
1546
  },
1457
1547
  });
1458
1548
  });
1459
1549
 
1460
- function registerStatefulSession(transport, server) {
1461
- transport.onclose = () => {
1462
- const sid = transport.sessionId;
1463
- if (sid) {
1464
- transports.delete(sid);
1465
- servers.delete(sid);
1466
- }
1467
- };
1468
-
1469
- return new StreamableHTTPServerTransport({
1470
- sessionIdGenerator: () => randomUUID(),
1471
- enableJsonResponse: true,
1472
- onsessioninitialized: (sessionId) => {
1473
- transports.set(sessionId, transport);
1474
- servers.set(sessionId, server);
1475
- },
1476
- onsessionclosed: (sessionId) => {
1477
- if (sessionId) {
1478
- transports.delete(sessionId);
1479
- servers.delete(sessionId);
1480
- }
1481
- },
1482
- });
1483
- }
1484
-
1485
- app.post(MCP_PATH, async (req, res) => {
1550
+ app.post(MCP_PATH, rateLimitMiddleware, async (req, res) => {
1486
1551
  try {
1487
1552
  if (MCP_TRANSPORT_MODE === 'stateless') {
1488
1553
  const server = makeServer();
@@ -1619,8 +1684,27 @@ process.on('uncaughtException', (error) => {
1619
1684
  process.exit(1);
1620
1685
  });
1621
1686
 
1622
- process.on('SIGINT', () => process.exit(0));
1623
- process.on('SIGTERM', () => process.exit(0));
1687
+ async function gracefulShutdown(signal) {
1688
+ console.log(`${signal} received, shutting down gracefully...`);
1689
+ // Clean up upload sessions (remove temp dirs)
1690
+ const cleanups = [];
1691
+ for (const [id, session] of uploadSessions.entries()) {
1692
+ cleanups.push(cleanupUploadSession(session).catch(() => {}));
1693
+ uploadSessions.delete(id);
1694
+ }
1695
+ downloadSessions.clear();
1696
+ shellSessions.clear();
1697
+ await Promise.allSettled(cleanups);
1698
+ httpServer.close(() => {
1699
+ console.log('Server closed');
1700
+ process.exit(0);
1701
+ });
1702
+ // Force exit after 10 seconds
1703
+ setTimeout(() => process.exit(0), 10000).unref();
1704
+ }
1705
+
1706
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1707
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1624
1708
  EOF
1625
1709
  run_as_root python3 - "$app_file" "$SERVER_VERSION" "$SCRIPT_VERSION" <<'PY'
1626
1710
  from pathlib import Path
@@ -1707,6 +1791,10 @@ ENABLE_PASSWORDLESS_SUDO=${ENABLE_PASSWORDLESS_SUDO}
1707
1791
  MAX_FILE_MB=${MAX_FILE_MB}
1708
1792
  MAX_COMMAND_OUTPUT_KB=${MAX_COMMAND_OUTPUT_KB}
1709
1793
  MAX_REQUEST_BODY_MB=${MAX_REQUEST_BODY_MB}
1794
+ MAX_CONCURRENT_REQUESTS=${MAX_CONCURRENT_REQUESTS}
1795
+ MAX_UPLOAD_SESSIONS=${MAX_UPLOAD_SESSIONS}
1796
+ MAX_DOWNLOAD_SESSIONS=${MAX_DOWNLOAD_SESSIONS}
1797
+ MAX_SHELL_SESSIONS=${MAX_SHELL_SESSIONS}
1710
1798
  TLS_CERT_FILE=${TLS_CERT_FILE}
1711
1799
  TLS_KEY_FILE=${TLS_KEY_FILE}
1712
1800
  EOF
@@ -1876,6 +1964,11 @@ FILE_ROOT=${FILE_ROOT}
1876
1964
  ENABLE_PASSWORDLESS_SUDO=${ENABLE_PASSWORDLESS_SUDO}
1877
1965
  MAX_FILE_MB=${MAX_FILE_MB}
1878
1966
  MAX_COMMAND_OUTPUT_KB=${MAX_COMMAND_OUTPUT_KB}
1967
+ MAX_REQUEST_BODY_MB=${MAX_REQUEST_BODY_MB}
1968
+ MAX_CONCURRENT_REQUESTS=${MAX_CONCURRENT_REQUESTS}
1969
+ MAX_UPLOAD_SESSIONS=${MAX_UPLOAD_SESSIONS}
1970
+ MAX_DOWNLOAD_SESSIONS=${MAX_DOWNLOAD_SESSIONS}
1971
+ MAX_SHELL_SESSIONS=${MAX_SHELL_SESSIONS}
1879
1972
  TLS_CERT_FILE=${TLS_CERT_FILE}
1880
1973
  TLS_KEY_FILE=${TLS_KEY_FILE}
1881
1974
  BACKUP_ROOT=${BACKUP_ROOT}
@@ -109,6 +109,7 @@ export declare class BianbuCloudFilesTabComponent extends BaseTabComponent {
109
109
  get totalSize(): string;
110
110
  get selectionSummary(): string;
111
111
  isSelected(index: number): boolean;
112
+ get activeTransferLabel(): string;
112
113
  toggleDetailPane(): void;
113
114
  private clearPreview;
114
115
  baseName(path: string): string;