@shelv/mcp 0.2.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/LICENSE +15 -0
- package/README.md +55 -0
- package/dist/bin/shelv-mcp.d.ts +1 -0
- package/dist/bin/shelv-mcp.js +1093 -0
- package/dist/bin/shelv-mcp.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +892 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/server.json +15 -0
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { createShelvClient } from "@shelv/adapters";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
var API_BASE_URL = "https://api.shelv.dev";
|
|
9
|
+
function parseBoolean(value, fallback) {
|
|
10
|
+
if (value === void 0) return fallback;
|
|
11
|
+
const normalized = value.trim().toLowerCase();
|
|
12
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Invalid boolean value: ${value}`);
|
|
19
|
+
}
|
|
20
|
+
function parseInteger(value, fallback, label) {
|
|
21
|
+
if (value === void 0 || value.trim() === "") {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
const parsed = Number.parseInt(value, 10);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
26
|
+
throw new Error(`${label} must be a positive integer`);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
function parseTransport(value) {
|
|
31
|
+
if (!value || value.trim() === "") {
|
|
32
|
+
return "stdio";
|
|
33
|
+
}
|
|
34
|
+
if (value === "stdio" || value === "http") {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
throw new Error("SHELV_MCP_TRANSPORT must be either 'stdio' or 'http'");
|
|
38
|
+
}
|
|
39
|
+
function loadConfig(env = process.env) {
|
|
40
|
+
return {
|
|
41
|
+
apiBaseUrl: API_BASE_URL,
|
|
42
|
+
apiKey: env.SHELV_API_KEY?.trim() || void 0,
|
|
43
|
+
transport: parseTransport(env.SHELV_MCP_TRANSPORT),
|
|
44
|
+
httpHost: env.SHELV_MCP_HTTP_HOST?.trim() || "127.0.0.1",
|
|
45
|
+
httpPort: parseInteger(env.SHELV_MCP_HTTP_PORT, 3334, "SHELV_MCP_HTTP_PORT"),
|
|
46
|
+
enableWriteTools: parseBoolean(env.SHELV_MCP_ENABLE_WRITE_TOOLS, false),
|
|
47
|
+
searchMaxFiles: parseInteger(
|
|
48
|
+
env.SHELV_MCP_SEARCH_MAX_FILES,
|
|
49
|
+
500,
|
|
50
|
+
"SHELV_MCP_SEARCH_MAX_FILES"
|
|
51
|
+
),
|
|
52
|
+
searchMaxBytes: parseInteger(
|
|
53
|
+
env.SHELV_MCP_SEARCH_MAX_BYTES,
|
|
54
|
+
5e6,
|
|
55
|
+
"SHELV_MCP_SEARCH_MAX_BYTES"
|
|
56
|
+
),
|
|
57
|
+
searchMaxMatches: parseInteger(
|
|
58
|
+
env.SHELV_MCP_SEARCH_MAX_MATCHES,
|
|
59
|
+
200,
|
|
60
|
+
"SHELV_MCP_SEARCH_MAX_MATCHES"
|
|
61
|
+
),
|
|
62
|
+
readMaxBytes: parseInteger(
|
|
63
|
+
env.SHELV_MCP_READ_MAX_BYTES,
|
|
64
|
+
25e4,
|
|
65
|
+
"SHELV_MCP_READ_MAX_BYTES"
|
|
66
|
+
)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/errors.ts
|
|
71
|
+
import { AdapterError } from "@shelv/adapters";
|
|
72
|
+
var McpToolError = class extends Error {
|
|
73
|
+
code;
|
|
74
|
+
status;
|
|
75
|
+
details;
|
|
76
|
+
retryable;
|
|
77
|
+
constructor(data, options) {
|
|
78
|
+
super(data.message, options);
|
|
79
|
+
this.name = "McpToolError";
|
|
80
|
+
this.code = data.code;
|
|
81
|
+
this.status = data.status;
|
|
82
|
+
this.details = data.details;
|
|
83
|
+
this.retryable = data.retryable;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var ApiRequestError = class extends Error {
|
|
87
|
+
method;
|
|
88
|
+
path;
|
|
89
|
+
status;
|
|
90
|
+
body;
|
|
91
|
+
constructor(method, path4, status, body, options) {
|
|
92
|
+
const message = typeof body === "object" && body !== null && "message" in body && typeof body.message === "string" ? body.message : `Shelv API request failed (${status})`;
|
|
93
|
+
super(message, options);
|
|
94
|
+
this.name = "ApiRequestError";
|
|
95
|
+
this.method = method;
|
|
96
|
+
this.path = path4;
|
|
97
|
+
this.status = status;
|
|
98
|
+
this.body = body;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
function statusToCode(status) {
|
|
102
|
+
if (status === 400) return "INPUT_ERROR";
|
|
103
|
+
if (status === 401 || status === 403) return "AUTH_ERROR";
|
|
104
|
+
if (status === 402) return "BILLING_REQUIRED";
|
|
105
|
+
if (status === 404) return "NOT_FOUND";
|
|
106
|
+
if (status === 409) return "NOT_READY";
|
|
107
|
+
if (status === 429) return "RATE_LIMITED";
|
|
108
|
+
return "UPSTREAM_ERROR";
|
|
109
|
+
}
|
|
110
|
+
function extractStatusFromMessage(message) {
|
|
111
|
+
const match = message.match(/\((\d{3})\)/);
|
|
112
|
+
if (!match) return void 0;
|
|
113
|
+
const status = Number.parseInt(match[1] ?? "", 10);
|
|
114
|
+
return Number.isFinite(status) ? status : void 0;
|
|
115
|
+
}
|
|
116
|
+
function toMcpToolError(error, fallbackMessage = "Request failed") {
|
|
117
|
+
if (error instanceof McpToolError) {
|
|
118
|
+
return error;
|
|
119
|
+
}
|
|
120
|
+
if (error instanceof ApiRequestError) {
|
|
121
|
+
return new McpToolError(
|
|
122
|
+
{
|
|
123
|
+
code: statusToCode(error.status),
|
|
124
|
+
status: error.status,
|
|
125
|
+
message: error.message,
|
|
126
|
+
details: error.body,
|
|
127
|
+
retryable: error.status >= 500 || error.status === 429
|
|
128
|
+
},
|
|
129
|
+
{ cause: error }
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof AdapterError) {
|
|
133
|
+
if (error.code === "TREE_FETCH_FAILED") {
|
|
134
|
+
const status = extractStatusFromMessage(error.message);
|
|
135
|
+
return new McpToolError(
|
|
136
|
+
{
|
|
137
|
+
code: status ? statusToCode(status) : "UPSTREAM_ERROR",
|
|
138
|
+
status,
|
|
139
|
+
message: error.message,
|
|
140
|
+
retryable: status ? status >= 500 || status === 429 : false
|
|
141
|
+
},
|
|
142
|
+
{ cause: error }
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (error.code === "ARCHIVE_TIMEOUT") {
|
|
146
|
+
return new McpToolError(
|
|
147
|
+
{
|
|
148
|
+
code: "UPSTREAM_ERROR",
|
|
149
|
+
message: error.message,
|
|
150
|
+
retryable: true
|
|
151
|
+
},
|
|
152
|
+
{ cause: error }
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (error.code === "ARCHIVE_PARSE_FAILED") {
|
|
156
|
+
return new McpToolError(
|
|
157
|
+
{
|
|
158
|
+
code: "UPSTREAM_ERROR",
|
|
159
|
+
message: error.message,
|
|
160
|
+
retryable: false
|
|
161
|
+
},
|
|
162
|
+
{ cause: error }
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return new McpToolError(
|
|
166
|
+
{
|
|
167
|
+
code: "UPSTREAM_ERROR",
|
|
168
|
+
message: error.message,
|
|
169
|
+
retryable: false
|
|
170
|
+
},
|
|
171
|
+
{ cause: error }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (error instanceof Error) {
|
|
175
|
+
return new McpToolError(
|
|
176
|
+
{
|
|
177
|
+
code: "UPSTREAM_ERROR",
|
|
178
|
+
message: error.message || fallbackMessage,
|
|
179
|
+
retryable: false
|
|
180
|
+
},
|
|
181
|
+
{ cause: error }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return new McpToolError({
|
|
185
|
+
code: "UPSTREAM_ERROR",
|
|
186
|
+
message: fallbackMessage,
|
|
187
|
+
details: error,
|
|
188
|
+
retryable: false
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function serializeToolError(error) {
|
|
192
|
+
return {
|
|
193
|
+
code: error.code,
|
|
194
|
+
status: error.status,
|
|
195
|
+
details: error.details,
|
|
196
|
+
retryable: error.retryable,
|
|
197
|
+
message: error.message
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/http-client.ts
|
|
202
|
+
var ShelvHttpClient = class {
|
|
203
|
+
apiKey;
|
|
204
|
+
apiBaseUrl;
|
|
205
|
+
fetchImplementation;
|
|
206
|
+
constructor(config) {
|
|
207
|
+
this.apiKey = config.apiKey;
|
|
208
|
+
this.apiBaseUrl = config.apiBaseUrl.replace(/\/$/, "");
|
|
209
|
+
this.fetchImplementation = config.fetchImplementation ?? fetch;
|
|
210
|
+
}
|
|
211
|
+
async listShelves(params) {
|
|
212
|
+
const search = new URLSearchParams();
|
|
213
|
+
if (params?.page) search.set("page", String(params.page));
|
|
214
|
+
if (params?.limit) search.set("limit", String(params.limit));
|
|
215
|
+
const path4 = search.size > 0 ? `/v1/shelves?${search.toString()}` : "/v1/shelves";
|
|
216
|
+
return this.requestJson("GET", path4);
|
|
217
|
+
}
|
|
218
|
+
async createShelf(input) {
|
|
219
|
+
const formData = new FormData();
|
|
220
|
+
formData.append(
|
|
221
|
+
"file",
|
|
222
|
+
new Blob([Buffer.from(input.pdfBytes)], { type: "application/pdf" }),
|
|
223
|
+
input.fileName
|
|
224
|
+
);
|
|
225
|
+
if (input.name) formData.append("name", input.name);
|
|
226
|
+
if (input.template) formData.append("template", input.template);
|
|
227
|
+
if (typeof input.review === "boolean") {
|
|
228
|
+
formData.append("review", input.review ? "true" : "false");
|
|
229
|
+
}
|
|
230
|
+
return this.requestJson("POST", "/v1/shelves", {
|
|
231
|
+
body: formData
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async requestJson(method, path4, options) {
|
|
235
|
+
const response = await this.fetchImplementation(`${this.apiBaseUrl}${path4}`, {
|
|
236
|
+
method,
|
|
237
|
+
headers: {
|
|
238
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
239
|
+
},
|
|
240
|
+
body: options?.body
|
|
241
|
+
});
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
throw new ApiRequestError(
|
|
244
|
+
method,
|
|
245
|
+
path4,
|
|
246
|
+
response.status,
|
|
247
|
+
await this.parseErrorBody(response)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return await response.json();
|
|
251
|
+
}
|
|
252
|
+
async parseErrorBody(response) {
|
|
253
|
+
const contentType = response.headers.get("content-type") || "";
|
|
254
|
+
if (contentType.includes("application/json")) {
|
|
255
|
+
try {
|
|
256
|
+
return await response.json();
|
|
257
|
+
} catch {
|
|
258
|
+
return { message: "Failed to parse API error body" };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
return await response.text();
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// src/tools/create-shelf.ts
|
|
270
|
+
import fs from "fs/promises";
|
|
271
|
+
import path2 from "path";
|
|
272
|
+
import { z } from "zod";
|
|
273
|
+
|
|
274
|
+
// src/tools/common.ts
|
|
275
|
+
import path from "path";
|
|
276
|
+
function successResult(text, structuredContent) {
|
|
277
|
+
return {
|
|
278
|
+
content: [{ type: "text", text }],
|
|
279
|
+
structuredContent
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function errorResult(error) {
|
|
283
|
+
const normalized = toMcpToolError(error, "Tool execution failed");
|
|
284
|
+
return {
|
|
285
|
+
isError: true,
|
|
286
|
+
content: [{ type: "text", text: normalized.message }],
|
|
287
|
+
structuredContent: {
|
|
288
|
+
error: serializeToolError(normalized)
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function ensureRelativePath(inputPath) {
|
|
293
|
+
const trimmed = inputPath.trim();
|
|
294
|
+
if (!trimmed) {
|
|
295
|
+
throw new McpToolError({
|
|
296
|
+
code: "INPUT_ERROR",
|
|
297
|
+
message: "Path is required",
|
|
298
|
+
status: 400,
|
|
299
|
+
retryable: false
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (trimmed.includes("\\") || trimmed.includes("\0")) {
|
|
303
|
+
throw new McpToolError({
|
|
304
|
+
code: "INPUT_ERROR",
|
|
305
|
+
message: "Path contains unsupported characters",
|
|
306
|
+
status: 400,
|
|
307
|
+
retryable: false
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
const normalized = path.posix.normalize(trimmed);
|
|
311
|
+
if (normalized === "." || normalized === "") {
|
|
312
|
+
throw new McpToolError({
|
|
313
|
+
code: "INPUT_ERROR",
|
|
314
|
+
message: "Path must reference a file under the shelf root",
|
|
315
|
+
status: 400,
|
|
316
|
+
retryable: false
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) {
|
|
320
|
+
throw new McpToolError({
|
|
321
|
+
code: "INPUT_ERROR",
|
|
322
|
+
message: "Path traversal is not allowed",
|
|
323
|
+
status: 400,
|
|
324
|
+
retryable: false
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return normalized;
|
|
328
|
+
}
|
|
329
|
+
function safeJoin(baseDir, relativePath) {
|
|
330
|
+
const resolvedBase = path.resolve(baseDir);
|
|
331
|
+
const candidate = path.resolve(resolvedBase, relativePath);
|
|
332
|
+
if (candidate !== resolvedBase && !candidate.startsWith(`${resolvedBase}${path.sep}`)) {
|
|
333
|
+
throw new McpToolError({
|
|
334
|
+
code: "LOCAL_IO_ERROR",
|
|
335
|
+
message: `Unsafe output path: ${relativePath}`,
|
|
336
|
+
retryable: false
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return candidate;
|
|
340
|
+
}
|
|
341
|
+
function truncateUtf8(content, maxBytes) {
|
|
342
|
+
const totalBytes = Buffer.byteLength(content, "utf8");
|
|
343
|
+
if (totalBytes <= maxBytes) {
|
|
344
|
+
return { value: content, truncated: false, bytes: totalBytes };
|
|
345
|
+
}
|
|
346
|
+
let low = 0;
|
|
347
|
+
let high = content.length;
|
|
348
|
+
while (low < high) {
|
|
349
|
+
const mid = Math.ceil((low + high) / 2);
|
|
350
|
+
const candidateBytes = Buffer.byteLength(content.slice(0, mid), "utf8");
|
|
351
|
+
if (candidateBytes <= maxBytes) {
|
|
352
|
+
low = mid;
|
|
353
|
+
} else {
|
|
354
|
+
high = mid - 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const value = content.slice(0, low);
|
|
358
|
+
return {
|
|
359
|
+
value,
|
|
360
|
+
truncated: true,
|
|
361
|
+
bytes: Buffer.byteLength(value, "utf8")
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function inferContentType(filePath) {
|
|
365
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
366
|
+
if (ext === ".md") return "text/markdown";
|
|
367
|
+
if (ext === ".json") return "application/json";
|
|
368
|
+
if (ext === ".txt") return "text/plain";
|
|
369
|
+
return "text/plain";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/tools/create-shelf.ts
|
|
373
|
+
var MAX_PDF_BYTES = 300 * 1024 * 1024;
|
|
374
|
+
var inputSchema = {
|
|
375
|
+
pdf_path: z.string().min(1),
|
|
376
|
+
name: z.string().min(1).max(100).optional(),
|
|
377
|
+
template: z.enum(["book", "legal-contract", "academic-paper"]).optional(),
|
|
378
|
+
review: z.boolean().optional()
|
|
379
|
+
};
|
|
380
|
+
var outputSchema = {
|
|
381
|
+
shelf: z.object({
|
|
382
|
+
publicId: z.string(),
|
|
383
|
+
name: z.string(),
|
|
384
|
+
status: z.string(),
|
|
385
|
+
template: z.string().nullable(),
|
|
386
|
+
pageCount: z.number().nullable(),
|
|
387
|
+
reviewMode: z.boolean(),
|
|
388
|
+
createdAt: z.string(),
|
|
389
|
+
updatedAt: z.string()
|
|
390
|
+
})
|
|
391
|
+
};
|
|
392
|
+
async function assertPdfFile(filePath) {
|
|
393
|
+
const stats = await fs.stat(filePath).catch(() => null);
|
|
394
|
+
if (!stats || !stats.isFile()) {
|
|
395
|
+
throw new McpToolError({
|
|
396
|
+
code: "INPUT_ERROR",
|
|
397
|
+
message: "pdf_path must point to an existing file",
|
|
398
|
+
status: 400,
|
|
399
|
+
retryable: false
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (stats.size > MAX_PDF_BYTES) {
|
|
403
|
+
throw new McpToolError({
|
|
404
|
+
code: "INPUT_ERROR",
|
|
405
|
+
message: "PDF exceeds 300 MB limit",
|
|
406
|
+
status: 400,
|
|
407
|
+
retryable: false
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (path2.extname(filePath).toLowerCase() !== ".pdf") {
|
|
411
|
+
throw new McpToolError({
|
|
412
|
+
code: "INPUT_ERROR",
|
|
413
|
+
message: "Only .pdf files are supported",
|
|
414
|
+
status: 400,
|
|
415
|
+
retryable: false
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const handle = await fs.open(filePath, "r");
|
|
419
|
+
try {
|
|
420
|
+
const header = Buffer.alloc(5);
|
|
421
|
+
await handle.read(header, 0, 5, 0);
|
|
422
|
+
if (header.toString("utf8") !== "%PDF-") {
|
|
423
|
+
throw new McpToolError({
|
|
424
|
+
code: "INPUT_ERROR",
|
|
425
|
+
message: "File does not appear to be a valid PDF",
|
|
426
|
+
status: 400,
|
|
427
|
+
retryable: false
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
await handle.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function registerCreateShelfTool(server, context) {
|
|
435
|
+
server.registerTool(
|
|
436
|
+
"create_shelf",
|
|
437
|
+
{
|
|
438
|
+
title: "Create Shelf",
|
|
439
|
+
description: "Upload a local PDF and create a shelf",
|
|
440
|
+
inputSchema,
|
|
441
|
+
outputSchema,
|
|
442
|
+
annotations: { readOnlyHint: false }
|
|
443
|
+
},
|
|
444
|
+
async (input, extra) => {
|
|
445
|
+
try {
|
|
446
|
+
const absolutePath = path2.resolve(input.pdf_path);
|
|
447
|
+
await assertPdfFile(absolutePath);
|
|
448
|
+
const fileBytes = new Uint8Array(await fs.readFile(absolutePath));
|
|
449
|
+
const apiKey = context.getApiKey(extra);
|
|
450
|
+
const client = context.createHttpClient(apiKey);
|
|
451
|
+
const shelf = await client.createShelf({
|
|
452
|
+
pdfBytes: fileBytes,
|
|
453
|
+
fileName: path2.basename(absolutePath),
|
|
454
|
+
name: input.name,
|
|
455
|
+
template: input.template,
|
|
456
|
+
review: input.review
|
|
457
|
+
});
|
|
458
|
+
return successResult(`Created shelf ${shelf.publicId}`, { shelf });
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return errorResult(error);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/tools/get-shelf-tree.ts
|
|
467
|
+
import { z as z2 } from "zod";
|
|
468
|
+
var inputSchema2 = {
|
|
469
|
+
shelf_id: z2.string().min(1)
|
|
470
|
+
};
|
|
471
|
+
var outputSchema2 = {
|
|
472
|
+
shelf_id: z2.string(),
|
|
473
|
+
name: z2.string(),
|
|
474
|
+
file_count: z2.number(),
|
|
475
|
+
files: z2.record(z2.string())
|
|
476
|
+
};
|
|
477
|
+
function registerGetShelfTreeTool(server, context) {
|
|
478
|
+
server.registerTool(
|
|
479
|
+
"get_shelf_tree",
|
|
480
|
+
{
|
|
481
|
+
title: "Get Shelf Tree",
|
|
482
|
+
description: "Get the full file tree and file contents for a shelf",
|
|
483
|
+
inputSchema: inputSchema2,
|
|
484
|
+
outputSchema: outputSchema2,
|
|
485
|
+
annotations: { readOnlyHint: true }
|
|
486
|
+
},
|
|
487
|
+
async (input, extra) => {
|
|
488
|
+
try {
|
|
489
|
+
const apiKey = context.getApiKey(extra);
|
|
490
|
+
const client = context.createShelvClient(apiKey);
|
|
491
|
+
const tree = await client.getTree(input.shelf_id);
|
|
492
|
+
return successResult(
|
|
493
|
+
`Loaded ${tree.fileCount} files for shelf ${tree.shelfPublicId}`,
|
|
494
|
+
{
|
|
495
|
+
shelf_id: tree.shelfPublicId,
|
|
496
|
+
name: tree.name,
|
|
497
|
+
file_count: tree.fileCount,
|
|
498
|
+
files: tree.files
|
|
499
|
+
}
|
|
500
|
+
);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
return errorResult(error);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/tools/hydrate-shelf.ts
|
|
509
|
+
import fs2 from "fs/promises";
|
|
510
|
+
import path3 from "path";
|
|
511
|
+
import { resolveShelfSource } from "@shelv/adapters";
|
|
512
|
+
import { z as z3 } from "zod";
|
|
513
|
+
var inputSchema3 = {
|
|
514
|
+
shelf_id: z3.string().min(1),
|
|
515
|
+
target_dir: z3.string().min(1),
|
|
516
|
+
overwrite: z3.boolean().optional()
|
|
517
|
+
};
|
|
518
|
+
var outputSchema3 = {
|
|
519
|
+
shelf_id: z3.string(),
|
|
520
|
+
source_kind: z3.enum(["archive", "tree"]),
|
|
521
|
+
target_dir: z3.string(),
|
|
522
|
+
files_written: z3.number(),
|
|
523
|
+
bytes_written: z3.number(),
|
|
524
|
+
archive_version: z3.string().nullable()
|
|
525
|
+
};
|
|
526
|
+
function registerHydrateShelfTool(server, context) {
|
|
527
|
+
server.registerTool(
|
|
528
|
+
"hydrate_shelf",
|
|
529
|
+
{
|
|
530
|
+
title: "Hydrate Shelf",
|
|
531
|
+
description: "Download and write shelf files into a local directory",
|
|
532
|
+
inputSchema: inputSchema3,
|
|
533
|
+
outputSchema: outputSchema3,
|
|
534
|
+
annotations: { readOnlyHint: false }
|
|
535
|
+
},
|
|
536
|
+
async (input, extra) => {
|
|
537
|
+
try {
|
|
538
|
+
const apiKey = context.getApiKey(extra);
|
|
539
|
+
const source = await resolveShelfSource({
|
|
540
|
+
client: context.createShelvClient(apiKey),
|
|
541
|
+
shelfPublicId: input.shelf_id,
|
|
542
|
+
mode: "archive-first"
|
|
543
|
+
});
|
|
544
|
+
const overwrite = input.overwrite ?? false;
|
|
545
|
+
const targetDir = path3.resolve(input.target_dir);
|
|
546
|
+
await fs2.mkdir(targetDir, { recursive: true });
|
|
547
|
+
let filesWritten = 0;
|
|
548
|
+
let bytesWritten = 0;
|
|
549
|
+
for (const [relativePath, content] of Object.entries(source.files)) {
|
|
550
|
+
const normalized = ensureRelativePath(relativePath);
|
|
551
|
+
const fullPath = safeJoin(targetDir, normalized);
|
|
552
|
+
if (!overwrite) {
|
|
553
|
+
const exists = await fs2.access(fullPath).then(() => true).catch(() => false);
|
|
554
|
+
if (exists) {
|
|
555
|
+
throw new McpToolError({
|
|
556
|
+
code: "LOCAL_IO_ERROR",
|
|
557
|
+
message: `Refusing to overwrite existing file: ${normalized}`,
|
|
558
|
+
retryable: false
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
await fs2.mkdir(path3.dirname(fullPath), { recursive: true });
|
|
563
|
+
await fs2.writeFile(fullPath, content, "utf8");
|
|
564
|
+
filesWritten += 1;
|
|
565
|
+
bytesWritten += Buffer.byteLength(content, "utf8");
|
|
566
|
+
}
|
|
567
|
+
return successResult(
|
|
568
|
+
`Hydrated ${filesWritten} files to ${targetDir}`,
|
|
569
|
+
{
|
|
570
|
+
shelf_id: input.shelf_id,
|
|
571
|
+
source_kind: source.kind,
|
|
572
|
+
target_dir: targetDir,
|
|
573
|
+
files_written: filesWritten,
|
|
574
|
+
bytes_written: bytesWritten,
|
|
575
|
+
archive_version: source.kind === "archive" ? source.archiveVersion : null
|
|
576
|
+
}
|
|
577
|
+
);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
return errorResult(error);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/tools/list-shelves.ts
|
|
586
|
+
import { z as z4 } from "zod";
|
|
587
|
+
var inputSchema4 = {
|
|
588
|
+
page: z4.number().int().min(1).optional(),
|
|
589
|
+
limit: z4.number().int().min(1).max(100).optional()
|
|
590
|
+
};
|
|
591
|
+
var outputSchema4 = {
|
|
592
|
+
shelves: z4.array(
|
|
593
|
+
z4.object({
|
|
594
|
+
publicId: z4.string(),
|
|
595
|
+
name: z4.string(),
|
|
596
|
+
status: z4.string(),
|
|
597
|
+
template: z4.string().nullable(),
|
|
598
|
+
pageCount: z4.number().nullable(),
|
|
599
|
+
reviewMode: z4.boolean(),
|
|
600
|
+
createdAt: z4.string(),
|
|
601
|
+
updatedAt: z4.string()
|
|
602
|
+
})
|
|
603
|
+
),
|
|
604
|
+
pagination: z4.object({
|
|
605
|
+
page: z4.number(),
|
|
606
|
+
limit: z4.number(),
|
|
607
|
+
total: z4.number(),
|
|
608
|
+
totalPages: z4.number()
|
|
609
|
+
})
|
|
610
|
+
};
|
|
611
|
+
function registerListShelvesTool(server, context) {
|
|
612
|
+
server.registerTool(
|
|
613
|
+
"list_shelves",
|
|
614
|
+
{
|
|
615
|
+
title: "List Shelves",
|
|
616
|
+
description: "List shelves available to the authenticated user",
|
|
617
|
+
inputSchema: inputSchema4,
|
|
618
|
+
outputSchema: outputSchema4,
|
|
619
|
+
annotations: { readOnlyHint: true }
|
|
620
|
+
},
|
|
621
|
+
async (input, extra) => {
|
|
622
|
+
try {
|
|
623
|
+
const apiKey = context.getApiKey(extra);
|
|
624
|
+
const client = context.createHttpClient(apiKey);
|
|
625
|
+
const result = await client.listShelves({
|
|
626
|
+
page: input.page,
|
|
627
|
+
limit: input.limit
|
|
628
|
+
});
|
|
629
|
+
return successResult(
|
|
630
|
+
`Loaded ${result.data.length} shelves (page ${result.pagination.page}/${result.pagination.totalPages})`,
|
|
631
|
+
{
|
|
632
|
+
shelves: result.data,
|
|
633
|
+
pagination: result.pagination
|
|
634
|
+
}
|
|
635
|
+
);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
return errorResult(error);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/tools/read-shelf-file.ts
|
|
644
|
+
import { z as z5 } from "zod";
|
|
645
|
+
var inputSchema5 = {
|
|
646
|
+
shelf_id: z5.string().min(1),
|
|
647
|
+
path: z5.string().min(1)
|
|
648
|
+
};
|
|
649
|
+
var outputSchema5 = {
|
|
650
|
+
shelf_id: z5.string(),
|
|
651
|
+
path: z5.string(),
|
|
652
|
+
content_type: z5.string(),
|
|
653
|
+
content: z5.string(),
|
|
654
|
+
bytes: z5.number(),
|
|
655
|
+
truncated: z5.boolean()
|
|
656
|
+
};
|
|
657
|
+
function registerReadShelfFileTool(server, context) {
|
|
658
|
+
server.registerTool(
|
|
659
|
+
"read_shelf_file",
|
|
660
|
+
{
|
|
661
|
+
title: "Read Shelf File",
|
|
662
|
+
description: "Read a single file from a shelf",
|
|
663
|
+
inputSchema: inputSchema5,
|
|
664
|
+
outputSchema: outputSchema5,
|
|
665
|
+
annotations: { readOnlyHint: true }
|
|
666
|
+
},
|
|
667
|
+
async (input, extra) => {
|
|
668
|
+
try {
|
|
669
|
+
const normalizedPath = ensureRelativePath(input.path);
|
|
670
|
+
const apiKey = context.getApiKey(extra);
|
|
671
|
+
const client = context.createShelvClient(apiKey);
|
|
672
|
+
const raw = await client.getFile(input.shelf_id, normalizedPath);
|
|
673
|
+
const truncated = truncateUtf8(raw, context.config.readMaxBytes);
|
|
674
|
+
return successResult(
|
|
675
|
+
truncated.truncated ? `Read ${normalizedPath} (truncated to ${truncated.bytes} bytes)` : `Read ${normalizedPath}`,
|
|
676
|
+
{
|
|
677
|
+
shelf_id: input.shelf_id,
|
|
678
|
+
path: normalizedPath,
|
|
679
|
+
content_type: inferContentType(normalizedPath),
|
|
680
|
+
content: truncated.value,
|
|
681
|
+
bytes: truncated.bytes,
|
|
682
|
+
truncated: truncated.truncated
|
|
683
|
+
}
|
|
684
|
+
);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
return errorResult(error);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/tools/search-shelf.ts
|
|
693
|
+
import { resolveShelfSource as resolveShelfSource2 } from "@shelv/adapters";
|
|
694
|
+
import { z as z6 } from "zod";
|
|
695
|
+
var inputSchema6 = {
|
|
696
|
+
shelf_id: z6.string().min(1),
|
|
697
|
+
query: z6.string().min(1),
|
|
698
|
+
mode: z6.enum(["substring", "regex"]).optional(),
|
|
699
|
+
case_sensitive: z6.boolean().optional(),
|
|
700
|
+
max_matches: z6.number().int().min(1).optional()
|
|
701
|
+
};
|
|
702
|
+
var outputSchema6 = {
|
|
703
|
+
shelf_id: z6.string(),
|
|
704
|
+
query: z6.string(),
|
|
705
|
+
mode: z6.enum(["substring", "regex"]),
|
|
706
|
+
case_sensitive: z6.boolean(),
|
|
707
|
+
matches: z6.array(
|
|
708
|
+
z6.object({
|
|
709
|
+
path: z6.string(),
|
|
710
|
+
line: z6.string(),
|
|
711
|
+
line_number: z6.number(),
|
|
712
|
+
snippet: z6.string()
|
|
713
|
+
})
|
|
714
|
+
),
|
|
715
|
+
scanned_files: z6.number(),
|
|
716
|
+
scanned_bytes: z6.number(),
|
|
717
|
+
truncated: z6.boolean()
|
|
718
|
+
};
|
|
719
|
+
function buildMatcher(query, mode, caseSensitive) {
|
|
720
|
+
if (mode === "regex") {
|
|
721
|
+
let regex;
|
|
722
|
+
try {
|
|
723
|
+
regex = new RegExp(query, caseSensitive ? "" : "i");
|
|
724
|
+
} catch {
|
|
725
|
+
throw new McpToolError({
|
|
726
|
+
code: "INPUT_ERROR",
|
|
727
|
+
message: "Invalid regular expression",
|
|
728
|
+
status: 400,
|
|
729
|
+
retryable: false
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return (line) => regex.test(line);
|
|
733
|
+
}
|
|
734
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
735
|
+
return (line) => {
|
|
736
|
+
const haystack = caseSensitive ? line : line.toLowerCase();
|
|
737
|
+
return haystack.includes(needle);
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function registerSearchShelfTool(server, context) {
|
|
741
|
+
server.registerTool(
|
|
742
|
+
"search_shelf",
|
|
743
|
+
{
|
|
744
|
+
title: "Search Shelf",
|
|
745
|
+
description: "Search for text across files in a shelf",
|
|
746
|
+
inputSchema: inputSchema6,
|
|
747
|
+
outputSchema: outputSchema6,
|
|
748
|
+
annotations: { readOnlyHint: true }
|
|
749
|
+
},
|
|
750
|
+
async (input, extra) => {
|
|
751
|
+
try {
|
|
752
|
+
const mode = input.mode ?? "substring";
|
|
753
|
+
const caseSensitive = input.case_sensitive ?? false;
|
|
754
|
+
const apiKey = context.getApiKey(extra);
|
|
755
|
+
const source = await resolveShelfSource2({
|
|
756
|
+
client: context.createShelvClient(apiKey),
|
|
757
|
+
shelfPublicId: input.shelf_id,
|
|
758
|
+
mode: "archive-first"
|
|
759
|
+
});
|
|
760
|
+
const matcher = buildMatcher(input.query, mode, caseSensitive);
|
|
761
|
+
const maxMatches = Math.min(
|
|
762
|
+
input.max_matches ?? context.config.searchMaxMatches,
|
|
763
|
+
context.config.searchMaxMatches
|
|
764
|
+
);
|
|
765
|
+
const matches = [];
|
|
766
|
+
let scannedFiles = 0;
|
|
767
|
+
let scannedBytes = 0;
|
|
768
|
+
let truncated = false;
|
|
769
|
+
for (const [filePath, content] of Object.entries(source.files)) {
|
|
770
|
+
if (scannedFiles >= context.config.searchMaxFiles) {
|
|
771
|
+
truncated = true;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
775
|
+
if (scannedBytes + bytes > context.config.searchMaxBytes) {
|
|
776
|
+
truncated = true;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
scannedFiles += 1;
|
|
780
|
+
scannedBytes += bytes;
|
|
781
|
+
const lines = content.split(/\r?\n/);
|
|
782
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
783
|
+
const line = lines[index] || "";
|
|
784
|
+
if (!matcher(line)) continue;
|
|
785
|
+
matches.push({
|
|
786
|
+
path: filePath,
|
|
787
|
+
line,
|
|
788
|
+
line_number: index + 1,
|
|
789
|
+
snippet: line.slice(0, 300)
|
|
790
|
+
});
|
|
791
|
+
if (matches.length >= maxMatches) {
|
|
792
|
+
truncated = true;
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (truncated) break;
|
|
797
|
+
}
|
|
798
|
+
return successResult(
|
|
799
|
+
`Found ${matches.length} matches across ${scannedFiles} files`,
|
|
800
|
+
{
|
|
801
|
+
shelf_id: input.shelf_id,
|
|
802
|
+
query: input.query,
|
|
803
|
+
mode,
|
|
804
|
+
case_sensitive: caseSensitive,
|
|
805
|
+
matches,
|
|
806
|
+
scanned_files: scannedFiles,
|
|
807
|
+
scanned_bytes: scannedBytes,
|
|
808
|
+
truncated
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
return errorResult(error);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/tools/index.ts
|
|
819
|
+
function registerShelvTools(server, context) {
|
|
820
|
+
registerListShelvesTool(server, context);
|
|
821
|
+
registerGetShelfTreeTool(server, context);
|
|
822
|
+
registerReadShelfFileTool(server, context);
|
|
823
|
+
registerSearchShelfTool(server, context);
|
|
824
|
+
if (context.config.enableWriteTools) {
|
|
825
|
+
registerCreateShelfTool(server, context);
|
|
826
|
+
registerHydrateShelfTool(server, context);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/server.ts
|
|
831
|
+
function validateApiKey(token) {
|
|
832
|
+
const trimmed = token.trim();
|
|
833
|
+
if (!trimmed.startsWith("sk_")) {
|
|
834
|
+
throw new McpToolError({
|
|
835
|
+
code: "AUTH_ERROR",
|
|
836
|
+
message: "Shelv API key must use sk_ prefix",
|
|
837
|
+
status: 401,
|
|
838
|
+
retryable: false
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
return trimmed;
|
|
842
|
+
}
|
|
843
|
+
function createContext(config) {
|
|
844
|
+
return {
|
|
845
|
+
config,
|
|
846
|
+
getApiKey(extra) {
|
|
847
|
+
const authToken = extra?.authInfo?.token;
|
|
848
|
+
if (typeof authToken === "string" && authToken.trim().length > 0) {
|
|
849
|
+
return validateApiKey(authToken);
|
|
850
|
+
}
|
|
851
|
+
if (config.apiKey) {
|
|
852
|
+
return validateApiKey(config.apiKey);
|
|
853
|
+
}
|
|
854
|
+
throw new McpToolError({
|
|
855
|
+
code: "AUTH_ERROR",
|
|
856
|
+
message: "Missing Shelv API key. Set SHELV_API_KEY or provide Authorization bearer token.",
|
|
857
|
+
status: 401,
|
|
858
|
+
retryable: false
|
|
859
|
+
});
|
|
860
|
+
},
|
|
861
|
+
createShelvClient(apiKey) {
|
|
862
|
+
return createShelvClient({
|
|
863
|
+
apiKey,
|
|
864
|
+
apiBaseUrl: config.apiBaseUrl
|
|
865
|
+
});
|
|
866
|
+
},
|
|
867
|
+
createHttpClient(apiKey) {
|
|
868
|
+
return new ShelvHttpClient({
|
|
869
|
+
apiKey,
|
|
870
|
+
apiBaseUrl: config.apiBaseUrl
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function createShelvMcpRuntime(env = process.env) {
|
|
876
|
+
const config = loadConfig(env);
|
|
877
|
+
const context = createContext(config);
|
|
878
|
+
const server = new McpServer(
|
|
879
|
+
{
|
|
880
|
+
name: "shelv-mcp",
|
|
881
|
+
version: "0.1.0"
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
instructions: "Shelv MCP server for creating, listing, reading, searching, and hydrating document shelves."
|
|
885
|
+
}
|
|
886
|
+
);
|
|
887
|
+
registerShelvTools(server, context);
|
|
888
|
+
return { server, config };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/transports/http.ts
|
|
892
|
+
import { createServer } from "http";
|
|
893
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
894
|
+
var MCP_PATH = "/mcp";
|
|
895
|
+
function stripPort(value) {
|
|
896
|
+
const trimmed = value.trim();
|
|
897
|
+
if (trimmed.startsWith("[")) {
|
|
898
|
+
const end = trimmed.indexOf("]");
|
|
899
|
+
if (end >= 0) return trimmed.slice(1, end).toLowerCase();
|
|
900
|
+
}
|
|
901
|
+
const [host] = trimmed.split(":");
|
|
902
|
+
return (host || "").toLowerCase();
|
|
903
|
+
}
|
|
904
|
+
function getAllowedHosts(config) {
|
|
905
|
+
const configuredHost = stripPort(config.httpHost);
|
|
906
|
+
const hosts = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", configuredHost]);
|
|
907
|
+
return hosts;
|
|
908
|
+
}
|
|
909
|
+
function readJsonBody(req) {
|
|
910
|
+
return new Promise((resolve, reject) => {
|
|
911
|
+
const chunks = [];
|
|
912
|
+
req.on("data", (chunk) => {
|
|
913
|
+
chunks.push(chunk);
|
|
914
|
+
});
|
|
915
|
+
req.on("error", reject);
|
|
916
|
+
req.on("end", () => {
|
|
917
|
+
if (chunks.length === 0) {
|
|
918
|
+
resolve(void 0);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
922
|
+
try {
|
|
923
|
+
resolve(JSON.parse(raw));
|
|
924
|
+
} catch {
|
|
925
|
+
reject(new Error("Invalid JSON request body"));
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
function respondJson(res, status, payload) {
|
|
931
|
+
if (res.writableEnded) return;
|
|
932
|
+
const body = JSON.stringify(payload);
|
|
933
|
+
res.statusCode = status;
|
|
934
|
+
res.setHeader("content-type", "application/json");
|
|
935
|
+
res.end(body);
|
|
936
|
+
}
|
|
937
|
+
function validateHostAndOrigin(req, config) {
|
|
938
|
+
const allowedHosts = getAllowedHosts(config);
|
|
939
|
+
const hostHeader = req.headers.host;
|
|
940
|
+
if (hostHeader) {
|
|
941
|
+
const host = stripPort(hostHeader);
|
|
942
|
+
if (!allowedHosts.has(host)) {
|
|
943
|
+
return { ok: false, reason: "Host header is not allowed" };
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const origin = req.headers.origin;
|
|
947
|
+
if (origin) {
|
|
948
|
+
try {
|
|
949
|
+
const originHost = stripPort(new URL(origin).host);
|
|
950
|
+
if (!allowedHosts.has(originHost)) {
|
|
951
|
+
return { ok: false, reason: "Origin is not allowed" };
|
|
952
|
+
}
|
|
953
|
+
} catch {
|
|
954
|
+
return { ok: false, reason: "Invalid Origin header" };
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return { ok: true };
|
|
958
|
+
}
|
|
959
|
+
function parseAuth(req, config) {
|
|
960
|
+
const header = req.headers.authorization;
|
|
961
|
+
if (header === void 0 || header.trim() === "") {
|
|
962
|
+
if (config.apiKey) {
|
|
963
|
+
return {
|
|
964
|
+
ok: true,
|
|
965
|
+
auth: {
|
|
966
|
+
token: config.apiKey,
|
|
967
|
+
clientId: "env-fallback",
|
|
968
|
+
scopes: [],
|
|
969
|
+
expiresAt: void 0
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
ok: false,
|
|
975
|
+
reason: "Missing Authorization header"
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
if (!header.startsWith("Bearer ")) {
|
|
979
|
+
return {
|
|
980
|
+
ok: false,
|
|
981
|
+
reason: "Authorization must use Bearer token"
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const token = header.slice("Bearer ".length).trim();
|
|
985
|
+
if (!token.startsWith("sk_")) {
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
reason: "Shelv API key must use sk_ prefix"
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
ok: true,
|
|
993
|
+
auth: {
|
|
994
|
+
token,
|
|
995
|
+
clientId: "request-bearer",
|
|
996
|
+
scopes: [],
|
|
997
|
+
expiresAt: void 0
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
async function runHttpTransport(server, config) {
|
|
1002
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1003
|
+
sessionIdGenerator: void 0
|
|
1004
|
+
});
|
|
1005
|
+
await server.connect(transport);
|
|
1006
|
+
const httpServer = createServer(async (req, res) => {
|
|
1007
|
+
try {
|
|
1008
|
+
const requestPath = req.url ? new URL(req.url, "http://localhost").pathname : "/";
|
|
1009
|
+
if (requestPath !== MCP_PATH) {
|
|
1010
|
+
respondJson(res, 404, { error: "Not found" });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const method = req.method || "GET";
|
|
1014
|
+
if (!["GET", "POST", "DELETE"].includes(method)) {
|
|
1015
|
+
respondJson(res, 405, { error: "Method not allowed" });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const security = validateHostAndOrigin(req, config);
|
|
1019
|
+
if (!security.ok) {
|
|
1020
|
+
respondJson(res, 403, { error: security.reason });
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const auth = parseAuth(req, config);
|
|
1024
|
+
if (!auth.ok) {
|
|
1025
|
+
respondJson(res, 401, { error: auth.reason });
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const authenticatedRequest = req;
|
|
1029
|
+
authenticatedRequest.auth = auth.auth;
|
|
1030
|
+
const body = method === "POST" ? await readJsonBody(req) : void 0;
|
|
1031
|
+
await transport.handleRequest(authenticatedRequest, res, body);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
respondJson(res, 500, {
|
|
1034
|
+
error: error instanceof Error ? error.message : "Internal server error"
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
await new Promise((resolve, reject) => {
|
|
1039
|
+
httpServer.once("error", reject);
|
|
1040
|
+
httpServer.listen(config.httpPort, config.httpHost, () => {
|
|
1041
|
+
httpServer.off("error", reject);
|
|
1042
|
+
resolve();
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
const host = config.httpHost.includes(":") ? `[${config.httpHost}]` : config.httpHost;
|
|
1046
|
+
const url = `http://${host}:${config.httpPort}${MCP_PATH}`;
|
|
1047
|
+
return {
|
|
1048
|
+
server: httpServer,
|
|
1049
|
+
url,
|
|
1050
|
+
close() {
|
|
1051
|
+
return new Promise((resolve, reject) => {
|
|
1052
|
+
httpServer.close((error) => {
|
|
1053
|
+
if (error) {
|
|
1054
|
+
reject(error);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
resolve();
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/transports/stdio.ts
|
|
1065
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1066
|
+
async function runStdioTransport(server) {
|
|
1067
|
+
const transport = new StdioServerTransport();
|
|
1068
|
+
await server.connect(transport);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/bin/shelv-mcp.ts
|
|
1072
|
+
async function main() {
|
|
1073
|
+
const runtime = createShelvMcpRuntime();
|
|
1074
|
+
if (runtime.config.transport === "stdio") {
|
|
1075
|
+
if (!runtime.config.apiKey) {
|
|
1076
|
+
throw new Error("SHELV_API_KEY is required in stdio mode");
|
|
1077
|
+
}
|
|
1078
|
+
await runStdioTransport(runtime.server);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const http = await runHttpTransport(runtime.server, runtime.config);
|
|
1082
|
+
console.error(`shelv-mcp listening at ${http.url}`);
|
|
1083
|
+
console.error(
|
|
1084
|
+
runtime.config.enableWriteTools ? "write tools enabled" : "write tools disabled (set SHELV_MCP_ENABLE_WRITE_TOOLS=true to enable)"
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
main().catch((error) => {
|
|
1088
|
+
console.error(
|
|
1089
|
+
error instanceof Error ? error.message : "Failed to start shelv-mcp"
|
|
1090
|
+
);
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
});
|
|
1093
|
+
//# sourceMappingURL=shelv-mcp.js.map
|