tabby-bianbu-mcp 0.4.0 → 0.5.1

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