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