@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/README.md
CHANGED
|
@@ -1,3 +1,290 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tdxvolt/volt-drive-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that lets AI assistants (Cursor,
|
|
4
|
+
Claude Code, Claude Desktop, ...) browse, search and edit your **VoltDrive**
|
|
5
|
+
drives.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
AI client --MCP (stdio or HTTP)--> volt-drive-mcp --gRPC--> your Volt <--sync--> VoltDrive
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It connects to a Volt using your `volt.config.json`, so it acts as the identity
|
|
12
|
+
in that file and sees the drives that identity can access. Edits go through
|
|
13
|
+
Volt's sync layer, so they merge with concurrent edits and appear on every
|
|
14
|
+
device that has the drive.
|
|
15
|
+
|
|
16
|
+
**Requirements:** Node.js 20 or later, and a `volt.config.json` for a Volt that
|
|
17
|
+
can access the drive(s) you want to use.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### 1. Check it works
|
|
22
|
+
|
|
23
|
+
Run this once from a terminal (use the real path to your config):
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx -y @tdxvolt/volt-drive-mcp --config /absolute/path/to/volt.config.json --read-only
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You should see something like:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
[volt-drive-mcp] info: serving VoltDrive over stdio (read-only)
|
|
33
|
+
[volt-drive-mcp] info: connecting to the Volt using /absolute/path/to/volt.config.json
|
|
34
|
+
[volt-drive-mcp] info: connected to <your volt>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It then waits silently: it is an MCP server and speaks JSON-RPC on stdin/stdout,
|
|
38
|
+
so it isn't meant to be used interactively. Press Ctrl+C to stop it. To try the
|
|
39
|
+
tools by hand, see [Trying the tools](#trying-the-tools).
|
|
40
|
+
|
|
41
|
+
### 2. Add it to your AI client
|
|
42
|
+
|
|
43
|
+
Always use an **absolute path** for `--config`: clients start the server from a
|
|
44
|
+
directory you don't control, so relative paths won't resolve.
|
|
45
|
+
|
|
46
|
+
**Cursor**: add to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json`
|
|
47
|
+
(one project), then check *Settings → Tools & MCP* for a green dot:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"voltdrive": {
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": [
|
|
55
|
+
"-y", "@tdxvolt/volt-drive-mcp",
|
|
56
|
+
"--config", "/absolute/path/to/volt.config.json",
|
|
57
|
+
"--read-only"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Claude Code**:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
claude mcp add voltdrive -- npx -y @tdxvolt/volt-drive-mcp \
|
|
68
|
+
--config /absolute/path/to/volt.config.json --read-only
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Claude Desktop**: add the same `command` and `args` as the Cursor example under
|
|
72
|
+
`mcpServers` in `claude_desktop_config.json`, then restart the app.
|
|
73
|
+
|
|
74
|
+
### 3. Use it
|
|
75
|
+
|
|
76
|
+
Ask the assistant to use your drive in plain language. It calls the tools
|
|
77
|
+
itself, and most clients show each call and ask for approval first.
|
|
78
|
+
|
|
79
|
+
- "List my drives, then show me the top-level folders of *Project Files*."
|
|
80
|
+
- "Read `notes/meeting.md` from my VoltDrive and summarise the action items."
|
|
81
|
+
- "Search my drive for files that mention 'invoice'."
|
|
82
|
+
- With writes enabled: "Create `notes/summary.md` in my drive with a summary of
|
|
83
|
+
this conversation", or "Move `drafts/old.md` to `archive/old.md`".
|
|
84
|
+
|
|
85
|
+
Mention the drive ("on my VoltDrive", "in the *Project Files* drive") so the
|
|
86
|
+
assistant picks these tools rather than searching your local workspace.
|
|
87
|
+
|
|
88
|
+
### Enabling writes
|
|
89
|
+
|
|
90
|
+
The examples above use `--read-only`, which removes every tool that changes a
|
|
91
|
+
drive. Once you're happy, remove `--read-only` from `args` and restart the
|
|
92
|
+
server. Keep your client's per-call approval on for write tools, and see
|
|
93
|
+
[Safety](#safety-and-identity).
|
|
94
|
+
|
|
95
|
+
### Limiting which drives are visible
|
|
96
|
+
|
|
97
|
+
By default every drive the identity can see is available. To restrict it, add
|
|
98
|
+
`--drive` (repeatable) with a drive id, alias or name:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
"args": ["-y", "@tdxvolt/volt-drive-mcp", "--config", "/absolute/path/to/volt.config.json",
|
|
102
|
+
"--drive", "3bfde0b4-04c0-4620-8027-1b191b048503", "--read-only"]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Run the assistant's `list_drives` tool (or the command in
|
|
106
|
+
[Trying the tools](#trying-the-tools)) to find ids and names.
|
|
107
|
+
|
|
108
|
+
## Options
|
|
109
|
+
|
|
110
|
+
| Option | Environment variable | Meaning |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| `-c, --config <file>` | `VOLT_DRIVE_MCP_CONFIG` | Path to `volt.config.json`. Default `./volt.config.json` in the working directory. |
|
|
113
|
+
| `--read-only` | `VOLT_DRIVE_MCP_READ_ONLY=1` | Do not offer any tool that changes a drive. |
|
|
114
|
+
| `-d, --drive <id\|alias\|name>` | `VOLT_DRIVE_MCP_DRIVES=a,b` | Only expose these drives (repeatable). |
|
|
115
|
+
| `--max-file-bytes <n>` | | Largest file that can be read or written. Default 10 MiB. A drive's own maximum file size also applies if it is smaller. |
|
|
116
|
+
| `--max-response-chars <n>` | | Most characters one read returns. Default 100000. Longer files come back in pages. |
|
|
117
|
+
| `-h, --help` | | Show usage. |
|
|
118
|
+
|
|
119
|
+
Logs go to **stderr** only (stdout carries the protocol). Set
|
|
120
|
+
`VOLT_DRIVE_MCP_LOG=debug` for more detail. Your client's MCP log shows them.
|
|
121
|
+
|
|
122
|
+
The server connects to the Volt in the background. The client's handshake
|
|
123
|
+
completes immediately, and if the Volt can't be reached each tool call retries
|
|
124
|
+
and reports why.
|
|
125
|
+
|
|
126
|
+
## Tools
|
|
127
|
+
|
|
128
|
+
Every tool takes a `drive` argument (id, alias or name). Paths are relative to
|
|
129
|
+
the drive root, use `/`, and cannot contain `..`.
|
|
130
|
+
|
|
131
|
+
**Read tools** (always available):
|
|
132
|
+
|
|
133
|
+
| Tool | What it does |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `list_drives` | The drives this identity can see. Start here. |
|
|
136
|
+
| `get_drive_status` | File and folder counts, size, drive settings, sync state. |
|
|
137
|
+
| `list_directory` | Files and folders in a folder, optionally recursive. |
|
|
138
|
+
| `read_file` | A file's text, in pages for large files, or base64 bytes for binary files. |
|
|
139
|
+
| `search_files` | Find files by name or glob, and/or by text inside them. |
|
|
140
|
+
| `list_file_versions` | Saved versions of a file (needs file versions enabled on the drive). |
|
|
141
|
+
| `read_file_version` | A file's text as of a saved version. |
|
|
142
|
+
|
|
143
|
+
**Write tools** (omitted with `--read-only`):
|
|
144
|
+
|
|
145
|
+
| Tool | What it does |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `write_file` | Create a file, or replace one with `overwrite: true`. Text changes are applied as a minimal diff so they merge with concurrent edits. |
|
|
148
|
+
| `create_folder` | Create a folder and any missing parents. |
|
|
149
|
+
| `move_path` | Rename or move a file or folder. The destination must not exist. |
|
|
150
|
+
| `delete_path` | Delete a file, or a folder with `recursive: true`. |
|
|
151
|
+
| `create_file_version` | Save a named version of a file. |
|
|
152
|
+
| `revert_file_version` | Restore a file's content from a saved version. |
|
|
153
|
+
|
|
154
|
+
Behaviour worth knowing:
|
|
155
|
+
|
|
156
|
+
- **Large files** are returned in pieces of up to `--max-response-chars`. The
|
|
157
|
+
result says when it is truncated and gives the `offset` to continue from.
|
|
158
|
+
- **Binary files** are stored as blobs. `read_file` returns their metadata, or
|
|
159
|
+
the bytes with `encoding: "base64"`. Whether a drive accepts binary files is
|
|
160
|
+
its *include binary files* setting, which the connector respects: writing
|
|
161
|
+
binary data to a drive that ignores them fails with an explanation.
|
|
162
|
+
- **Files still syncing** to your Volt (for example just after cloning a large
|
|
163
|
+
drive) are waited for, and reported as not yet synced if they don't arrive,
|
|
164
|
+
rather than returned empty.
|
|
165
|
+
- **File versions** only work on drives with versioning enabled; otherwise the
|
|
166
|
+
tools say so.
|
|
167
|
+
- **Search** scans text files only. Binary and very large files are skipped and
|
|
168
|
+
counted in the result.
|
|
169
|
+
|
|
170
|
+
## Trying the tools
|
|
171
|
+
|
|
172
|
+
To exercise the server without an AI client:
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
# Web UI: pick a tool, fill in arguments, see the result
|
|
176
|
+
npx @modelcontextprotocol/inspector npx -y @tdxvolt/volt-drive-mcp -- \
|
|
177
|
+
--config /absolute/path/to/volt.config.json --read-only
|
|
178
|
+
|
|
179
|
+
# One call from the terminal, no browser
|
|
180
|
+
npx @modelcontextprotocol/inspector --cli --method tools/call --tool-name list_drives \
|
|
181
|
+
npx -y @tdxvolt/volt-drive-mcp -- --config /absolute/path/to/volt.config.json --read-only
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The Inspector has its own `--config` option, so the connector's flags must come
|
|
185
|
+
after the `--`. The Inspector's web UI prints a `http://127.0.0.1:6274?…` URL
|
|
186
|
+
to open; click **Connect**, then **Tools**.
|
|
187
|
+
|
|
188
|
+
## Streamable HTTP (shared service)
|
|
189
|
+
|
|
190
|
+
By default the AI client starts its own copy of the server over stdio, which is
|
|
191
|
+
simplest. To run one shared server that several clients connect to instead:
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
export VOLT_DRIVE_MCP_TOKEN="$(openssl rand -hex 32)"
|
|
195
|
+
npx -y -p @tdxvolt/volt-drive-mcp volt-drive-mcp-http \
|
|
196
|
+
--config /absolute/path/to/volt.config.json --read-only --port 3333
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Clients then use the URL and token (Cursor shown):
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"mcpServers": {
|
|
204
|
+
"voltdrive": {
|
|
205
|
+
"url": "http://127.0.0.1:3333/mcp",
|
|
206
|
+
"headers": { "Authorization": "Bearer <the token>" }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
| Option | Meaning |
|
|
213
|
+
| --- | --- |
|
|
214
|
+
| `--host <addr>` | Address to listen on. Default `127.0.0.1` (this machine only). |
|
|
215
|
+
| `-p, --port <n>` | Port. Default `3333`. |
|
|
216
|
+
| `--path <path>` | Endpoint path. Default `/mcp`. |
|
|
217
|
+
| `--token <secret>` / `VOLT_DRIVE_MCP_TOKEN` | Bearer token clients must send. **Required** when `--host` is not a loopback address. |
|
|
218
|
+
| `--allowed-host <name>` | Extra `Host` header value to accept (repeatable), for use behind a reverse proxy. |
|
|
219
|
+
|
|
220
|
+
All options above also apply (`--config`, `--read-only`, `--drive`, ...). The
|
|
221
|
+
HTTP server is stateless and shares one Volt connection between requests. It
|
|
222
|
+
accepts only `POST`, compares the token in constant time, and on loopback
|
|
223
|
+
rejects unexpected `Host` and `Origin` headers (DNS rebinding protection). It
|
|
224
|
+
does **not** provide TLS: put it behind a reverse proxy before exposing it
|
|
225
|
+
beyond your own machine. Everyone with the token shares the one Volt identity.
|
|
226
|
+
|
|
227
|
+
## Safety and identity
|
|
228
|
+
|
|
229
|
+
- **The connector acts as the identity in `volt.config.json`.** Anything that
|
|
230
|
+
identity can do in a drive, the AI client can do through the connector.
|
|
231
|
+
Prefer a dedicated identity that has only the drives you want to expose, and
|
|
232
|
+
use `--read-only` and `--drive` to narrow things further. Volt's own access
|
|
233
|
+
policy still applies: permission failures come back as tool errors.
|
|
234
|
+
- Write tools carry MCP annotations (`readOnlyHint`, `destructiveHint`,
|
|
235
|
+
`idempotentHint`) so clients can ask before running them. `delete_path` won't
|
|
236
|
+
remove a non-empty folder unless `recursive: true`, and `write_file` won't
|
|
237
|
+
replace a file unless `overwrite: true`. Changes sync to everyone who has the
|
|
238
|
+
drive; use `create_file_version` first if you may want to roll back.
|
|
239
|
+
- File contents are untrusted input to the assistant: a document can contain
|
|
240
|
+
instructions aimed at it. Only grant write access to drives you trust.
|
|
241
|
+
- Never put the token or `volt.config.json` in a shared or committed
|
|
242
|
+
configuration file.
|
|
243
|
+
|
|
244
|
+
## Troubleshooting
|
|
245
|
+
|
|
246
|
+
| Symptom | Likely cause and fix |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| Server shows red / "failed to start" in the client | Run the command from [step 1](#1-check-it-works) in a terminal to see the error. Check Node is 20+ (`node -v`) and `npx` is on the PATH the client uses. |
|
|
249
|
+
| Tool calls fail with "could not connect to the Volt: …" | The config path is wrong or relative, or the Volt isn't running or reachable. The message includes the underlying reason; the server retries on the next call. |
|
|
250
|
+
| `list_drives` returns no drives | The identity in the config has no access to any drive on that Volt, or `--drive` doesn't match any of them. |
|
|
251
|
+
| "File versions are disabled on this drive" | Turn on file versions in the drive's settings. |
|
|
252
|
+
| "this drive ignores binary files" | Enable *include binary files* in the drive's settings, or write text only. |
|
|
253
|
+
| "not finished syncing to this Volt yet" | The file's content hasn't arrived from the other devices. Try again shortly. |
|
|
254
|
+
| `permission denied` style errors on write | The identity can read the drive but not write it. |
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
git clone https://github.com/tdxvolt/tdxvolt-volt-drive-mcp
|
|
260
|
+
cd tdxvolt-volt-drive-mcp
|
|
261
|
+
npm install
|
|
262
|
+
npm test # unit and protocol tests, no Volt needed
|
|
263
|
+
npm run inspect -- --config /path/to/volt.config.json --read-only # MCP Inspector
|
|
264
|
+
node bin/stdio.js --config /path/to/volt.config.json # run directly
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`npm test` runs the whole stack (MCP client, server, drive logic, sync) against
|
|
268
|
+
an in-memory fake Volt in `test/helpers/fake-volt.js`.
|
|
269
|
+
|
|
270
|
+
`scripts/e2e.js` checks the real thing. It creates a throwaway drive on a
|
|
271
|
+
running Volt, drives every tool through a spawned stdio server, then deletes
|
|
272
|
+
the drive:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
npm run e2e -- --config /path/to/volt.config.json
|
|
276
|
+
# --keep keeps the drive afterwards; --no-binary skips the binary-file step
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Layout: `src/drive-service.js` holds the transport-independent logic,
|
|
280
|
+
`src/server.js` defines the MCP tools on top of it, and `bin/stdio.js` and
|
|
281
|
+
`bin/http.js` (with `src/http.js`) are the two entry points.
|
|
282
|
+
|
|
283
|
+
### Data model
|
|
284
|
+
|
|
285
|
+
A drive is a Volt synapse of kind `volt:drive-database`. Its `__meta` document
|
|
286
|
+
has an `idx` map of `documentId -> { json: { name, s }, __hash, __schema,
|
|
287
|
+
__timestamp }`; folders live in a `folders` document. Text files are a Yjs
|
|
288
|
+
`content` text per document; binary files are blobs addressed by SHA-256.
|
|
289
|
+
Metadata is written with `SetSynapseDocumentMetadata`, which must restate the
|
|
290
|
+
document's schema on every call, even for a rename.
|
package/bin/http.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serve VoltDrive to MCP clients over Streamable HTTP. Unlike the stdio
|
|
5
|
+
* entry point this runs as a long-lived service that several clients can
|
|
6
|
+
* share; see the README for the security notes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { COMMON_USAGE, HTTP_USAGE, parseConfig } from "../src/config.js";
|
|
10
|
+
import { createDriveService } from "../src/connect.js";
|
|
11
|
+
import { startHttpServer } from "../src/http.js";
|
|
12
|
+
import { createLogger, redirectConsoleToStderr } from "../src/logger.js";
|
|
13
|
+
|
|
14
|
+
redirectConsoleToStderr();
|
|
15
|
+
const logger = createLogger();
|
|
16
|
+
|
|
17
|
+
const usage = `Usage: volt-drive-mcp-http [options]\n\n${COMMON_USAGE}\n\n${HTTP_USAGE}\n`;
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = parseConfig(process.argv.slice(2), { http: true });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
logger.error(err.message);
|
|
25
|
+
process.stderr.write(`\n${usage}`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (config.help) {
|
|
30
|
+
process.stderr.write(usage);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const service = createDriveService(config.configPath, {
|
|
35
|
+
options: config.options,
|
|
36
|
+
logger
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let started;
|
|
40
|
+
try {
|
|
41
|
+
started = await startHttpServer(service, config.http, { logger });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logger.error(err.message);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { httpServer, port, settings } = started;
|
|
48
|
+
logger.info(
|
|
49
|
+
`serving VoltDrive at http://${settings.host}:${port}${settings.path}` +
|
|
50
|
+
`${service.readOnly ? " (read-only)" : ""}` +
|
|
51
|
+
`${settings.token ? " (bearer token required)" : " (no token: loopback only)"}`
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
service.ensureConnected().catch((err) => logger.error(err.message));
|
|
55
|
+
|
|
56
|
+
let shuttingDown = false;
|
|
57
|
+
const shutdown = async (signal) => {
|
|
58
|
+
if (shuttingDown) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
shuttingDown = true;
|
|
62
|
+
logger.info(`shutting down (${signal})`);
|
|
63
|
+
httpServer.close();
|
|
64
|
+
httpServer.closeAllConnections?.();
|
|
65
|
+
try {
|
|
66
|
+
await service.close();
|
|
67
|
+
} finally {
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
72
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch((err) => {
|
|
76
|
+
logger.error(err?.stack ?? String(err));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
});
|
package/bin/stdio.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serve VoltDrive to an MCP client over stdio. The client (Cursor, Claude
|
|
5
|
+
* Desktop, ...) spawns this process and talks to it over stdin/stdout, so
|
|
6
|
+
* nothing else may ever write to stdout.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { COMMON_USAGE, parseConfig } from "../src/config.js";
|
|
11
|
+
import { createDriveService } from "../src/connect.js";
|
|
12
|
+
import { createLogger, redirectConsoleToStderr } from "../src/logger.js";
|
|
13
|
+
import { createServer } from "../src/server.js";
|
|
14
|
+
|
|
15
|
+
redirectConsoleToStderr();
|
|
16
|
+
const logger = createLogger();
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
let config;
|
|
20
|
+
try {
|
|
21
|
+
config = parseConfig(process.argv.slice(2));
|
|
22
|
+
} catch (err) {
|
|
23
|
+
logger.error(err.message);
|
|
24
|
+
process.stderr.write(`\nUsage: volt-drive-mcp [options]\n\n${COMMON_USAGE}\n`);
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (config.help) {
|
|
29
|
+
process.stderr.write(`Usage: volt-drive-mcp [options]\n\n${COMMON_USAGE}\n`);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const service = createDriveService(config.configPath, {
|
|
34
|
+
options: config.options,
|
|
35
|
+
logger
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const server = createServer(service, { logger });
|
|
39
|
+
await server.connect(new StdioServerTransport());
|
|
40
|
+
logger.info(
|
|
41
|
+
`serving VoltDrive over stdio${service.readOnly ? " (read-only)" : ""}`
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Connect to the Volt in the background: the MCP handshake must not wait for
|
|
45
|
+
// it. If it fails here, each tool call will retry and report the reason.
|
|
46
|
+
service.ensureConnected().catch((err) => logger.error(err.message));
|
|
47
|
+
|
|
48
|
+
let shuttingDown = false;
|
|
49
|
+
const shutdown = async (signal) => {
|
|
50
|
+
if (shuttingDown) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
shuttingDown = true;
|
|
54
|
+
logger.info(`shutting down (${signal})`);
|
|
55
|
+
try {
|
|
56
|
+
await server.close();
|
|
57
|
+
await service.close();
|
|
58
|
+
} finally {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
64
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
65
|
+
// The client closing our stdin is the normal way a stdio server is stopped.
|
|
66
|
+
process.stdin.on("end", () => shutdown("stdin closed"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((err) => {
|
|
70
|
+
logger.error(err?.stack ?? String(err));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tdxvolt/volt-drive-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol (MCP) server that exposes VoltDrive drives to AI clients",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"voltdrive",
|
|
9
|
+
"tdxvolt",
|
|
10
|
+
"volt"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/tdxvolt/tdxvolt-volt-drive-mcp.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/tdxvolt/tdxvolt-volt-drive-mcp/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/tdxvolt/tdxvolt-volt-drive-mcp#readme",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./src/index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"volt-drive-mcp": "bin/stdio.js",
|
|
27
|
+
"volt-drive-mcp-http": "bin/http.js"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/index.js",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"start": "node ./bin/stdio.js",
|
|
39
|
+
"start:http": "node ./bin/http.js",
|
|
40
|
+
"inspect": "npx @modelcontextprotocol/inspector node ./bin/stdio.js --",
|
|
41
|
+
"test": "node --test \"test/*.test.js\"",
|
|
42
|
+
"e2e": "node ./scripts/e2e.js",
|
|
43
|
+
"prepublishOnly": "npm test"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"author": "toby.ealden@gmail.com",
|
|
49
|
+
"license": "ISC",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@grpc/grpc-js": "^1.12.6",
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
53
|
+
"@tdxvolt/volt-client-grpc": "^0.20.36",
|
|
54
|
+
"yjs": "^13.6.18",
|
|
55
|
+
"zod": "^3.25.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/blob.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { concatBytes, toBytes } from "./bytes.js";
|
|
2
|
+
import { DriveError, timeout as timeoutError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
/** Match the native client's file transfer block size. */
|
|
5
|
+
const BLOB_CHUNK_BYTES = 512 * 1024;
|
|
6
|
+
|
|
7
|
+
/** Abort a transfer when nothing has happened for this long. */
|
|
8
|
+
const STALL_TIMEOUT_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
11
|
+
|
|
12
|
+
function toError(err) {
|
|
13
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function statusMessage(response) {
|
|
17
|
+
const message = response?.status?.message;
|
|
18
|
+
return message ? String(message) : "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the synapse blob store already holds `hash`.
|
|
23
|
+
*
|
|
24
|
+
* Passing `documentId` authorises the call against that one document, so an
|
|
25
|
+
* identity holding access to just that document can use its blob.
|
|
26
|
+
*/
|
|
27
|
+
export async function hasBlob(client, synapseId, hash, documentId) {
|
|
28
|
+
const response = await client.HasSynapseBlob({
|
|
29
|
+
synapse_id: synapseId,
|
|
30
|
+
hash,
|
|
31
|
+
...(documentId ? { document_id: documentId } : {})
|
|
32
|
+
});
|
|
33
|
+
return Boolean(response?.exists);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Upload `bytes` to the blob store (streamed in blocks). */
|
|
37
|
+
export async function putBlob(client, synapseId, hash, bytes, documentId) {
|
|
38
|
+
if (bytes.byteLength === 0) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const call = client.PutSynapseBlob({
|
|
43
|
+
start: {
|
|
44
|
+
synapse_id: synapseId,
|
|
45
|
+
hash,
|
|
46
|
+
size: bytes.byteLength,
|
|
47
|
+
...(documentId ? { document_id: documentId } : {})
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
let settled = false;
|
|
53
|
+
let stallTimer = null;
|
|
54
|
+
|
|
55
|
+
const finish = (err) => {
|
|
56
|
+
if (settled) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
settled = true;
|
|
60
|
+
clearTimeout(stallTimer);
|
|
61
|
+
if (err) {
|
|
62
|
+
reject(err);
|
|
63
|
+
} else {
|
|
64
|
+
resolve();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const armStall = () => {
|
|
69
|
+
clearTimeout(stallTimer);
|
|
70
|
+
stallTimer = setTimeout(
|
|
71
|
+
() => finish(timeoutError("blob upload stalled")),
|
|
72
|
+
STALL_TIMEOUT_MS
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
call.on("error", (err) => finish(toError(err)));
|
|
77
|
+
call.on("data", (payload) => {
|
|
78
|
+
const message = statusMessage(payload);
|
|
79
|
+
if (message) {
|
|
80
|
+
finish(new DriveError(message));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
call.on("end", () => finish());
|
|
84
|
+
|
|
85
|
+
armStall();
|
|
86
|
+
|
|
87
|
+
(async () => {
|
|
88
|
+
// The initial `start` message is written on the next tick, so let it go
|
|
89
|
+
// out before any data blocks.
|
|
90
|
+
await tick();
|
|
91
|
+
for (let offset = 0; offset < bytes.byteLength; offset += BLOB_CHUNK_BYTES) {
|
|
92
|
+
if (settled) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
call.write({
|
|
96
|
+
block: Buffer.from(
|
|
97
|
+
bytes.buffer,
|
|
98
|
+
bytes.byteOffset + offset,
|
|
99
|
+
Math.min(BLOB_CHUNK_BYTES, bytes.byteLength - offset)
|
|
100
|
+
)
|
|
101
|
+
});
|
|
102
|
+
armStall();
|
|
103
|
+
await tick();
|
|
104
|
+
}
|
|
105
|
+
call.end();
|
|
106
|
+
})().catch((err) => finish(toError(err)));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Upload `bytes` unless a blob with this hash already exists. */
|
|
111
|
+
export async function ensureBlob(client, synapseId, hash, bytes, documentId) {
|
|
112
|
+
if (bytes.byteLength === 0) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (await hasBlob(client, synapseId, hash, documentId)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await putBlob(client, synapseId, hash, bytes, documentId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Download the blob `hash`. */
|
|
122
|
+
export function getBlob(client, synapseId, hash, documentId, { maxBytes } = {}) {
|
|
123
|
+
if (!hash) {
|
|
124
|
+
return Promise.resolve(new Uint8Array());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const call = client.GetSynapseBlob({
|
|
128
|
+
synapse_id: synapseId,
|
|
129
|
+
hash,
|
|
130
|
+
...(documentId ? { document_id: documentId } : {})
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const chunks = [];
|
|
135
|
+
let received = 0;
|
|
136
|
+
let settled = false;
|
|
137
|
+
let stallTimer = null;
|
|
138
|
+
|
|
139
|
+
const finish = (err, bytes) => {
|
|
140
|
+
if (settled) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
settled = true;
|
|
144
|
+
clearTimeout(stallTimer);
|
|
145
|
+
if (err) {
|
|
146
|
+
try {
|
|
147
|
+
call.end();
|
|
148
|
+
} catch {
|
|
149
|
+
// stream already closed
|
|
150
|
+
}
|
|
151
|
+
reject(err);
|
|
152
|
+
} else {
|
|
153
|
+
resolve(bytes);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const armStall = () => {
|
|
158
|
+
clearTimeout(stallTimer);
|
|
159
|
+
stallTimer = setTimeout(
|
|
160
|
+
() => finish(timeoutError(`file transfer stalled after ${received} bytes`)),
|
|
161
|
+
STALL_TIMEOUT_MS
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
call.on("error", (err) => finish(toError(err)));
|
|
166
|
+
call.on("data", (payload) => {
|
|
167
|
+
const message = statusMessage(payload);
|
|
168
|
+
if (message) {
|
|
169
|
+
finish(new DriveError(message));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (payload?.block && payload.block.length !== 0) {
|
|
173
|
+
try {
|
|
174
|
+
const block = toBytes(payload.block);
|
|
175
|
+
chunks.push(block);
|
|
176
|
+
received += block.length;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
finish(toError(err));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (maxBytes && received > maxBytes) {
|
|
182
|
+
finish(new DriveError(`file exceeds the ${maxBytes} byte limit`, "too_large"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
armStall();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
call.on("end", () => finish(null, concatBytes(chunks)));
|
|
189
|
+
|
|
190
|
+
armStall();
|
|
191
|
+
});
|
|
192
|
+
}
|