@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.
@@ -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