@tdxvolt/volt-drive-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +289 -2
- package/bin/http.js +78 -0
- package/bin/stdio.js +72 -0
- package/package.json +54 -3
- 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 +1083 -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
|
@@ -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
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
|
+
}
|