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 +38 -19
- package/README.md +8 -1
- package/assets/bianbu_agent_proxy.meta.json +3 -3
- package/assets/bianbu_agent_proxy.sh +182 -34
- package/dist/configProvider.d.ts +5 -0
- package/dist/filesTab.component.d.ts +12 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.service.d.ts +12 -7
- package/dist/remoteRelease.d.ts +1 -0
- package/package.json +67 -67
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.
|
|
6
|
-
|
|
7
|
-
###
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
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
|
-
- `
|
|
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": "
|
|
7
|
-
"bytes":
|
|
8
|
-
"generatedAt": "2026-03-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 == '
|
|
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
|
-
|
|
503
|
-
with open(target, '
|
|
519
|
+
ensure_parent(target)
|
|
520
|
+
with open(target, 'wb') as fh:
|
|
504
521
|
fh.write(data)
|
|
505
522
|
out = stat_dict(target)
|
|
506
|
-
out['
|
|
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
|
-
|
|
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
|
|
767
|
+
async function writeBinaryPart(target, contentBase64, asRoot) {
|
|
708
768
|
if (asRoot) {
|
|
709
|
-
return runRootFileOp({ op: '
|
|
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.
|
|
717
|
-
|
|
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
|
|
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: '
|
|
1254
|
+
await runRootFileOp({ op: 'make_directory', path: parts_dir, parents: true });
|
|
1145
1255
|
} else {
|
|
1146
|
-
await fs.promises.mkdir(
|
|
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, {
|
|
1150
|
-
|
|
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: '
|
|
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
|
|
1170
|
-
const
|
|
1171
|
-
session.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1255
|
-
|
|
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 =
|
|
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 =
|
|
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
|
package/dist/configProvider.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|