@tdxvolt/volt-drive-mcp 0.0.0-stage → 0.1.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/README.md +289 -2
- package/bin/http.js +78 -0
- package/bin/stdio.js +72 -0
- package/package.json +55 -4
- package/src/blob.js +192 -0
- package/src/bytes.js +73 -0
- package/src/config.js +116 -0
- package/src/connect.js +43 -0
- package/src/doc-state.js +131 -0
- package/src/drive-index.js +370 -0
- package/src/drive-service.js +1096 -0
- package/src/drive-session.js +283 -0
- package/src/errors.js +23 -0
- package/src/http.js +229 -0
- package/src/index.js +4 -0
- package/src/logger.js +38 -0
- package/src/paths.js +145 -0
- package/src/server.js +338 -0
- package/src/sync-doc.js +268 -0
package/src/bytes.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalise a protobuf `bytes` value to a Uint8Array. The Volt gRPC client
|
|
5
|
+
* delivers Buffers, but other encodings (base64 strings from JSON based
|
|
6
|
+
* transports, plain arrays) are tolerated so callers do not have to care.
|
|
7
|
+
*/
|
|
8
|
+
export function toBytes(value) {
|
|
9
|
+
if (!value) {
|
|
10
|
+
return new Uint8Array();
|
|
11
|
+
}
|
|
12
|
+
if (value instanceof Uint8Array) {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return Uint8Array.from(value);
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
20
|
+
}
|
|
21
|
+
throw new Error("unexpected binary encoding");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function concatBytes(chunks) {
|
|
25
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
26
|
+
const bytes = new Uint8Array(total);
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
bytes.set(chunk, offset);
|
|
30
|
+
offset += chunk.length;
|
|
31
|
+
}
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* sha256 of the raw bytes as unpadded base64url. This is the content hash
|
|
37
|
+
* format used by the synapse blob store and by the native VoltDrive.
|
|
38
|
+
*/
|
|
39
|
+
export function blobHash(bytes) {
|
|
40
|
+
return createHash("sha256").update(bytes).digest("base64url");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Heuristic matching the Explorer and the native client: a file is binary if
|
|
45
|
+
* the first 8 KiB contain a NUL byte or are not valid UTF-8.
|
|
46
|
+
*/
|
|
47
|
+
export function looksBinary(bytes) {
|
|
48
|
+
const sample = bytes.subarray(0, Math.min(bytes.length, 8192));
|
|
49
|
+
for (const byte of sample) {
|
|
50
|
+
if (byte === 0) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
new TextDecoder("utf-8", { fatal: true }).decode(sample);
|
|
56
|
+
return false;
|
|
57
|
+
} catch {
|
|
58
|
+
// A multi-byte character may have been cut in half by the 8 KiB sample.
|
|
59
|
+
if (bytes.length > sample.length) {
|
|
60
|
+
for (let trim = 1; trim <= 3 && trim < sample.length; trim++) {
|
|
61
|
+
try {
|
|
62
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
63
|
+
sample.subarray(0, sample.length - trim)
|
|
64
|
+
);
|
|
65
|
+
return false;
|
|
66
|
+
} catch {
|
|
67
|
+
// keep trimming
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
const MIB = 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
const COMMON_OPTIONS = {
|
|
6
|
+
config: { type: "string", short: "c" },
|
|
7
|
+
"read-only": { type: "boolean" },
|
|
8
|
+
drive: { type: "string", multiple: true, short: "d" },
|
|
9
|
+
"max-file-bytes": { type: "string" },
|
|
10
|
+
"max-response-chars": { type: "string" },
|
|
11
|
+
help: { type: "boolean", short: "h" }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const HTTP_OPTIONS = {
|
|
15
|
+
host: { type: "string" },
|
|
16
|
+
port: { type: "string", short: "p" },
|
|
17
|
+
path: { type: "string" },
|
|
18
|
+
token: { type: "string" },
|
|
19
|
+
"allowed-host": { type: "string", multiple: true }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function parsePositiveInt(value, name) {
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
28
|
+
throw new Error(`--${name} must be a positive integer`);
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function envFlag(name) {
|
|
34
|
+
const value = process.env[name];
|
|
35
|
+
return value !== undefined && ["1", "true", "yes"].includes(value.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitList(value) {
|
|
39
|
+
return (value ?? "")
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((item) => item.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const COMMON_USAGE = `Options:
|
|
46
|
+
-c, --config <file> Volt client configuration (volt.config.json).
|
|
47
|
+
Env: VOLT_DRIVE_MCP_CONFIG. Default: ./volt.config.json
|
|
48
|
+
--read-only Do not offer any tool that changes a drive.
|
|
49
|
+
Env: VOLT_DRIVE_MCP_READ_ONLY=1
|
|
50
|
+
-d, --drive <id|name> Only expose this drive (repeatable).
|
|
51
|
+
Env: VOLT_DRIVE_MCP_DRIVES=a,b
|
|
52
|
+
--max-file-bytes <n> Largest file that can be read or written (default ${10 * MIB}).
|
|
53
|
+
--max-response-chars <n> Most characters returned by one read (default 100000).
|
|
54
|
+
-h, --help Show this help.
|
|
55
|
+
|
|
56
|
+
Logging goes to stderr; set VOLT_DRIVE_MCP_LOG=debug for more detail.`;
|
|
57
|
+
|
|
58
|
+
export const HTTP_USAGE = `HTTP options:
|
|
59
|
+
--host <addr> Address to listen on (default 127.0.0.1).
|
|
60
|
+
-p, --port <n> Port to listen on (default 3333).
|
|
61
|
+
--path <path> URL path of the MCP endpoint (default /mcp).
|
|
62
|
+
--token <secret> Bearer token clients must send. Env: VOLT_DRIVE_MCP_TOKEN
|
|
63
|
+
Required when listening on a non-loopback address.
|
|
64
|
+
--allowed-host <name> Extra Host header value to accept (repeatable), for
|
|
65
|
+
use behind a reverse proxy.`;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse command line arguments and environment into connector settings.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} argv
|
|
71
|
+
* @param {{ http?: boolean }} [mode]
|
|
72
|
+
*/
|
|
73
|
+
export function parseConfig(argv, { http = false } = {}) {
|
|
74
|
+
const { values } = parseArgs({
|
|
75
|
+
args: argv,
|
|
76
|
+
options: http ? { ...COMMON_OPTIONS, ...HTTP_OPTIONS } : COMMON_OPTIONS,
|
|
77
|
+
allowPositionals: false,
|
|
78
|
+
strict: true
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const drives = [
|
|
82
|
+
...splitList(process.env.VOLT_DRIVE_MCP_DRIVES),
|
|
83
|
+
...(values.drive ?? [])
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const config = {
|
|
87
|
+
help: Boolean(values.help),
|
|
88
|
+
configPath:
|
|
89
|
+
values.config ?? process.env.VOLT_DRIVE_MCP_CONFIG ?? "./volt.config.json",
|
|
90
|
+
options: {
|
|
91
|
+
readOnly: Boolean(values["read-only"]) || envFlag("VOLT_DRIVE_MCP_READ_ONLY"),
|
|
92
|
+
drives,
|
|
93
|
+
...(values["max-file-bytes"] !== undefined && {
|
|
94
|
+
maxFileBytes: parsePositiveInt(values["max-file-bytes"], "max-file-bytes")
|
|
95
|
+
}),
|
|
96
|
+
...(values["max-response-chars"] !== undefined && {
|
|
97
|
+
maxResponseChars: parsePositiveInt(
|
|
98
|
+
values["max-response-chars"],
|
|
99
|
+
"max-response-chars"
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (http) {
|
|
106
|
+
config.http = {
|
|
107
|
+
host: values.host ?? "127.0.0.1",
|
|
108
|
+
port: parsePositiveInt(values.port, "port") ?? 3333,
|
|
109
|
+
path: values.path ?? "/mcp",
|
|
110
|
+
token: values.token ?? process.env.VOLT_DRIVE_MCP_TOKEN ?? "",
|
|
111
|
+
allowedHosts: values["allowed-host"] ?? []
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return config;
|
|
116
|
+
}
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import grpc from "@grpc/grpc-js";
|
|
2
|
+
import { SyncProvider, VoltClient } from "@tdxvolt/volt-client-grpc";
|
|
3
|
+
import * as Y from "yjs";
|
|
4
|
+
import { DriveService } from "./drive-service.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a `DriveService` for the Volt described by `configPath`, without
|
|
8
|
+
* connecting yet. The connection is made on first use (call
|
|
9
|
+
* `service.ensureConnected()` to start it early) and is retried after a
|
|
10
|
+
* failure, so an MCP server can start and answer `tools/list` straight away
|
|
11
|
+
* even when the Volt is slow, down, or waiting for the identity to be approved.
|
|
12
|
+
*
|
|
13
|
+
* The configuration is the same `volt.config.json` the other Volt clients use
|
|
14
|
+
* and holds the identity this connector acts as: it can only reach the drives
|
|
15
|
+
* (and documents) that identity has been granted access to, so give it a
|
|
16
|
+
* dedicated identity with just the access you want an AI client to have.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} configPath
|
|
19
|
+
* @param {object} [settings]
|
|
20
|
+
* @param {Partial<import("./drive-service.js").DriveService["options"]>} [settings.options]
|
|
21
|
+
* @param {object} [settings.logger]
|
|
22
|
+
* @returns {DriveService}
|
|
23
|
+
*/
|
|
24
|
+
export function createDriveService(configPath, { options, logger } = {}) {
|
|
25
|
+
const client = new VoltClient(grpc);
|
|
26
|
+
|
|
27
|
+
const connect = async () => {
|
|
28
|
+
logger?.info?.(`connecting to the Volt using ${configPath}`);
|
|
29
|
+
await client.initialise(configPath);
|
|
30
|
+
logger?.info?.(
|
|
31
|
+
`connected to ${client.voltConfig?.display_name ?? client.voltConfig?.id}`
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return new DriveService({ client, Y, SyncProvider, connect, options, logger });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Like `createDriveService`, but resolves once the connection is up. */
|
|
39
|
+
export async function connectDriveService(configPath, settings) {
|
|
40
|
+
const service = createDriveService(configPath, settings);
|
|
41
|
+
await service.ensureConnected();
|
|
42
|
+
return service;
|
|
43
|
+
}
|
package/src/doc-state.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { DriveError, timeout as timeoutError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synapse metadata replicates ahead of content, so a drive can list a file
|
|
5
|
+
* whose bytes this Volt does not hold yet (most visibly just after cloning a
|
|
6
|
+
* large drive). Reading such a file would return an empty document or fail on
|
|
7
|
+
* a missing blob, so reads wait for the document to become ready.
|
|
8
|
+
*
|
|
9
|
+
* The state is checked once with `GetSynapseDocumentState`; only when the
|
|
10
|
+
* content is still in flight does the wait subscribe to
|
|
11
|
+
* `WatchSynapseDocumentState` to be told when it arrives.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DocumentState = {
|
|
15
|
+
UNKNOWN: "SYNAPSE_DOCUMENT_STATE_UNKNOWN",
|
|
16
|
+
ABSENT: "SYNAPSE_DOCUMENT_STATE_ABSENT",
|
|
17
|
+
DRAFT: "SYNAPSE_DOCUMENT_STATE_DRAFT",
|
|
18
|
+
ANNOUNCED: "SYNAPSE_DOCUMENT_STATE_ANNOUNCED",
|
|
19
|
+
READY: "SYNAPSE_DOCUMENT_STATE_READY",
|
|
20
|
+
DELETED: "SYNAPSE_DOCUMENT_STATE_DELETED"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const notYetSynced = () =>
|
|
24
|
+
timeoutError(
|
|
25
|
+
"the file's content has not finished syncing to this Volt yet; try again shortly"
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** What a state means for a reader: "wait", "gone" or "ready". */
|
|
29
|
+
function classify(state) {
|
|
30
|
+
if (state === DocumentState.ABSENT || state === DocumentState.DELETED) {
|
|
31
|
+
return "gone";
|
|
32
|
+
}
|
|
33
|
+
// `ready`, `draft` (an empty file) and any state this client does not
|
|
34
|
+
// understand can be read now.
|
|
35
|
+
return state === DocumentState.ANNOUNCED ? "wait" : "ready";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assertPresent(outcome) {
|
|
39
|
+
if (outcome === "gone") {
|
|
40
|
+
throw new DriveError("file is no longer in the drive", "not_found");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wait until a watch on `documentId` reports a state other than `announced`.
|
|
46
|
+
* Resolves with "ready" or "gone". If the watch cannot be used, resolves with
|
|
47
|
+
* "ready": reading anyway beats blocking on a signal that is not coming.
|
|
48
|
+
*/
|
|
49
|
+
function watchUntilSettled(client, synapseId, documentId, timeoutMs) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let call;
|
|
52
|
+
let finished = false;
|
|
53
|
+
let timer;
|
|
54
|
+
|
|
55
|
+
const finish = (outcome, error) => {
|
|
56
|
+
if (finished) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
finished = true;
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
try {
|
|
62
|
+
call?.write({ stop: true });
|
|
63
|
+
} catch {
|
|
64
|
+
// The call is already closed.
|
|
65
|
+
}
|
|
66
|
+
if (error) {
|
|
67
|
+
reject(error);
|
|
68
|
+
} else {
|
|
69
|
+
resolve(outcome);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
timer = setTimeout(() => finish(null, notYetSynced()), timeoutMs);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
call = client.WatchSynapseDocumentState({
|
|
77
|
+
start: { synapse_id: synapseId, document_id: [documentId] }
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
finish("ready");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
call.on("data", (payload) => {
|
|
85
|
+
const update = payload?.update;
|
|
86
|
+
if (!update || (update.document_id && update.document_id !== documentId)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const outcome = classify(update.state);
|
|
90
|
+
if (outcome !== "wait") {
|
|
91
|
+
finish(outcome);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
call.on("error", () => finish("ready"));
|
|
95
|
+
call.on("end", () => finish("ready"));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve once this Volt holds the document's content.
|
|
101
|
+
*
|
|
102
|
+
* - `announced`: the content is still in flight, wait for it.
|
|
103
|
+
* - `absent` / `deleted`: the file is gone, reject.
|
|
104
|
+
* - anything else can be read now.
|
|
105
|
+
*
|
|
106
|
+
* If the state cannot be queried at all the wait is skipped.
|
|
107
|
+
*/
|
|
108
|
+
export async function waitForDocumentReady(
|
|
109
|
+
client,
|
|
110
|
+
synapseId,
|
|
111
|
+
documentId,
|
|
112
|
+
{ timeoutMs = 30_000 } = {}
|
|
113
|
+
) {
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await client.GetSynapseDocumentState({
|
|
117
|
+
synapse_id: synapseId,
|
|
118
|
+
document_id: documentId
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const outcome = classify(response?.state);
|
|
125
|
+
assertPresent(outcome);
|
|
126
|
+
if (outcome === "ready") {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
assertPresent(await watchUntilSettled(client, synapseId, documentId, timeoutMs));
|
|
131
|
+
}
|