tabby-bianbu-mcp 0.4.0 → 0.5.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,20 @@
2
2
 
3
3
  All notable changes to `tabby-bianbu-mcp` will be documented in this file.
4
4
 
5
+ ## [0.5.0] - 2026-03-19
6
+
7
+ ### Added
8
+ - 32-slot MCP request scheduler with 2 interactive lanes and up to 30 transfer lanes
9
+ - single-active-file queue so multiple file transfers run one file at a time
10
+ - explicit worker cadence setting for 100 ms dispatch cycles
11
+ - parallel offset-based chunk transfer support between the Tabby client and remote Bianbu MCP server
12
+
13
+ ### Changed
14
+ - upload chunk default changed to 32 KiB
15
+ - download chunk default changed to 128 KiB
16
+ - downloads now stream into Tabby's native download sink instead of browser Blob URLs
17
+ - remote installer now raises the Express JSON body limit explicitly and reports parallel chunk capability in health
18
+
5
19
  ## [0.4.0] - 2026-03-19
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -14,6 +14,9 @@ It adds a dedicated Bianbu MCP settings page, a shell-like terminal tab, a file
14
14
  - packaged remote installer asset with SHA-256 metadata and integrity verification
15
15
  - push latest installer to the remote host and trigger `up` or `repair`
16
16
  - chunked upload and download support for larger files
17
+ - 32-slot request scheduling with 2 reserved interactive lanes and up to 30 transfer lanes
18
+ - one active file transfer at a time with queued follow-up uploads/downloads
19
+ - parallel offset-based file chunk transfers against the matching remote installer
17
20
  - atomic rename support through MCP `rename_path`
18
21
  - remote backup and `restore-latest` recovery support in the installer
19
22
 
@@ -37,9 +40,13 @@ The plugin stores these main settings under `bianbuMcp`:
37
40
  - `name`
38
41
  - `url`
39
42
  - `apiKey`
40
- - `minIntervalMs`
43
+ - `interactiveConcurrency`
44
+ - `transferConcurrency`
45
+ - `workerCadenceMs`
41
46
  - `maxRetries`
42
47
  - `retryBaseMs`
48
+ - `uploadChunkBytes`
49
+ - `downloadChunkBytes`
43
50
  - `notes`
44
51
  - `installerRemotePath`
45
52
  - `maintenanceAsRoot`
@@ -3,7 +3,7 @@
3
3
  "sourceFile": "bianbu_agent_proxy.sh",
4
4
  "scriptVersion": "1.2.0",
5
5
  "serverVersion": "1.2.0",
6
- "sha256": "d4f21b824acf3f73356977706da8c5917ec7832903ba1d2312322cead4f8c69f",
7
- "bytes": 56914,
8
- "generatedAt": "2026-03-19T06:19:56.738Z"
6
+ "sha256": "62c492d026fe0e16e007d9f0bbeeaedfcbd55b457ca357a8d137aff9a24ce594",
7
+ "bytes": 63063,
8
+ "generatedAt": "2026-03-19T11:00:34.100Z"
9
9
  }
@@ -92,6 +92,7 @@ MCP tools:
92
92
  ENABLE_PASSWORDLESS_SUDO bootstrap 时为 RUN_USER 自动配置 sudo 免密码,默认: ${ENABLE_PASSWORDLESS_SUDO}
93
93
  MAX_FILE_MB 上传/下载单文件大小限制,默认: ${MAX_FILE_MB} MB
94
94
  MAX_COMMAND_OUTPUT_KB 命令输出截断上限,默认: ${MAX_COMMAND_OUTPUT_KB} KB
95
+ MAX_REQUEST_BODY_MB HTTP JSON 请求体上限,默认: ${MAX_REQUEST_BODY_MB} MB
95
96
  TLS_CERT_FILE 可选,HTTPS 证书路径
96
97
  TLS_KEY_FILE 可选,HTTPS 私钥路径
97
98
 
@@ -345,6 +346,7 @@ write_package() {
345
346
  "type": "module",
346
347
  "dependencies": {
347
348
  "@modelcontextprotocol/sdk": "1.27.1",
349
+ "express": "^5.2.1",
348
350
  "zod": "^4.0.0"
349
351
  }
350
352
  }
@@ -361,7 +363,7 @@ import path from 'node:path';
361
363
  import http from 'node:http';
362
364
  import https from 'node:https';
363
365
 
364
- import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
366
+ import express from 'express';
365
367
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
366
368
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
367
369
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
@@ -400,6 +402,9 @@ const FILE_ROOT = path.resolve(process.env.FILE_ROOT || '/');
400
402
  const ENABLE_PASSWORDLESS_SUDO=(process.env.ENABLE_PASSWORDLESS_SUDO || 'true').toLowerCase() === 'true';
401
403
  const MAX_FILE_BYTES = Number(process.env.MAX_FILE_MB || '64') * 1024 * 1024;
402
404
  const MAX_COMMAND_OUTPUT_BYTES = Number(process.env.MAX_COMMAND_OUTPUT_KB || '256') * 1024;
405
+ const MAX_REQUEST_BODY_MB = Number(process.env.MAX_REQUEST_BODY_MB || '8');
406
+ const MAX_REQUEST_BODY_BYTES = MAX_REQUEST_BODY_MB * 1024 * 1024;
407
+ const EXPRESS_JSON_LIMIT = `${MAX_REQUEST_BODY_MB}mb`;
403
408
  const TLS_CERT_FILE = process.env.TLS_CERT_FILE || '';
404
409
  const TLS_KEY_FILE = process.env.TLS_KEY_FILE || '';
405
410
  const MCP_TRANSPORT_MODE = (process.env.MCP_TRANSPORT_MODE || 'stateless').toLowerCase();
@@ -413,6 +418,9 @@ const SESSION_IDLE_MS = 60 * 60 * 1000;
413
418
  if (!['stateless', 'stateful'].includes(MCP_TRANSPORT_MODE)) {
414
419
  throw new Error(`Unsupported MCP_TRANSPORT_MODE: ${MCP_TRANSPORT_MODE}`);
415
420
  }
421
+ if (!(MAX_REQUEST_BODY_MB > 0)) {
422
+ throw new Error(`MAX_REQUEST_BODY_MB must be > 0, got ${MAX_REQUEST_BODY_MB}`);
423
+ }
416
424
 
417
425
  function textResult(text, structuredContent = undefined) {
418
426
  const result = { content: [{ type: 'text', text }] };
@@ -451,6 +459,14 @@ def stat_dict(p):
451
459
  'is_file': stat.S_ISREG(st.st_mode),
452
460
  }
453
461
 
462
+ def ensure_parent(p):
463
+ parent = os.path.dirname(p) or '.'
464
+ os.makedirs(parent, exist_ok=True)
465
+
466
+ def part_offset_from_name(name):
467
+ stem = name.rsplit('.', 1)[0]
468
+ return int(stem)
469
+
454
470
  if op == 'list_directory':
455
471
  if not os.path.isdir(target):
456
472
  raise RuntimeError(f'not a directory: {target}')
@@ -465,7 +481,7 @@ elif op == 'read_text_file':
465
481
  with open(target, 'r', encoding=payload.get('encoding', 'utf-8')) as fh:
466
482
  print(json.dumps({'path': target, 'content': fh.read()}, ensure_ascii=False))
467
483
  elif op == 'write_text_file':
468
- os.makedirs(os.path.dirname(target), exist_ok=True)
484
+ ensure_parent(target)
469
485
  if (not payload.get('overwrite', True)) and os.path.exists(target):
470
486
  raise RuntimeError(f'target exists and overwrite=false: {target}')
471
487
  fd, tmp_path = tempfile.mkstemp(prefix='.mcp-write-', dir=os.path.dirname(target) or '.')
@@ -482,7 +498,7 @@ elif op == 'upload_binary_file':
482
498
  data = base64.b64decode(payload['content_base64'])
483
499
  if len(data) > int(payload['max_file_bytes']):
484
500
  raise RuntimeError(f"payload exceeds max size {payload['max_file_bytes']} bytes")
485
- os.makedirs(os.path.dirname(target), exist_ok=True)
501
+ ensure_parent(target)
486
502
  if (not payload.get('overwrite', True)) and os.path.exists(target):
487
503
  raise RuntimeError(f'target exists and overwrite=false: {target}')
488
504
  fd, tmp_path = tempfile.mkstemp(prefix='.mcp-bin-', dir=os.path.dirname(target) or '.')
@@ -495,15 +511,15 @@ elif op == 'upload_binary_file':
495
511
  if os.path.exists(tmp_path):
496
512
  os.unlink(tmp_path)
497
513
  print(json.dumps(stat_dict(target), ensure_ascii=False))
498
- elif op == 'append_binary_file':
514
+ elif op == 'write_binary_part':
499
515
  data = base64.b64decode(payload['content_base64'])
500
516
  if len(data) > int(payload['max_file_bytes']):
501
517
  raise RuntimeError(f"payload exceeds max size {payload['max_file_bytes']} bytes")
502
- os.makedirs(os.path.dirname(target), exist_ok=True)
503
- with open(target, 'ab') as fh:
518
+ ensure_parent(target)
519
+ with open(target, 'wb') as fh:
504
520
  fh.write(data)
505
521
  out = stat_dict(target)
506
- out['appended'] = len(data)
522
+ out['written'] = len(data)
507
523
  print(json.dumps(out, ensure_ascii=False))
508
524
  elif op == 'download_binary_file':
509
525
  if not os.path.isfile(target):
@@ -538,13 +554,48 @@ elif op == 'delete_path':
538
554
  print(json.dumps(info, ensure_ascii=False))
539
555
  elif op == 'rename_path':
540
556
  dest = payload['dest']
541
- os.makedirs(os.path.dirname(dest), exist_ok=True)
557
+ ensure_parent(dest)
542
558
  os.replace(target, dest)
543
559
  print(json.dumps(stat_dict(dest), ensure_ascii=False))
544
560
  elif op == 'path_info':
545
561
  if not os.path.exists(target):
546
562
  raise RuntimeError(f'path not found: {target}')
547
563
  print(json.dumps(stat_dict(target), ensure_ascii=False))
564
+ elif op == 'merge_binary_parts':
565
+ if not os.path.isdir(target):
566
+ raise RuntimeError(f'parts directory not found: {target}')
567
+ dest = payload['dest']
568
+ expected_size = payload.get('expected_size')
569
+ ensure_parent(dest)
570
+ part_entries = []
571
+ for name in os.listdir(target):
572
+ full = os.path.join(target, name)
573
+ if not os.path.isfile(full):
574
+ continue
575
+ part_entries.append((part_offset_from_name(name), os.path.getsize(full), full))
576
+ part_entries.sort(key=lambda entry: entry[0])
577
+ cursor = 0
578
+ for offset, size, _full in part_entries:
579
+ if offset != cursor:
580
+ raise RuntimeError(f'missing or overlapping part at offset={offset}, expected={cursor}')
581
+ cursor += size
582
+ if expected_size is not None and cursor != int(expected_size):
583
+ raise RuntimeError(f'merged size {cursor} does not match expected_size={expected_size}')
584
+ fd, tmp_path = tempfile.mkstemp(prefix='.mcp-merge-', dir=os.path.dirname(dest) or '.')
585
+ os.close(fd)
586
+ try:
587
+ with open(tmp_path, 'wb') as out:
588
+ for _offset, _size, full in part_entries:
589
+ with open(full, 'rb') as src:
590
+ shutil.copyfileobj(src, out, 1024 * 1024)
591
+ os.replace(tmp_path, dest)
592
+ finally:
593
+ if os.path.exists(tmp_path):
594
+ os.unlink(tmp_path)
595
+ out = stat_dict(dest)
596
+ out['merged_parts'] = len(part_entries)
597
+ out['merged_bytes'] = cursor
598
+ print(json.dumps(out, ensure_ascii=False))
548
599
  elif op == 'read_binary_chunk':
549
600
  if not os.path.isfile(target):
550
601
  raise RuntimeError(f'file not found: {target}')
@@ -642,6 +693,13 @@ function newSessionId(prefix) {
642
693
  return `${prefix}-${randomUUID()}`;
643
694
  }
644
695
 
696
+ async function cleanupUploadSession(session) {
697
+ if (!session?.temp_dir) {
698
+ return;
699
+ }
700
+ await deleteAnyPath(session.temp_dir, true, session.as_root).catch(() => {});
701
+ }
702
+
645
703
  function sweepSessions() {
646
704
  const now = Date.now();
647
705
  for (const [id, entry] of shellSessions.entries()) {
@@ -651,6 +709,7 @@ function sweepSessions() {
651
709
  }
652
710
  for (const [id, entry] of uploadSessions.entries()) {
653
711
  if (now - entry.updatedAt > SESSION_IDLE_MS) {
712
+ cleanupUploadSession(entry).catch(() => {});
654
713
  uploadSessions.delete(id);
655
714
  }
656
715
  }
@@ -704,17 +763,51 @@ async function runCommandWithContext(command, { cwd='.', timeoutSeconds=120, asR
704
763
  }
705
764
  }
706
765
 
707
- async function appendBinaryChunk(target, contentBase64, asRoot) {
766
+ async function writeBinaryPart(target, contentBase64, asRoot) {
708
767
  if (asRoot) {
709
- return runRootFileOp({ op: 'append_binary_file', path: target, content_base64: contentBase64, max_file_bytes: MAX_FILE_BYTES });
768
+ return runRootFileOp({ op: 'write_binary_part', path: target, content_base64: contentBase64, max_file_bytes: MAX_FILE_BYTES });
710
769
  }
711
770
  const data = Buffer.from(contentBase64, 'base64');
712
771
  if (data.length > MAX_FILE_BYTES) {
713
772
  throw new Error(`payload exceeds max size ${MAX_FILE_BYTES} bytes`);
714
773
  }
715
774
  await fs.promises.mkdir(path.dirname(target), { recursive: true });
716
- await fs.promises.appendFile(target, data);
717
- return fileStat(target);
775
+ await fs.promises.writeFile(target, data);
776
+ const info = await fileStat(target);
777
+ return { ...info, written: data.length };
778
+ }
779
+
780
+ async function mergeBinaryParts(partsDir, dest, expectedSize, asRoot) {
781
+ if (asRoot) {
782
+ return runRootFileOp({ op: 'merge_binary_parts', path: partsDir, dest, expected_size: expectedSize });
783
+ }
784
+ const names = (await fs.promises.readdir(partsDir))
785
+ .filter((name) => /^\d+\.part$/.test(name))
786
+ .sort((a, b) => Number(a.split('.', 1)[0]) - Number(b.split('.', 1)[0]));
787
+ let cursor = 0;
788
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
789
+ const tempPath = `${dest}.tmp-${randomBytes(6).toString('hex')}`;
790
+ const out = await fs.promises.open(tempPath, 'w');
791
+ try {
792
+ for (const name of names) {
793
+ const offset = Number(name.split('.', 1)[0]);
794
+ const full = path.join(partsDir, name);
795
+ const data = await fs.promises.readFile(full);
796
+ if (offset !== cursor) {
797
+ throw new Error(`missing or overlapping part at offset=${offset}, expected=${cursor}`);
798
+ }
799
+ await out.write(data, 0, data.length, offset);
800
+ cursor += data.length;
801
+ }
802
+ } finally {
803
+ await out.close();
804
+ }
805
+ if (expectedSize !== null && expectedSize !== undefined && cursor !== expectedSize) {
806
+ await fs.promises.rm(tempPath, { force: true });
807
+ throw new Error(`merged size ${cursor} does not match expected_size=${expectedSize}`);
808
+ }
809
+ const info = await renameAnyPath(tempPath, dest, false);
810
+ return { ...info, merged_parts: names.length, merged_bytes: cursor };
718
811
  }
719
812
 
720
813
  async function statAnyPath(target, asRoot) {
@@ -785,16 +878,24 @@ function makeServer() {
785
878
  file_root: FILE_ROOT,
786
879
  max_file_bytes: MAX_FILE_BYTES,
787
880
  max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES,
881
+ max_request_body_bytes: MAX_REQUEST_BODY_BYTES,
788
882
  transport_mode: MCP_TRANSPORT_MODE,
789
883
  running_uid: process.getuid(),
790
884
  has_sudo: HAS_SUDO,
791
885
  passwordless_sudo_expected: ENABLE_PASSWORDLESS_SUDO,
886
+ session_idle_ms: SESSION_IDLE_MS,
887
+ logical_session_limits: {
888
+ shell: null,
889
+ upload: null,
890
+ download: null,
891
+ },
792
892
  script_version: INSTALLER_SCRIPT_VERSION,
793
893
  server_version: SERVER_VERSION,
794
894
  service_name: 'bianbu-mcp-server',
795
895
  tools: SUPPORTED_TOOLS,
796
896
  supports: {
797
897
  chunked_transfers: true,
898
+ parallel_chunk_offsets: true,
798
899
  rename_path: true,
799
900
  shell_session: true,
800
901
  },
@@ -1133,21 +1234,42 @@ function makeServer() {
1133
1234
  inputSchema: {
1134
1235
  path: z.string(),
1135
1236
  overwrite: z.boolean().default(true),
1237
+ total_size: z.number().int().nonnegative().optional(),
1238
+ chunk_bytes: z.number().int().positive().max(MAX_FILE_BYTES).optional(),
1136
1239
  as_root: z.boolean().default(false),
1137
1240
  },
1138
1241
  },
1139
- async ({ path: inputPath, overwrite, as_root }) => {
1242
+ async ({ path: inputPath, overwrite, total_size, chunk_bytes, as_root }) => {
1140
1243
  const target = await resolveRequestedPath(inputPath, as_root);
1244
+ const targetExists = await statAnyPath(target, as_root).then(() => true).catch(() => false);
1245
+ if (!overwrite && targetExists) {
1246
+ throw new Error(`target exists and overwrite=false: ${target}`);
1247
+ }
1141
1248
  const upload_id = newSessionId('upload');
1142
- const temp_path = `${target}.upload-${randomBytes(6).toString('hex')}`;
1249
+ const temp_dir = `${target}.upload-${randomBytes(6).toString('hex')}`;
1250
+ const parts_dir = path.join(temp_dir, 'parts');
1251
+ const merge_path = path.join(temp_dir, 'merged.bin');
1143
1252
  if (as_root) {
1144
- await runRootFileOp({ op: 'upload_binary_file', path: temp_path, content_base64: '', overwrite: true, max_file_bytes: MAX_FILE_BYTES });
1253
+ await runRootFileOp({ op: 'make_directory', path: parts_dir, parents: true });
1145
1254
  } else {
1146
- await fs.promises.mkdir(path.dirname(temp_path), { recursive: true });
1147
- await fs.promises.writeFile(temp_path, Buffer.alloc(0));
1255
+ await fs.promises.mkdir(parts_dir, { recursive: true });
1148
1256
  }
1149
- uploadSessions.set(upload_id, { upload_id, target, temp_path, overwrite, as_root, bytes_received: 0, updatedAt: Date.now() });
1150
- const payload = { upload_id, path: target, temp_path, overwrite, as_root, bytes_received: 0 };
1257
+ uploadSessions.set(upload_id, {
1258
+ upload_id,
1259
+ target,
1260
+ temp_dir,
1261
+ parts_dir,
1262
+ merge_path,
1263
+ overwrite,
1264
+ as_root,
1265
+ total_size: total_size ?? null,
1266
+ chunk_bytes: chunk_bytes ?? null,
1267
+ bytes_received: 0,
1268
+ next_offset: 0,
1269
+ parts: new Map(),
1270
+ updatedAt: Date.now(),
1271
+ });
1272
+ const payload = { upload_id, path: target, temp_dir, parts_dir, overwrite, as_root, total_size: total_size ?? null, chunk_bytes: chunk_bytes ?? null, bytes_received: 0 };
1151
1273
  return textResult(JSON.stringify(payload, null, 2), payload);
1152
1274
  },
1153
1275
  );
@@ -1155,22 +1277,28 @@ function makeServer() {
1155
1277
  server.registerTool(
1156
1278
  'upload_chunked_part',
1157
1279
  {
1158
- description: 'Append a base64 chunk to an upload session.',
1280
+ description: 'Write a base64 chunk to an upload session, optionally at an explicit offset for parallel uploads.',
1159
1281
  inputSchema: {
1160
1282
  upload_id: z.string(),
1161
1283
  content_base64: z.string(),
1284
+ offset: z.number().int().nonnegative().optional(),
1162
1285
  },
1163
1286
  },
1164
- async ({ upload_id, content_base64 }) => {
1287
+ async ({ upload_id, content_base64, offset }) => {
1165
1288
  const session = uploadSessions.get(upload_id);
1166
1289
  if (!session) {
1167
1290
  throw new Error(`unknown upload session: ${upload_id}`);
1168
1291
  }
1169
- const stat = await appendBinaryChunk(session.temp_path, content_base64, session.as_root);
1170
- const appended = Buffer.from(content_base64, 'base64').length;
1171
- session.bytes_received += appended;
1292
+ const chunkSize = Buffer.from(content_base64, 'base64').length;
1293
+ const effectiveOffset = offset ?? session.next_offset;
1294
+ const partPath = path.join(session.parts_dir, `${effectiveOffset}.part`);
1295
+ const stat = await writeBinaryPart(partPath, content_base64, session.as_root);
1296
+ const previousSize = session.parts.get(effectiveOffset) ?? 0;
1297
+ session.parts.set(effectiveOffset, chunkSize);
1298
+ session.bytes_received += chunkSize - previousSize;
1299
+ session.next_offset = Math.max(session.next_offset, effectiveOffset + chunkSize);
1172
1300
  session.updatedAt = Date.now();
1173
- const payload = { upload_id, bytes_received: session.bytes_received, temp_path: session.temp_path, stat };
1301
+ const payload = { upload_id, offset: effectiveOffset, part_size: chunkSize, bytes_received: session.bytes_received, parts_dir: session.parts_dir, stat };
1174
1302
  return textResult(JSON.stringify(payload, null, 2), payload);
1175
1303
  },
1176
1304
  );
@@ -1188,9 +1316,11 @@ function makeServer() {
1188
1316
  if (!session) {
1189
1317
  throw new Error(`unknown upload session: ${upload_id}`);
1190
1318
  }
1191
- const info = await renameAnyPath(session.temp_path, session.target, session.as_root);
1319
+ const merged = await mergeBinaryParts(session.parts_dir, session.merge_path, session.total_size, session.as_root);
1320
+ const info = await renameAnyPath(session.merge_path, session.target, session.as_root);
1321
+ await cleanupUploadSession(session);
1192
1322
  uploadSessions.delete(upload_id);
1193
- const payload = { upload_id, ok: true, ...info };
1323
+ const payload = { upload_id, ok: true, merged_parts: merged.merged_parts ?? session.parts.size, merged_bytes: merged.merged_bytes ?? session.bytes_received, ...info };
1194
1324
  return textResult(JSON.stringify(payload, null, 2), payload);
1195
1325
  },
1196
1326
  );
@@ -1208,7 +1338,7 @@ function makeServer() {
1208
1338
  if (!session) {
1209
1339
  throw new Error(`unknown upload session: ${upload_id}`);
1210
1340
  }
1211
- await deleteAnyPath(session.temp_path, false, session.as_root).catch(() => {});
1341
+ await cleanupUploadSession(session);
1212
1342
  uploadSessions.delete(upload_id);
1213
1343
  const payload = { upload_id, ok: true };
1214
1344
  return textResult(JSON.stringify(payload, null, 2), payload);
@@ -1241,21 +1371,27 @@ function makeServer() {
1241
1371
  server.registerTool(
1242
1372
  'download_chunked_part',
1243
1373
  {
1244
- description: 'Read the next chunk from a download session.',
1374
+ description: 'Read a chunk from a download session. If offset is provided, supports parallel range-style reads.',
1245
1375
  inputSchema: {
1246
1376
  download_id: z.string(),
1377
+ offset: z.number().int().nonnegative().optional(),
1378
+ chunk_bytes: z.number().int().positive().max(MAX_FILE_BYTES).optional(),
1247
1379
  },
1248
1380
  },
1249
- async ({ download_id }) => {
1381
+ async ({ download_id, offset, chunk_bytes }) => {
1250
1382
  const session = downloadSessions.get(download_id);
1251
1383
  if (!session) {
1252
1384
  throw new Error(`unknown download session: ${download_id}`);
1253
1385
  }
1254
- const payload = await readBinaryChunk(session.target, session.offset, session.chunk_bytes, session.as_root);
1255
- session.offset += payload.bytes_read;
1386
+ const effectiveOffset = offset ?? session.offset;
1387
+ const effectiveChunkBytes = chunk_bytes ?? session.chunk_bytes;
1388
+ const payload = await readBinaryChunk(session.target, effectiveOffset, effectiveChunkBytes, session.as_root);
1389
+ if (offset === undefined) {
1390
+ session.offset = effectiveOffset + payload.bytes_read;
1391
+ }
1256
1392
  session.updatedAt = Date.now();
1257
1393
  payload.download_id = download_id;
1258
- payload.next_offset = session.offset;
1394
+ payload.next_offset = effectiveOffset + payload.bytes_read;
1259
1395
  return textResult(JSON.stringify({ ...payload, content_base64: `[base64:${payload.bytes_read} bytes]` }, null, 2), payload);
1260
1396
  },
1261
1397
  );
@@ -1278,7 +1414,8 @@ function makeServer() {
1278
1414
  return server;
1279
1415
  }
1280
1416
 
1281
- const app = createMcpExpressApp({ host: HOST });
1417
+ const app = express();
1418
+ app.use(express.json({ limit: EXPRESS_JSON_LIMIT }));
1282
1419
  const transports = new Map();
1283
1420
  const servers = new Map();
1284
1421
 
@@ -1288,11 +1425,19 @@ app.get('/health', (_req, res) => {
1288
1425
  listen: `${HOST}:${PORT}${MCP_PATH}`,
1289
1426
  file_root: FILE_ROOT,
1290
1427
  transport_mode: MCP_TRANSPORT_MODE,
1428
+ max_request_body_bytes: MAX_REQUEST_BODY_BYTES,
1429
+ session_idle_ms: SESSION_IDLE_MS,
1430
+ logical_session_limits: {
1431
+ shell: null,
1432
+ upload: null,
1433
+ download: null,
1434
+ },
1291
1435
  script_version: INSTALLER_SCRIPT_VERSION,
1292
1436
  server_version: SERVER_VERSION,
1293
1437
  tools: SUPPORTED_TOOLS,
1294
1438
  supports: {
1295
1439
  chunked_transfers: true,
1440
+ parallel_chunk_offsets: true,
1296
1441
  rename_path: true,
1297
1442
  shell_session: true,
1298
1443
  },
@@ -1484,6 +1629,7 @@ install_node_modules() {
1484
1629
  run_as_root rm -rf "$INSTALL_ROOT/node_modules" "$INSTALL_ROOT/package-lock.json"
1485
1630
  run_as_root sh -c "cd '$INSTALL_ROOT' && npm install --omit=dev --no-fund --no-audit"
1486
1631
  run_as_root test -f "$INSTALL_ROOT/node_modules/@modelcontextprotocol/sdk/package.json" || die "npm 安装后缺少 @modelcontextprotocol/sdk"
1632
+ run_as_root test -f "$INSTALL_ROOT/node_modules/express/package.json" || die "npm 安装后缺少 express"
1487
1633
  run_as_root test -f "$INSTALL_ROOT/node_modules/zod/package.json" || die "npm 安装后缺少 zod"
1488
1634
  run_as_root chmod -R a+rX "$INSTALL_ROOT/node_modules"
1489
1635
  run_as_root sh -c "cd '$INSTALL_ROOT' && node -e \"import('./server.mjs').then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); })\"" || die "Node 运行时无法解析 MCP server"
@@ -1501,6 +1647,7 @@ FILE_ROOT=${FILE_ROOT}
1501
1647
  ENABLE_PASSWORDLESS_SUDO=${ENABLE_PASSWORDLESS_SUDO}
1502
1648
  MAX_FILE_MB=${MAX_FILE_MB}
1503
1649
  MAX_COMMAND_OUTPUT_KB=${MAX_COMMAND_OUTPUT_KB}
1650
+ MAX_REQUEST_BODY_MB=${MAX_REQUEST_BODY_MB}
1504
1651
  TLS_CERT_FILE=${TLS_CERT_FILE}
1505
1652
  TLS_KEY_FILE=${TLS_KEY_FILE}
1506
1653
  EOF
@@ -10,6 +10,11 @@ export declare class BianbuMcpConfigProvider extends ConfigProvider {
10
10
  minIntervalMs: number;
11
11
  maxRetries: number;
12
12
  retryBaseMs: number;
13
+ interactiveConcurrency: number;
14
+ transferConcurrency: number;
15
+ workerCadenceMs: number;
16
+ uploadChunkBytes: number;
17
+ downloadChunkBytes: number;
13
18
  notes: string;
14
19
  installerRemotePath: string;
15
20
  maintenanceAsRoot: boolean;
@@ -1,11 +1,11 @@
1
1
  import { Injector } from '@angular/core';
2
- import { BaseTabComponent, NotificationsService } from 'tabby-core';
2
+ import { BaseTabComponent, NotificationsService, PlatformService } from 'tabby-core';
3
3
  import { BianbuMcpService } from './mcp.service';
4
4
  interface TransferItem {
5
5
  id: number;
6
6
  name: string;
7
7
  direction: 'upload' | 'download';
8
- status: 'running' | 'done' | 'error' | 'cancelled';
8
+ status: 'queued' | 'running' | 'done' | 'error' | 'cancelled';
9
9
  bytesDone: number;
10
10
  bytesTotal: number | null;
11
11
  startedAt: number;
@@ -17,6 +17,7 @@ interface TransferItem {
17
17
  export declare class BianbuCloudFilesTabComponent extends BaseTabComponent {
18
18
  private mcp;
19
19
  private notifications;
20
+ private platform;
20
21
  currentPath: string;
21
22
  pathInput: string;
22
23
  asRoot: boolean;
@@ -45,7 +46,9 @@ export declare class BianbuCloudFilesTabComponent extends BaseTabComponent {
45
46
  transfersVisible: boolean;
46
47
  transfers: TransferItem[];
47
48
  private transferSeq;
48
- constructor(injector: Injector, mcp: BianbuMcpService, notifications: NotificationsService);
49
+ private transferQueue;
50
+ private transferQueueRunning;
51
+ constructor(injector: Injector, mcp: BianbuMcpService, notifications: NotificationsService, platform: PlatformService);
49
52
  ngOnInit(): void;
50
53
  get breadcrumbs(): string[];
51
54
  get selectedItem(): any | null;
@@ -84,6 +87,12 @@ export declare class BianbuCloudFilesTabComponent extends BaseTabComponent {
84
87
  onDragLeave(): void;
85
88
  onDrop(event: DragEvent): Promise<void>;
86
89
  downloadSelected(): Promise<void>;
90
+ private enqueueUpload;
91
+ private enqueueDownload;
92
+ private queueTransferJob;
93
+ private pumpTransferQueue;
94
+ private runUploadTransfer;
95
+ private runDownloadTransfer;
87
96
  private clearPreview;
88
97
  private baseName;
89
98
  private isTextLike;