@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.
@@ -0,0 +1,283 @@
1
+ import { DriveError, timeout as timeoutError } from "./errors.js";
2
+ import {
3
+ FOLDERS_BRANCH,
4
+ FOLDERS_DOCUMENT_ID,
5
+ FOLDERS_STATE_BRANCH,
6
+ IDX_BRANCH,
7
+ META_DOCUMENT_ID,
8
+ readDriveEntries,
9
+ readDriveFolders
10
+ } from "./drive-index.js";
11
+ import { createProvider, startAndWaitForSync, stopAndWait } from "./sync-doc.js";
12
+
13
+ const LEGACY_IMPORTED_KEY = "legacyImported";
14
+
15
+ /**
16
+ * A live, synced view of one drive's index.
17
+ *
18
+ * Opening a session starts two long lived Volt sync streams: the `__meta`
19
+ * document (the file index, read only for clients) and the `folders` document
20
+ * (explicit folders, writable). Both are kept up to date by the Volt, and the
21
+ * derived file and folder lists are recomputed lazily when they change.
22
+ */
23
+ export class DriveSession {
24
+ /**
25
+ * @param {object} ctx - { Y, SyncProvider, client, logger }
26
+ * @param {object} resource - the drive resource as returned by the Volt.
27
+ * @param {object} options
28
+ * @param {boolean} options.readOnly - never create or write the folders document.
29
+ * @param {number} options.syncTimeoutMs
30
+ */
31
+ constructor(ctx, resource, { readOnly = false, syncTimeoutMs = 30_000 } = {}) {
32
+ this.ctx = ctx;
33
+ this.resource = resource;
34
+ this.readOnly = readOnly;
35
+ this.syncTimeoutMs = syncTimeoutMs;
36
+
37
+ this.metaDoc = null;
38
+ this.metaProvider = null;
39
+ this.foldersDoc = null;
40
+ this.foldersProvider = null;
41
+ this.foldersMap = null;
42
+ this.foldersWritable = false;
43
+
44
+ this._opening = null;
45
+ this._metaSynced = false;
46
+ this._dirty = true;
47
+ this._entries = [];
48
+ this._folders = [];
49
+ this._waiters = new Set();
50
+ this._writeChain = Promise.resolve();
51
+ }
52
+
53
+ get id() {
54
+ return this.resource.id;
55
+ }
56
+
57
+ get metaSynced() {
58
+ return this._metaSynced;
59
+ }
60
+
61
+ /** Open the session (idempotent; concurrent callers share one attempt). */
62
+ open() {
63
+ if (!this._opening) {
64
+ this._opening = this._open().catch((err) => {
65
+ // Allow a later call to try again.
66
+ this._opening = null;
67
+ throw err;
68
+ });
69
+ }
70
+ return this._opening;
71
+ }
72
+
73
+ async _open() {
74
+ const { Y } = this.ctx;
75
+
76
+ this.metaDoc = new Y.Doc();
77
+ this.metaDoc.guid = META_DOCUMENT_ID;
78
+ this.metaProvider = createProvider(this.ctx, this.id, this.metaDoc);
79
+ this.metaProvider.on("sync", (synced) => {
80
+ this._metaSynced = Boolean(synced);
81
+ });
82
+
83
+ await startAndWaitForSync(
84
+ this.metaProvider,
85
+ this.syncTimeoutMs,
86
+ `drive ${this.id} metadata`
87
+ );
88
+
89
+ const idx = this.metaDoc.getMap(IDX_BRANCH);
90
+ idx.observeDeep(() => this._changed());
91
+
92
+ await this._openFolders(idx);
93
+
94
+ this._changed();
95
+ }
96
+
97
+ async _openFolders(idx) {
98
+ const { Y, client, logger } = this.ctx;
99
+
100
+ if (!idx.has(FOLDERS_DOCUMENT_ID) && !this.readOnly) {
101
+ // The folders document is created on first use.
102
+ try {
103
+ await client.SetSynapseDocumentMetadata({
104
+ synapse_id: this.id,
105
+ document_id: FOLDERS_DOCUMENT_ID,
106
+ metadata: [
107
+ { name: FOLDERS_BRANCH, type: "map" },
108
+ { name: FOLDERS_STATE_BRANCH, type: "map" }
109
+ ],
110
+ publish_hash: true
111
+ });
112
+ } catch (err) {
113
+ logger?.debug?.(`could not create folders document: ${err.message}`);
114
+ }
115
+ }
116
+
117
+ this.foldersDoc = new Y.Doc();
118
+ this.foldersDoc.guid = FOLDERS_DOCUMENT_ID;
119
+ const provider = createProvider(this.ctx, this.id, this.foldersDoc);
120
+
121
+ try {
122
+ await startAndWaitForSync(provider, Math.min(this.syncTimeoutMs, 10_000), "folders");
123
+ } catch (err) {
124
+ // No folders document for this identity (or none yet): show the folders
125
+ // recorded by earlier versions in `__meta`, read only.
126
+ logger?.debug?.(`folders document unavailable: ${err.message}`);
127
+ this.foldersDoc = null;
128
+ this.foldersMap = this.metaDoc.getMap(FOLDERS_BRANCH);
129
+ this.foldersMap.observeDeep(() => this._changed());
130
+ this.foldersWritable = false;
131
+ return;
132
+ }
133
+
134
+ this.foldersProvider = provider;
135
+ this.foldersMap = this.foldersDoc.getMap(FOLDERS_BRANCH);
136
+ this.foldersWritable = !provider.isReadOnly && !this.readOnly;
137
+
138
+ if (this.foldersWritable) {
139
+ const state = this.foldersDoc.getMap(FOLDERS_STATE_BRANCH);
140
+ if (!state.has(LEGACY_IMPORTED_KEY)) {
141
+ // One-off: carry over folders recorded in `__meta` by earlier
142
+ // versions. Idempotent, so concurrent importers are harmless.
143
+ const legacy = this.metaDoc.getMap(FOLDERS_BRANCH);
144
+ this.foldersDoc.transact(() => {
145
+ for (const path of legacy.keys()) {
146
+ if (path && !this.foldersMap.has(path)) {
147
+ this.foldersMap.set(path, true);
148
+ }
149
+ }
150
+ state.set(LEGACY_IMPORTED_KEY, true);
151
+ });
152
+ }
153
+ }
154
+
155
+ this.foldersMap.observeDeep(() => this._changed());
156
+ }
157
+
158
+ _changed() {
159
+ this._dirty = true;
160
+ for (const waiter of [...this._waiters]) {
161
+ waiter();
162
+ }
163
+ }
164
+
165
+ _refresh() {
166
+ if (!this._dirty || !this.metaDoc) {
167
+ return;
168
+ }
169
+ this._entries = readDriveEntries(this.metaDoc.getMap(IDX_BRANCH));
170
+ this._folders = this.foldersMap ? readDriveFolders(this.foldersMap) : [];
171
+ this._dirty = false;
172
+ }
173
+
174
+ /** Make sure the session is open and its metadata stream is live. */
175
+ async ensureReady() {
176
+ await this.open();
177
+ if (!this.metaSynced) {
178
+ // The stream dropped (for example the Volt connection was reset).
179
+ await startAndWaitForSync(
180
+ this.metaProvider,
181
+ this.syncTimeoutMs,
182
+ `drive ${this.id} metadata`
183
+ );
184
+ this._changed();
185
+ }
186
+ }
187
+
188
+ /** @returns {import("./drive-index.js").DriveEntry[]} */
189
+ get entries() {
190
+ this._refresh();
191
+ return this._entries;
192
+ }
193
+
194
+ /** @returns {string[]} */
195
+ get folders() {
196
+ this._refresh();
197
+ return this._folders;
198
+ }
199
+
200
+ /**
201
+ * Resolve once `predicate()` is true. The index is updated asynchronously by
202
+ * the Volt after a write, so callers use this to read their own writes.
203
+ */
204
+ waitFor(predicate, timeoutMs = 10_000, what = "the drive index to update") {
205
+ if (predicate()) {
206
+ return Promise.resolve();
207
+ }
208
+ return new Promise((resolve, reject) => {
209
+ const timer = setTimeout(() => {
210
+ this._waiters.delete(check);
211
+ reject(timeoutError(`timed out waiting for ${what}`));
212
+ }, timeoutMs);
213
+ const check = () => {
214
+ if (predicate()) {
215
+ clearTimeout(timer);
216
+ this._waiters.delete(check);
217
+ resolve();
218
+ }
219
+ };
220
+ this._waiters.add(check);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Run `fn` with exclusive access to this drive's write path so concurrent tool
226
+ * calls cannot interleave their check-then-write sequences.
227
+ */
228
+ exclusive(fn) {
229
+ const run = this._writeChain.then(fn, fn);
230
+ this._writeChain = run.catch(() => {});
231
+ return run;
232
+ }
233
+
234
+ /** Mutate the folders map in one transaction and wait for it to be sent. */
235
+ async mutateFolders(fn) {
236
+ if (!this.foldersWritable) {
237
+ throw new DriveError(
238
+ "the folders list of this drive is read-only for the current identity",
239
+ "forbidden"
240
+ );
241
+ }
242
+ this.foldersDoc.transact(() => fn(this.foldersMap));
243
+ await this.foldersProvider.whenOutboundDrained();
244
+ }
245
+
246
+ /** Add `paths` (and nothing else) to the explicit folders list. */
247
+ async addFolders(paths) {
248
+ if (!this.foldersWritable) {
249
+ // Folders are implied by the files they contain, so this only matters
250
+ // for empty folders; a read only folders list is not an error here.
251
+ return false;
252
+ }
253
+ await this.mutateFolders((map) => {
254
+ for (const path of paths) {
255
+ if (path && !map.has(path)) {
256
+ map.set(path, true);
257
+ }
258
+ }
259
+ });
260
+ return true;
261
+ }
262
+
263
+ /** Remove `paths` from the explicit folders list. */
264
+ async removeFolders(paths) {
265
+ if (!this.foldersWritable) {
266
+ return false;
267
+ }
268
+ await this.mutateFolders((map) => {
269
+ for (const path of paths) {
270
+ map.delete(path);
271
+ }
272
+ });
273
+ return true;
274
+ }
275
+
276
+ async close() {
277
+ const providers = [this.foldersProvider, this.metaProvider].filter(Boolean);
278
+ this.foldersProvider = null;
279
+ this.metaProvider = null;
280
+ this._opening = null;
281
+ await Promise.all(providers.map((provider) => stopAndWait(provider, 3_000)));
282
+ }
283
+ }
package/src/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * An error whose message is safe and useful to show to the model. The MCP layer
3
+ * converts these into tool results with `isError: true` so the model can read
4
+ * the message and correct its call, rather than failing the protocol request.
5
+ */
6
+ export class DriveError extends Error {
7
+ /**
8
+ * @param {string} message
9
+ * @param {string} [code] - short machine readable code, e.g. "not_found".
10
+ */
11
+ constructor(message, code = "error") {
12
+ super(message);
13
+ this.name = "DriveError";
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ export const notFound = (message) => new DriveError(message, "not_found");
19
+ export const invalid = (message) => new DriveError(message, "invalid");
20
+ export const conflict = (message) => new DriveError(message, "conflict");
21
+ export const forbidden = (message) => new DriveError(message, "forbidden");
22
+ export const tooLarge = (message) => new DriveError(message, "too_large");
23
+ export const timeout = (message) => new DriveError(message, "timeout");
package/src/http.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Streamable HTTP front end for the connector.
3
+ *
4
+ * The server is stateless: every POST gets its own `McpServer` and transport,
5
+ * all sharing one `DriveService`, so the Volt connection and the open drive
6
+ * sessions are reused across requests and clients. There are no MCP sessions
7
+ * to track and nothing to clean up when a client disappears.
8
+ *
9
+ * Because this is a network service in front of someone's files it defends
10
+ * itself: it binds to loopback by default, requires a bearer token (always,
11
+ * unless bound to loopback with none configured), and checks the Host and
12
+ * Origin headers so a web page cannot reach it through DNS rebinding.
13
+ */
14
+
15
+ import { createServer as createHttpServer } from "node:http";
16
+ import { timingSafeEqual, createHash } from "node:crypto";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { createServer } from "./server.js";
19
+
20
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
21
+
22
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "[::1]"]);
23
+
24
+ export function isLoopbackHost(host) {
25
+ return LOOPBACK_HOSTS.has(host);
26
+ }
27
+
28
+ /** Compare secrets without leaking their length or content through timing. */
29
+ function secretsMatch(given, expected) {
30
+ const a = createHash("sha256").update(given).digest();
31
+ const b = createHash("sha256").update(expected).digest();
32
+ return timingSafeEqual(a, b);
33
+ }
34
+
35
+ /** Host name of a Host header value ("example.com:3333" -> "example.com"). */
36
+ function hostName(headerValue) {
37
+ const value = String(headerValue ?? "").toLowerCase();
38
+ if (value.startsWith("[")) {
39
+ const end = value.indexOf("]");
40
+ return end === -1 ? value : value.slice(0, end + 1);
41
+ }
42
+ return value.split(":")[0];
43
+ }
44
+
45
+ function sendJson(res, status, body, headers = {}) {
46
+ const payload = JSON.stringify(body);
47
+ res.writeHead(status, {
48
+ "content-type": "application/json",
49
+ "content-length": Buffer.byteLength(payload),
50
+ ...headers
51
+ });
52
+ res.end(payload);
53
+ }
54
+
55
+ function jsonRpcError(res, status, code, message, headers) {
56
+ sendJson(res, status, { jsonrpc: "2.0", error: { code, message }, id: null }, headers);
57
+ }
58
+
59
+ async function readBody(req) {
60
+ const chunks = [];
61
+ let size = 0;
62
+ for await (const chunk of req) {
63
+ size += chunk.length;
64
+ if (size > MAX_BODY_BYTES) {
65
+ const err = new Error("request body is too large");
66
+ err.status = 413;
67
+ throw err;
68
+ }
69
+ chunks.push(chunk);
70
+ }
71
+ const text = Buffer.concat(chunks).toString("utf8");
72
+ if (!text) {
73
+ return undefined;
74
+ }
75
+ try {
76
+ return JSON.parse(text);
77
+ } catch {
78
+ const err = new Error("request body is not valid JSON");
79
+ err.status = 400;
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate the HTTP settings and fill in defaults.
86
+ * @param {{ host?: string, port?: number, path?: string, token?: string, allowedHosts?: string[] }} http
87
+ */
88
+ export function resolveHttpSettings(http = {}) {
89
+ const settings = {
90
+ host: http.host ?? "127.0.0.1",
91
+ port: http.port ?? 3333,
92
+ path: http.path ?? "/mcp",
93
+ token: http.token ?? "",
94
+ allowedHosts: (http.allowedHosts ?? []).map((h) => h.toLowerCase())
95
+ };
96
+ if (!settings.path.startsWith("/")) {
97
+ settings.path = `/${settings.path}`;
98
+ }
99
+ if (!settings.token && !isLoopbackHost(settings.host)) {
100
+ throw new Error(
101
+ `refusing to listen on ${settings.host} without a token: pass --token or set VOLT_DRIVE_MCP_TOKEN`
102
+ );
103
+ }
104
+ return settings;
105
+ }
106
+
107
+ /**
108
+ * Create (but do not start) the HTTP server.
109
+ *
110
+ * @param {import("./drive-service.js").DriveService} service
111
+ * @param {Parameters<typeof resolveHttpSettings>[0]} httpSettings
112
+ * @param {{ logger?: import("./logger.js").Logger }} [deps]
113
+ */
114
+ export function createHttpApp(service, httpSettings, { logger } = {}) {
115
+ const settings = resolveHttpSettings(httpSettings);
116
+ const loopbackOnly = isLoopbackHost(settings.host);
117
+
118
+ // Names the Host header may carry: loopback names and the bind address, plus
119
+ // whatever the operator allowed for a reverse proxy.
120
+ const allowedHostNames = new Set([
121
+ ...LOOPBACK_HOSTS,
122
+ settings.host.toLowerCase(),
123
+ ...settings.allowedHosts
124
+ ]);
125
+
126
+ const hostAllowed = (req) => {
127
+ // A remote deployment is protected by its token; a Host allow-list there
128
+ // would only get in the way unless the operator asked for one.
129
+ if (!loopbackOnly && settings.allowedHosts.length === 0) {
130
+ return true;
131
+ }
132
+ return allowedHostNames.has(hostName(req.headers.host));
133
+ };
134
+
135
+ const originAllowed = (req) => {
136
+ const origin = req.headers.origin;
137
+ if (!origin) {
138
+ return true; // not a browser
139
+ }
140
+ try {
141
+ const url = new URL(origin);
142
+ return allowedHostNames.has(url.hostname.toLowerCase());
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+
148
+ const authorised = (req) => {
149
+ if (!settings.token) {
150
+ return true;
151
+ }
152
+ const header = String(req.headers.authorization ?? "");
153
+ const match = /^Bearer\s+(.+)$/i.exec(header);
154
+ return Boolean(match) && secretsMatch(match[1].trim(), settings.token);
155
+ };
156
+
157
+ const handle = async (req, res) => {
158
+ const url = new URL(req.url ?? "/", "http://localhost");
159
+ if (url.pathname !== settings.path) {
160
+ sendJson(res, 404, { error: "not found" });
161
+ return;
162
+ }
163
+ if (!hostAllowed(req) || !originAllowed(req)) {
164
+ jsonRpcError(res, 403, -32000, "Forbidden: unexpected Host or Origin");
165
+ return;
166
+ }
167
+ if (!authorised(req)) {
168
+ jsonRpcError(res, 401, -32001, "Unauthorized", {
169
+ "www-authenticate": 'Bearer realm="volt-drive-mcp"'
170
+ });
171
+ return;
172
+ }
173
+ if (req.method !== "POST") {
174
+ // Stateless: there is no server-initiated stream and no session to end.
175
+ jsonRpcError(res, 405, -32000, "Method not allowed", { allow: "POST" });
176
+ return;
177
+ }
178
+
179
+ let body;
180
+ try {
181
+ body = await readBody(req);
182
+ } catch (err) {
183
+ jsonRpcError(res, err.status ?? 400, -32700, err.message);
184
+ return;
185
+ }
186
+
187
+ const server = createServer(service, { logger });
188
+ const transport = new StreamableHTTPServerTransport({
189
+ sessionIdGenerator: undefined,
190
+ enableJsonResponse: true
191
+ });
192
+ res.on("close", () => {
193
+ transport.close().catch(() => {});
194
+ server.close().catch(() => {});
195
+ });
196
+ await server.connect(transport);
197
+ await transport.handleRequest(req, res, body);
198
+ };
199
+
200
+ return createHttpServer((req, res) => {
201
+ handle(req, res).catch((err) => {
202
+ logger?.error(`request failed: ${err?.stack ?? err}`);
203
+ if (!res.headersSent) {
204
+ jsonRpcError(res, 500, -32603, "Internal server error");
205
+ } else {
206
+ res.end();
207
+ }
208
+ });
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Start listening. Resolves with the server and the actual port (useful with
214
+ * port 0).
215
+ */
216
+ export async function startHttpServer(service, httpSettings, deps = {}) {
217
+ const settings = resolveHttpSettings(httpSettings);
218
+ const httpServer = createHttpApp(service, settings, deps);
219
+ await new Promise((resolve, reject) => {
220
+ httpServer.once("error", reject);
221
+ httpServer.listen(settings.port, settings.host, () => {
222
+ httpServer.off("error", reject);
223
+ resolve();
224
+ });
225
+ });
226
+ const address = httpServer.address();
227
+ const port = typeof address === "object" && address ? address.port : settings.port;
228
+ return { httpServer, port, settings };
229
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { connectDriveService, createDriveService } from "./connect.js";
2
+ export { DEFAULT_OPTIONS, DriveService } from "./drive-service.js";
3
+ export { DriveError } from "./errors.js";
4
+ export { createServer, defineTools } from "./server.js";
package/src/logger.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Logging for a process whose stdout may be the MCP protocol channel.
3
+ *
4
+ * Everything goes to stderr, never stdout: on the stdio transport a stray line
5
+ * on stdout corrupts the JSON-RPC stream. The level comes from
6
+ * `VOLT_DRIVE_MCP_LOG` (error | info | debug), default info.
7
+ */
8
+
9
+ const LEVELS = { error: 0, info: 1, debug: 2 };
10
+
11
+ export function createLogger(level = process.env.VOLT_DRIVE_MCP_LOG || "info") {
12
+ const threshold = LEVELS[level] ?? LEVELS.info;
13
+
14
+ const write = (name, message) => {
15
+ if (LEVELS[name] > threshold) {
16
+ return;
17
+ }
18
+ process.stderr.write(`[volt-drive-mcp] ${name}: ${message}\n`);
19
+ };
20
+
21
+ return {
22
+ error: (message) => write("error", message),
23
+ info: (message) => write("info", message),
24
+ debug: (message) => write("debug", message)
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Make `console.log` and friends write to stderr. Third party code (and the
30
+ * Volt client's dependencies) occasionally logs to stdout, which on the stdio
31
+ * transport would break the protocol.
32
+ */
33
+ export function redirectConsoleToStderr() {
34
+ const toStderr = (...args) => console.error(...args);
35
+ console.log = toStderr;
36
+ console.info = toStderr;
37
+ console.debug = toStderr;
38
+ }