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 +14 -0
- package/README.md +8 -1
- package/assets/bianbu_agent_proxy.meta.json +3 -3
- package/assets/bianbu_agent_proxy.sh +181 -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 +1 -1
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
|
-
- `
|
|
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": "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
|
|
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
|
-
|
|
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
|
-
|
|
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 == '
|
|
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
|
-
|
|
503
|
-
with open(target, '
|
|
518
|
+
ensure_parent(target)
|
|
519
|
+
with open(target, 'wb') as fh:
|
|
504
520
|
fh.write(data)
|
|
505
521
|
out = stat_dict(target)
|
|
506
|
-
out['
|
|
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
|
-
|
|
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
|
|
766
|
+
async function writeBinaryPart(target, contentBase64, asRoot) {
|
|
708
767
|
if (asRoot) {
|
|
709
|
-
return runRootFileOp({ op: '
|
|
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.
|
|
717
|
-
|
|
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
|
|
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: '
|
|
1253
|
+
await runRootFileOp({ op: 'make_directory', path: parts_dir, parents: true });
|
|
1145
1254
|
} else {
|
|
1146
|
-
await fs.promises.mkdir(
|
|
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, {
|
|
1150
|
-
|
|
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: '
|
|
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
|
|
1170
|
-
const
|
|
1171
|
-
session.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1255
|
-
|
|
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 =
|
|
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 =
|
|
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
|
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;
|