@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/paths.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { invalid } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const MAX_PATH_LENGTH = 4096;
|
|
4
|
+
const MAX_SEGMENT_LENGTH = 255;
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line no-control-regex
|
|
7
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalise a user supplied drive path into the canonical form used in the
|
|
11
|
+
* drive index: forward slashes, no leading or trailing slash, no empty, `.` or
|
|
12
|
+
* `..` segments. The drive root is the empty string.
|
|
13
|
+
*
|
|
14
|
+
* Drive paths are relative to the root of the drive and are also materialised
|
|
15
|
+
* as paths on the local disk by the VoltDrive daemon, so anything that could
|
|
16
|
+
* escape the root (`..`, absolute paths, drive letters) is rejected outright
|
|
17
|
+
* rather than being silently rewritten.
|
|
18
|
+
*
|
|
19
|
+
* @param {unknown} input
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {boolean} [options.allowRoot] - permit the empty path (the drive root).
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeDrivePath(input, { allowRoot = false } = {}) {
|
|
25
|
+
if (input === undefined || input === null) {
|
|
26
|
+
input = "";
|
|
27
|
+
}
|
|
28
|
+
if (typeof input !== "string") {
|
|
29
|
+
throw invalid("path must be a string");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (CONTROL_CHARS.test(input)) {
|
|
33
|
+
throw invalid("path contains control characters");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (input.length > MAX_PATH_LENGTH) {
|
|
37
|
+
throw invalid("path is too long");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const unified = input.replace(/\\/g, "/");
|
|
41
|
+
|
|
42
|
+
if (/^[A-Za-z]:/.test(unified)) {
|
|
43
|
+
throw invalid("path must be relative to the drive root (no drive letters)");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const segments = [];
|
|
47
|
+
for (const segment of unified.split("/")) {
|
|
48
|
+
if (segment === "" || segment === ".") {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (segment === "..") {
|
|
52
|
+
throw invalid("path must not contain '..' segments");
|
|
53
|
+
}
|
|
54
|
+
if (segment.length > MAX_SEGMENT_LENGTH) {
|
|
55
|
+
throw invalid("path segment is too long");
|
|
56
|
+
}
|
|
57
|
+
segments.push(segment);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const normalized = segments.join("/");
|
|
61
|
+
if (!normalized && !allowRoot) {
|
|
62
|
+
throw invalid("path must not be empty");
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The last segment of a normalised path. */
|
|
68
|
+
export function basename(path) {
|
|
69
|
+
const slash = path.lastIndexOf("/");
|
|
70
|
+
return slash < 0 ? path : path.slice(slash + 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The parent folder of a normalised path ("" for top level items). */
|
|
74
|
+
export function parentOf(path) {
|
|
75
|
+
const slash = path.lastIndexOf("/");
|
|
76
|
+
return slash < 0 ? "" : path.slice(0, slash);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every ancestor folder of `path`, outermost first, excluding `path` itself. */
|
|
80
|
+
export function ancestorsOf(path) {
|
|
81
|
+
const result = [];
|
|
82
|
+
let slash = path.indexOf("/");
|
|
83
|
+
while (slash > 0) {
|
|
84
|
+
result.push(path.slice(0, slash));
|
|
85
|
+
slash = path.indexOf("/", slash + 1);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when `path` equals `folder` or lies underneath it. */
|
|
91
|
+
export function isWithin(path, folder) {
|
|
92
|
+
if (!folder) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return path === folder || path.startsWith(`${folder}/`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Stable ordering for drive paths: case-insensitive first, then exact, so the
|
|
100
|
+
* listing order does not depend on the locale of the host.
|
|
101
|
+
*/
|
|
102
|
+
export function compareDrivePaths(a, b) {
|
|
103
|
+
const la = a.toLowerCase();
|
|
104
|
+
const lb = b.toLowerCase();
|
|
105
|
+
if (la < lb) {
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
if (la > lb) {
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
if (a < b) {
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
return a > b ? 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compile a glob (`*` any run within a segment, `**` any run across segments,
|
|
119
|
+
* `?` a single character) into a case-insensitive regular expression anchored
|
|
120
|
+
* to the whole path.
|
|
121
|
+
*/
|
|
122
|
+
export function globToRegExp(glob) {
|
|
123
|
+
let source = "";
|
|
124
|
+
for (let i = 0; i < glob.length; i++) {
|
|
125
|
+
const ch = glob[i];
|
|
126
|
+
if (ch === "*") {
|
|
127
|
+
if (glob[i + 1] === "*") {
|
|
128
|
+
source += ".*";
|
|
129
|
+
i++;
|
|
130
|
+
if (glob[i + 1] === "/") {
|
|
131
|
+
// `**/` also matches zero directories.
|
|
132
|
+
i++;
|
|
133
|
+
source = source.slice(0, -2) + "(?:.*/)?";
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
source += "[^/]*";
|
|
137
|
+
}
|
|
138
|
+
} else if (ch === "?") {
|
|
139
|
+
source += "[^/]";
|
|
140
|
+
} else {
|
|
141
|
+
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return new RegExp(`^${source}$`, "i");
|
|
145
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DriveError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export const SERVER_NAME = "volt-drive";
|
|
6
|
+
export const SERVER_VERSION = "0.1.0";
|
|
7
|
+
|
|
8
|
+
const SERVER_INSTRUCTIONS = [
|
|
9
|
+
"Access to VoltDrive drives: folders of files stored in a Volt and synced to the devices of the people who share them.",
|
|
10
|
+
"Call list_drives first to find the drive to use, then browse with list_directory, search with search_files, and read with read_file.",
|
|
11
|
+
"Paths are relative to the drive root, use forward slashes, and never start with a slash.",
|
|
12
|
+
"Edits sync to every device that has the drive, so prefer small targeted changes, and never overwrite or delete a file unless asked to."
|
|
13
|
+
].join(" ");
|
|
14
|
+
|
|
15
|
+
const drive = z
|
|
16
|
+
.string()
|
|
17
|
+
.describe("The drive to use: its id, alias or exact name (see list_drives).");
|
|
18
|
+
|
|
19
|
+
const path = z
|
|
20
|
+
.string()
|
|
21
|
+
.describe("Path relative to the drive root, e.g. 'notes/todo.md'. Use forward slashes.");
|
|
22
|
+
|
|
23
|
+
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
24
|
+
const ADDITIVE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
25
|
+
const IDEMPOTENT_ADDITIVE = { ...ADDITIVE, idempotentHint: true };
|
|
26
|
+
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
|
|
27
|
+
|
|
28
|
+
const text = (value) => ({ type: "text", text: value });
|
|
29
|
+
const json = (value) => text(JSON.stringify(value, null, 2));
|
|
30
|
+
|
|
31
|
+
/** A result for a successful call that returns a JSON document. */
|
|
32
|
+
const jsonResult = (value) => ({ content: [json(value)] });
|
|
33
|
+
|
|
34
|
+
/** A tool failure the model can read and act on. */
|
|
35
|
+
const errorResult = (message) => ({
|
|
36
|
+
isError: true,
|
|
37
|
+
content: [text(message)]
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* File reads return the file's content as its own text block, exactly as
|
|
42
|
+
* stored, followed by a small JSON block of metadata. That avoids escaping the
|
|
43
|
+
* content inside a JSON string, which wastes tokens and invites mistakes.
|
|
44
|
+
*/
|
|
45
|
+
function fileResult(result) {
|
|
46
|
+
const { content, ...meta } = result;
|
|
47
|
+
if (content === null || content === undefined) {
|
|
48
|
+
return { content: [json(meta)] };
|
|
49
|
+
}
|
|
50
|
+
if (meta.truncated) {
|
|
51
|
+
meta.nextOffset = meta.offset + meta.returned;
|
|
52
|
+
}
|
|
53
|
+
return { content: [text(content), json(meta)] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every tool the connector offers. `write` tools are only registered when the
|
|
58
|
+
* service is not read-only, so a read-only deployment never even advertises
|
|
59
|
+
* them to the model.
|
|
60
|
+
*/
|
|
61
|
+
export function defineTools(service) {
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
name: "list_drives",
|
|
65
|
+
title: "List drives",
|
|
66
|
+
description:
|
|
67
|
+
"List the VoltDrive drives available to this connector. Returns each drive's id, name and aliases. Start here.",
|
|
68
|
+
inputSchema: {},
|
|
69
|
+
annotations: READ,
|
|
70
|
+
handler: async () => jsonResult({ drives: await service.listDrives() })
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "get_drive_status",
|
|
74
|
+
title: "Get drive status",
|
|
75
|
+
description:
|
|
76
|
+
"Summary of a drive: number of files and folders, total size, its sync settings (size limit, binary files, versioning) and whether a VoltDrive client on the connected Volt is idle or still syncing.",
|
|
77
|
+
inputSchema: { drive },
|
|
78
|
+
annotations: READ,
|
|
79
|
+
handler: async ({ drive: ref }) => jsonResult(await service.getDriveStatus(ref))
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "list_directory",
|
|
83
|
+
title: "List a folder",
|
|
84
|
+
description:
|
|
85
|
+
"List the files and folders directly inside a folder of a drive (the drive root if path is empty). Set recursive to list everything beneath it.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
drive,
|
|
88
|
+
path: z.string().optional().describe("Folder path; omit or leave empty for the drive root."),
|
|
89
|
+
recursive: z.boolean().optional().describe("List all descendants, not just direct children. Default false."),
|
|
90
|
+
max_entries: z.number().int().min(1).max(2000).optional().describe("Most entries to return. Default 500.")
|
|
91
|
+
},
|
|
92
|
+
annotations: READ,
|
|
93
|
+
handler: async (args) =>
|
|
94
|
+
jsonResult(
|
|
95
|
+
await service.listDirectory(args.drive, args.path ?? "", {
|
|
96
|
+
recursive: args.recursive ?? false,
|
|
97
|
+
maxEntries: args.max_entries ?? 500
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "read_file",
|
|
103
|
+
title: "Read a file",
|
|
104
|
+
description:
|
|
105
|
+
"Read a file from a drive. Text files are returned as text. Binary files return only their metadata unless encoding is 'base64'. Large files are returned in pieces: when the result is truncated, call again with offset set to nextOffset.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
drive,
|
|
108
|
+
path,
|
|
109
|
+
offset: z.number().int().min(0).optional().describe("Where to start: character offset for text, byte offset for base64. Default 0."),
|
|
110
|
+
limit: z.number().int().min(1).optional().describe("Most characters (text) or bytes (base64) to return. Capped by the server."),
|
|
111
|
+
encoding: z.enum(["utf-8", "base64"]).optional().describe("'utf-8' (default) for text, 'base64' to fetch the raw bytes of a binary file.")
|
|
112
|
+
},
|
|
113
|
+
annotations: READ,
|
|
114
|
+
handler: async (args) =>
|
|
115
|
+
fileResult(
|
|
116
|
+
await service.readFile(args.drive, args.path, {
|
|
117
|
+
offset: args.offset ?? 0,
|
|
118
|
+
limit: args.limit,
|
|
119
|
+
encoding: args.encoding ?? "utf-8"
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "search_files",
|
|
125
|
+
title: "Search files",
|
|
126
|
+
description:
|
|
127
|
+
"Find files in a drive by name and/or by text content. name_pattern is a substring or a glob such as '*.md' or 'docs/**/*.txt'. content_query is a case-insensitive text search that scans text files only (binary and very large files are skipped and counted in the result).",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
drive,
|
|
130
|
+
name_pattern: z.string().optional().describe("Substring or glob to match against file names or paths."),
|
|
131
|
+
content_query: z.string().optional().describe("Text to look for inside files."),
|
|
132
|
+
path: z.string().optional().describe("Only search beneath this folder."),
|
|
133
|
+
max_results: z.number().int().min(1).max(500).optional().describe("Most results to return. Default 50.")
|
|
134
|
+
},
|
|
135
|
+
annotations: READ,
|
|
136
|
+
handler: async (args) =>
|
|
137
|
+
jsonResult(
|
|
138
|
+
await service.searchFiles(args.drive, {
|
|
139
|
+
namePattern: args.name_pattern ?? "",
|
|
140
|
+
contentQuery: args.content_query ?? "",
|
|
141
|
+
path: args.path ?? "",
|
|
142
|
+
maxResults: args.max_results ?? 50
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "list_file_versions",
|
|
148
|
+
title: "List file versions",
|
|
149
|
+
description:
|
|
150
|
+
"List the saved versions of a file, newest first. Only available on drives that have file versioning enabled.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
drive,
|
|
153
|
+
path,
|
|
154
|
+
limit: z.number().int().min(1).max(200).optional().describe("Most versions to return. Default 50."),
|
|
155
|
+
offset: z.number().int().min(0).optional().describe("Versions to skip from the newest. Default 0.")
|
|
156
|
+
},
|
|
157
|
+
annotations: READ,
|
|
158
|
+
handler: async (args) =>
|
|
159
|
+
jsonResult(
|
|
160
|
+
await service.listFileVersions(args.drive, args.path, {
|
|
161
|
+
limit: args.limit ?? 50,
|
|
162
|
+
offset: args.offset ?? 0
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "read_file_version",
|
|
168
|
+
title: "Read an earlier version of a file",
|
|
169
|
+
description:
|
|
170
|
+
"Read the text of a file as it was at a saved version (see list_file_versions). Text files only.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
drive,
|
|
173
|
+
path,
|
|
174
|
+
version_id: z.string().describe("A versionId from list_file_versions."),
|
|
175
|
+
offset: z.number().int().min(0).optional().describe("Character offset to start from. Default 0."),
|
|
176
|
+
limit: z.number().int().min(1).optional().describe("Most characters to return. Capped by the server.")
|
|
177
|
+
},
|
|
178
|
+
annotations: READ,
|
|
179
|
+
handler: async (args) =>
|
|
180
|
+
fileResult(
|
|
181
|
+
await service.readFileVersion(args.drive, args.path, args.version_id, {
|
|
182
|
+
offset: args.offset ?? 0,
|
|
183
|
+
limit: args.limit
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// ----------------------------------------------------------- write tools
|
|
189
|
+
{
|
|
190
|
+
name: "write_file",
|
|
191
|
+
title: "Create or replace a file",
|
|
192
|
+
description:
|
|
193
|
+
"Create a file, or replace the whole content of an existing one. Existing files are only replaced when overwrite is true, so read the file first and keep unrelated content. Missing parent folders are created. For binary data pass base64 content with encoding 'base64'. The change syncs to everyone who has the drive.",
|
|
194
|
+
write: true,
|
|
195
|
+
inputSchema: {
|
|
196
|
+
drive,
|
|
197
|
+
path,
|
|
198
|
+
content: z.string().describe("The complete new content of the file."),
|
|
199
|
+
encoding: z.enum(["utf-8", "base64"]).optional().describe("How content is encoded: 'utf-8' (default) for text, 'base64' for bytes."),
|
|
200
|
+
overwrite: z.boolean().optional().describe("Replace the file if it already exists. Default false.")
|
|
201
|
+
},
|
|
202
|
+
annotations: DESTRUCTIVE,
|
|
203
|
+
handler: async (args) =>
|
|
204
|
+
jsonResult(
|
|
205
|
+
await service.writeFile(args.drive, args.path, {
|
|
206
|
+
content: args.content,
|
|
207
|
+
encoding: args.encoding ?? "utf-8",
|
|
208
|
+
overwrite: args.overwrite ?? false
|
|
209
|
+
})
|
|
210
|
+
)
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "create_folder",
|
|
214
|
+
title: "Create a folder",
|
|
215
|
+
description:
|
|
216
|
+
"Create a folder, including any missing parents. Does nothing if the folder already exists.",
|
|
217
|
+
write: true,
|
|
218
|
+
inputSchema: { drive, path },
|
|
219
|
+
annotations: IDEMPOTENT_ADDITIVE,
|
|
220
|
+
handler: async (args) => jsonResult(await service.createFolder(args.drive, args.path))
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "move_path",
|
|
224
|
+
title: "Move or rename a file or folder",
|
|
225
|
+
description:
|
|
226
|
+
"Rename or move a file or folder to a new full path. The destination must not already exist. Moving a folder moves everything in it.",
|
|
227
|
+
write: true,
|
|
228
|
+
inputSchema: {
|
|
229
|
+
drive,
|
|
230
|
+
from: z.string().describe("Current path of the file or folder."),
|
|
231
|
+
to: z.string().describe("New full path, including the new name.")
|
|
232
|
+
},
|
|
233
|
+
annotations: ADDITIVE,
|
|
234
|
+
handler: async (args) => jsonResult(await service.movePath(args.drive, args.from, args.to))
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "delete_path",
|
|
238
|
+
title: "Delete a file or folder",
|
|
239
|
+
description:
|
|
240
|
+
"Delete a file, or a folder and everything in it. A folder that is not empty is only deleted when recursive is true. Deleting syncs to everyone who has the drive; confirm with the user first unless they have clearly asked for it.",
|
|
241
|
+
write: true,
|
|
242
|
+
inputSchema: {
|
|
243
|
+
drive,
|
|
244
|
+
path,
|
|
245
|
+
recursive: z.boolean().optional().describe("Allow deleting a folder that contains files. Default false.")
|
|
246
|
+
},
|
|
247
|
+
annotations: DESTRUCTIVE,
|
|
248
|
+
handler: async (args) =>
|
|
249
|
+
jsonResult(
|
|
250
|
+
await service.deletePath(args.drive, args.path, {
|
|
251
|
+
recursive: args.recursive ?? false
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "create_file_version",
|
|
257
|
+
title: "Save a version of a file",
|
|
258
|
+
description:
|
|
259
|
+
"Save a named checkpoint of a file's current content so it can be restored later. Only available on drives with file versioning enabled.",
|
|
260
|
+
write: true,
|
|
261
|
+
inputSchema: {
|
|
262
|
+
drive,
|
|
263
|
+
path,
|
|
264
|
+
label: z.string().optional().describe("A short description of this version.")
|
|
265
|
+
},
|
|
266
|
+
annotations: ADDITIVE,
|
|
267
|
+
handler: async (args) =>
|
|
268
|
+
jsonResult(await service.createFileVersion(args.drive, args.path, args.label ?? ""))
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "revert_file_version",
|
|
272
|
+
title: "Revert a file to a saved version",
|
|
273
|
+
description:
|
|
274
|
+
"Replace a file's content with that of a saved version (see list_file_versions). Current content is replaced, so save a version first if it should be kept.",
|
|
275
|
+
write: true,
|
|
276
|
+
inputSchema: {
|
|
277
|
+
drive,
|
|
278
|
+
path,
|
|
279
|
+
version_id: z.string().describe("A versionId from list_file_versions.")
|
|
280
|
+
},
|
|
281
|
+
annotations: DESTRUCTIVE,
|
|
282
|
+
handler: async (args) =>
|
|
283
|
+
jsonResult(await service.revertFileVersion(args.drive, args.path, args.version_id))
|
|
284
|
+
}
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Turn anything a tool throws into a result with `isError`. Errors the
|
|
290
|
+
* connector raises itself (`DriveError`) carry messages written for the model;
|
|
291
|
+
* anything else is reported with its message but logged in full.
|
|
292
|
+
*/
|
|
293
|
+
function guard(handler, name, logger) {
|
|
294
|
+
return async (args) => {
|
|
295
|
+
try {
|
|
296
|
+
return await handler(args ?? {});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (err instanceof DriveError) {
|
|
299
|
+
return errorResult(err.message);
|
|
300
|
+
}
|
|
301
|
+
logger?.error?.(`tool ${name} failed: ${err?.stack ?? err}`);
|
|
302
|
+
return errorResult(`${name} failed: ${err?.message ?? String(err)}`);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Build an MCP server exposing `service`'s drives. Contains no transport
|
|
309
|
+
* logic, so the same server is served over stdio or HTTP.
|
|
310
|
+
*
|
|
311
|
+
* @param {import("./drive-service.js").DriveService} service
|
|
312
|
+
* @param {{ logger?: object }} [options]
|
|
313
|
+
* @returns {McpServer}
|
|
314
|
+
*/
|
|
315
|
+
export function createServer(service, { logger } = {}) {
|
|
316
|
+
const server = new McpServer(
|
|
317
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
318
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
for (const tool of defineTools(service)) {
|
|
322
|
+
if (tool.write && service.readOnly) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
server.registerTool(
|
|
326
|
+
tool.name,
|
|
327
|
+
{
|
|
328
|
+
title: tool.title,
|
|
329
|
+
description: tool.description,
|
|
330
|
+
inputSchema: tool.inputSchema,
|
|
331
|
+
annotations: tool.annotations
|
|
332
|
+
},
|
|
333
|
+
guard(tool.handler, tool.name, logger)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return server;
|
|
338
|
+
}
|