slima-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1738 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+
9
+ // src/utils/errors.ts
10
+ var SlimaApiError = class extends Error {
11
+ constructor(status, code, message) {
12
+ super(message || `API Error: ${status}`);
13
+ this.status = status;
14
+ this.code = code;
15
+ this.name = "SlimaApiError";
16
+ }
17
+ };
18
+ var AuthenticationError = class extends SlimaApiError {
19
+ constructor(message = "Invalid or expired API token") {
20
+ super(401, "UNAUTHORIZED", message);
21
+ this.name = "AuthenticationError";
22
+ }
23
+ };
24
+ var NotFoundError = class extends SlimaApiError {
25
+ constructor(resource = "Resource") {
26
+ super(404, "NOT_FOUND", `${resource} not found`);
27
+ this.name = "NotFoundError";
28
+ }
29
+ };
30
+ var InsufficientCreditsError = class extends SlimaApiError {
31
+ constructor() {
32
+ super(402, "INSUFFICIENT_CREDITS", "Insufficient credits");
33
+ this.name = "InsufficientCreditsError";
34
+ }
35
+ };
36
+ function formatErrorForMcp(error) {
37
+ if (error instanceof SlimaApiError) {
38
+ switch (error.code) {
39
+ case "NOT_FOUND":
40
+ return "Resource not found. Please check the token.";
41
+ case "UNAUTHORIZED":
42
+ return "Invalid API token. Please check your configuration.";
43
+ case "INSUFFICIENT_CREDITS":
44
+ return "Insufficient credits. Please top up your account.";
45
+ case "TOKEN_EXPIRED":
46
+ return "API token has expired. Please generate a new one.";
47
+ case "TOKEN_REVOKED":
48
+ return "API token has been revoked. Please generate a new one.";
49
+ default:
50
+ return error.message;
51
+ }
52
+ }
53
+ if (error instanceof Error) {
54
+ return error.message;
55
+ }
56
+ return "An unexpected error occurred.";
57
+ }
58
+
59
+ // src/utils/logger.ts
60
+ var isDebug = process.env.DEBUG === "true" || process.env.DEBUG === "1";
61
+ var logger = {
62
+ info: (message, ...args) => {
63
+ console.error(`[INFO] ${message}`, ...args);
64
+ },
65
+ warn: (message, ...args) => {
66
+ console.error(`[WARN] ${message}`, ...args);
67
+ },
68
+ error: (message, error) => {
69
+ if (error instanceof Error) {
70
+ console.error(`[ERROR] ${message}:`, error.message);
71
+ if (isDebug && error.stack) {
72
+ console.error(error.stack);
73
+ }
74
+ } else if (error !== void 0) {
75
+ console.error(`[ERROR] ${message}:`, error);
76
+ } else {
77
+ console.error(`[ERROR] ${message}`);
78
+ }
79
+ },
80
+ debug: (message, ...args) => {
81
+ if (isDebug) {
82
+ console.error(`[DEBUG] ${message}`, ...args);
83
+ }
84
+ }
85
+ };
86
+
87
+ // src/api/client.ts
88
+ var SlimaApiClient = class {
89
+ baseUrl;
90
+ token;
91
+ constructor(config) {
92
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
93
+ this.token = config.token;
94
+ }
95
+ /**
96
+ * 發送 API 請求
97
+ */
98
+ async request(method, path, body) {
99
+ const url = `${this.baseUrl}${path}`;
100
+ logger.debug(`${method} ${path}`);
101
+ const response = await fetch(url, {
102
+ method,
103
+ headers: {
104
+ Authorization: `Bearer ${this.token}`,
105
+ "Content-Type": "application/json",
106
+ "User-Agent": "slima-mcp-server/0.1.0"
107
+ },
108
+ body: body ? JSON.stringify(body) : void 0
109
+ });
110
+ if (!response.ok) {
111
+ await this.handleErrorResponse(response);
112
+ }
113
+ const json = await response.json();
114
+ return json.data;
115
+ }
116
+ /**
117
+ * 處理錯誤回應
118
+ */
119
+ async handleErrorResponse(response) {
120
+ let errorData;
121
+ try {
122
+ errorData = await response.json();
123
+ } catch {
124
+ }
125
+ const code = errorData?.error?.code;
126
+ const message = errorData?.error?.message;
127
+ switch (response.status) {
128
+ case 401:
129
+ throw new AuthenticationError(message);
130
+ case 404:
131
+ throw new NotFoundError(message);
132
+ case 402:
133
+ throw new InsufficientCreditsError();
134
+ default:
135
+ throw new SlimaApiError(response.status, code, message);
136
+ }
137
+ }
138
+ // === 書籍相關 ===
139
+ /**
140
+ * 列出所有書籍
141
+ */
142
+ async listBooks() {
143
+ return this.request("GET", "/api/v1/books");
144
+ }
145
+ /**
146
+ * 取得書籍詳情
147
+ */
148
+ async getBook(token) {
149
+ return this.request("GET", `/api/v1/books/${token}`);
150
+ }
151
+ // === 版本控制相關 ===
152
+ /**
153
+ * 列出 Commits
154
+ * 注意:API 回傳 { commits: [...] },需要解構
155
+ */
156
+ async listCommits(bookToken, limit = 10) {
157
+ const response = await this.request(
158
+ "GET",
159
+ `/api/v1/books/${bookToken}/commits?limit=${limit}`
160
+ );
161
+ return response.commits;
162
+ }
163
+ /**
164
+ * 取得 Commit 詳情
165
+ */
166
+ async getCommit(bookToken, commitToken) {
167
+ return this.request(
168
+ "GET",
169
+ `/api/v1/books/${bookToken}/commits/${commitToken}`
170
+ );
171
+ }
172
+ /**
173
+ * 列出分支
174
+ */
175
+ async listBranches(bookToken) {
176
+ return this.request("GET", `/api/v1/books/${bookToken}/branches`);
177
+ }
178
+ /**
179
+ * 下載 Blobs
180
+ * 注意:API 回傳 { blobs: [...], notFound: [...], truncated: ... }
181
+ */
182
+ async downloadBlobs(bookToken, hashes) {
183
+ const response = await this.request(
184
+ "POST",
185
+ `/api/v1/books/${bookToken}/blobs/download`,
186
+ { hashes }
187
+ );
188
+ return response.blobs;
189
+ }
190
+ // === Persona 相關 ===
191
+ /**
192
+ * 列出所有 Personas
193
+ */
194
+ async listPersonas(genre) {
195
+ const params = genre ? `?genre=${encodeURIComponent(genre)}` : "";
196
+ return this.request("GET", `/api/v1/personas${params}`);
197
+ }
198
+ /**
199
+ * 取得 Persona 詳情
200
+ */
201
+ async getPersona(token) {
202
+ return this.request("GET", `/api/v1/personas/${token}`);
203
+ }
204
+ // === Reader Test 相關 ===
205
+ /**
206
+ * 建立 Reader Test
207
+ */
208
+ async createReaderTest(bookToken, params) {
209
+ return this.request(
210
+ "POST",
211
+ `/api/v1/books/${bookToken}/reader_tests`,
212
+ {
213
+ reader_test: {
214
+ report_type: params.reportType || "chapter",
215
+ persona_tokens: params.personaTokens,
216
+ commit_token: params.commitToken,
217
+ model: params.model,
218
+ scope_config: params.scopeConfig
219
+ },
220
+ content: params.content
221
+ }
222
+ );
223
+ }
224
+ /**
225
+ * 取得 Reader Test 進度
226
+ */
227
+ async getReaderTestProgress(bookToken, testToken) {
228
+ return this.request(
229
+ "GET",
230
+ `/api/v1/books/${bookToken}/reader_tests/${testToken}/progress`
231
+ );
232
+ }
233
+ /**
234
+ * 取得 Reader Test 結果
235
+ */
236
+ async getReaderTest(bookToken, testToken) {
237
+ return this.request(
238
+ "GET",
239
+ `/api/v1/books/${bookToken}/reader_tests/${testToken}`
240
+ );
241
+ }
242
+ // === MCP File Operations ===
243
+ /**
244
+ * 讀取檔案內容
245
+ */
246
+ async readFile(bookToken, path) {
247
+ return this.request(
248
+ "POST",
249
+ `/api/v1/books/${bookToken}/mcp/files/read`,
250
+ { path }
251
+ );
252
+ }
253
+ /**
254
+ * 建立新檔案
255
+ */
256
+ async createFile(bookToken, params) {
257
+ return this.request(
258
+ "POST",
259
+ `/api/v1/books/${bookToken}/mcp/files/create`,
260
+ {
261
+ path: params.path,
262
+ content: params.content || "",
263
+ parent_path: params.parentPath,
264
+ commit_message: params.commitMessage
265
+ }
266
+ );
267
+ }
268
+ /**
269
+ * 更新(覆寫)檔案內容
270
+ */
271
+ async updateFile(bookToken, params) {
272
+ return this.request(
273
+ "POST",
274
+ `/api/v1/books/${bookToken}/mcp/files/update`,
275
+ {
276
+ path: params.path,
277
+ content: params.content,
278
+ commit_message: params.commitMessage
279
+ }
280
+ );
281
+ }
282
+ /**
283
+ * 刪除檔案
284
+ */
285
+ async deleteFile(bookToken, params) {
286
+ return this.request(
287
+ "POST",
288
+ `/api/v1/books/${bookToken}/mcp/files/delete`,
289
+ {
290
+ path: params.path,
291
+ commit_message: params.commitMessage
292
+ }
293
+ );
294
+ }
295
+ /**
296
+ * 附加內容到檔案
297
+ */
298
+ async appendToFile(bookToken, params) {
299
+ return this.request(
300
+ "POST",
301
+ `/api/v1/books/${bookToken}/mcp/files/append`,
302
+ {
303
+ path: params.path,
304
+ content: params.content,
305
+ commit_message: params.commitMessage
306
+ }
307
+ );
308
+ }
309
+ /**
310
+ * 搜尋檔案內容
311
+ */
312
+ async searchFiles(bookToken, params) {
313
+ return this.request(
314
+ "POST",
315
+ `/api/v1/books/${bookToken}/mcp/files/search`,
316
+ {
317
+ query: params.query,
318
+ file_types: params.fileTypes,
319
+ limit: params.limit
320
+ }
321
+ );
322
+ }
323
+ };
324
+
325
+ // src/tools/books.ts
326
+ import { z } from "zod";
327
+ function registerBookTools(server, client) {
328
+ server.tool(
329
+ "list_books",
330
+ "List all books in your Slima library",
331
+ {},
332
+ async () => {
333
+ try {
334
+ const books = await client.listBooks();
335
+ if (books.length === 0) {
336
+ return {
337
+ content: [
338
+ {
339
+ type: "text",
340
+ text: "No books found in your library."
341
+ }
342
+ ]
343
+ };
344
+ }
345
+ const formatted = books.map((book) => {
346
+ const words = book.totalWordCount?.toLocaleString() || "0";
347
+ return `- **${book.title}** (${book.token})
348
+ Author: ${book.authorName || "N/A"} | Words: ${words}`;
349
+ }).join("\n\n");
350
+ return {
351
+ content: [
352
+ {
353
+ type: "text",
354
+ text: `Found ${books.length} book(s):
355
+
356
+ ${formatted}`
357
+ }
358
+ ]
359
+ };
360
+ } catch (error) {
361
+ return {
362
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
363
+ isError: true
364
+ };
365
+ }
366
+ }
367
+ );
368
+ server.tool(
369
+ "get_book",
370
+ "Get details of a specific book",
371
+ {
372
+ book_token: z.string().describe("Book token (e.g., bk_abc123)")
373
+ },
374
+ async ({ book_token }) => {
375
+ try {
376
+ const book = await client.getBook(book_token);
377
+ const text = `# ${book.title}
378
+
379
+ - **Token**: ${book.token}
380
+ - **Author**: ${book.authorName || "N/A"}
381
+ - **Description**: ${book.description || "N/A"}
382
+ - **Language**: ${book.language || "N/A"}
383
+ - **Total Words**: ${book.totalWordCount?.toLocaleString() || "0"}
384
+ - **Manuscript Words**: ${book.manuscriptWordCount?.toLocaleString() || "0"}
385
+ - **Created**: ${book.createdAt}
386
+ - **Updated**: ${book.updatedAt}`;
387
+ return {
388
+ content: [{ type: "text", text }]
389
+ };
390
+ } catch (error) {
391
+ return {
392
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
393
+ isError: true
394
+ };
395
+ }
396
+ }
397
+ );
398
+ server.tool(
399
+ "get_book_structure",
400
+ "Get the file/folder structure of a book",
401
+ {
402
+ book_token: z.string().describe("Book token")
403
+ },
404
+ async ({ book_token }) => {
405
+ try {
406
+ const commits = await client.listCommits(book_token, 1);
407
+ if (commits.length === 0) {
408
+ return {
409
+ content: [
410
+ {
411
+ type: "text",
412
+ text: "No commits found. The book may be empty."
413
+ }
414
+ ]
415
+ };
416
+ }
417
+ const snapshot = commits[0].filesSnapshot;
418
+ const tree = formatFileTree(snapshot);
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `File structure for book ${book_token}:
424
+
425
+ ${tree}`
426
+ }
427
+ ]
428
+ };
429
+ } catch (error) {
430
+ return {
431
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
432
+ isError: true
433
+ };
434
+ }
435
+ }
436
+ );
437
+ server.tool(
438
+ "get_writing_stats",
439
+ "Get writing statistics for a book",
440
+ {
441
+ book_token: z.string().describe("Book token")
442
+ },
443
+ async ({ book_token }) => {
444
+ try {
445
+ const [book, commits] = await Promise.all([
446
+ client.getBook(book_token),
447
+ client.listCommits(book_token, 10)
448
+ ]);
449
+ let wordChange = 0;
450
+ if (commits.length > 1) {
451
+ const latest = commits[0].totalWordCount || 0;
452
+ const oldest = commits[commits.length - 1].totalWordCount || 0;
453
+ wordChange = latest - oldest;
454
+ }
455
+ const sign = wordChange >= 0 ? "+" : "";
456
+ const progressText = commits.length > 1 ? `${sign}${wordChange.toLocaleString()} words (last ${commits.length} commits)` : "N/A (not enough history)";
457
+ const text = `# Writing Stats: ${book.title}
458
+
459
+ - **Total Words**: ${book.totalWordCount?.toLocaleString() || "0"}
460
+ - **Manuscript Words**: ${book.manuscriptWordCount?.toLocaleString() || "0"}
461
+ - **Recent Progress**: ${progressText}
462
+ - **Commits Analyzed**: ${commits.length}
463
+ - **Language**: ${book.language || "N/A"}`;
464
+ return {
465
+ content: [{ type: "text", text }]
466
+ };
467
+ } catch (error) {
468
+ return {
469
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
470
+ isError: true
471
+ };
472
+ }
473
+ }
474
+ );
475
+ }
476
+ function formatFileTree(files, indent = 0) {
477
+ return files.sort((a, b) => a.position - b.position).map((f) => {
478
+ const prefix = " ".repeat(indent);
479
+ const icon = f.kind === "folder" ? "\u{1F4C1}" : "\u{1F4C4}";
480
+ const words = f.kind === "file" && f.wordCount ? ` (${f.wordCount} words)` : "";
481
+ const manuscript = f.isManuscript ? " [M]" : "";
482
+ let line = `${prefix}${icon} ${f.name}${words}${manuscript}`;
483
+ if (f.children && f.children.length > 0) {
484
+ line += "\n" + formatFileTree(f.children, indent + 1);
485
+ }
486
+ return line;
487
+ }).join("\n");
488
+ }
489
+
490
+ // src/tools/content.ts
491
+ import { z as z2 } from "zod";
492
+ function registerContentTools(server, client) {
493
+ server.tool(
494
+ "get_chapter",
495
+ "Read the content of a chapter or file from a book",
496
+ {
497
+ book_token: z2.string().describe("Book token (e.g., bk_abc123)"),
498
+ file_path: z2.string().describe("File path (e.g., /chapters/01.md or chapter-01)")
499
+ },
500
+ async ({ book_token, file_path }) => {
501
+ try {
502
+ const commits = await client.listCommits(book_token, 1);
503
+ if (commits.length === 0) {
504
+ return {
505
+ content: [
506
+ {
507
+ type: "text",
508
+ text: "No commits found. The book may be empty."
509
+ }
510
+ ]
511
+ };
512
+ }
513
+ const file = findFileInSnapshot(commits[0].filesSnapshot, file_path);
514
+ if (!file) {
515
+ const availableFiles = listAllFiles(commits[0].filesSnapshot);
516
+ const fileList = availableFiles.slice(0, 10).join("\n- ");
517
+ const moreText = availableFiles.length > 10 ? `
518
+ ... and ${availableFiles.length - 10} more files` : "";
519
+ return {
520
+ content: [
521
+ {
522
+ type: "text",
523
+ text: `File not found: ${file_path}
524
+
525
+ Available files:
526
+ - ${fileList}${moreText}
527
+
528
+ Tip: Use get_book_structure to see the full file tree.`
529
+ }
530
+ ],
531
+ isError: true
532
+ };
533
+ }
534
+ if (file.kind === "folder") {
535
+ return {
536
+ content: [
537
+ {
538
+ type: "text",
539
+ text: `"${file_path}" is a folder, not a file. Please specify a file path.`
540
+ }
541
+ ],
542
+ isError: true
543
+ };
544
+ }
545
+ if (!file.blobHash) {
546
+ return {
547
+ content: [
548
+ {
549
+ type: "text",
550
+ text: `File "${file_path}" has no content (empty file).`
551
+ }
552
+ ]
553
+ };
554
+ }
555
+ const blobs = await client.downloadBlobs(book_token, [file.blobHash]);
556
+ if (blobs.length === 0) {
557
+ return {
558
+ content: [
559
+ {
560
+ type: "text",
561
+ text: "Could not retrieve file content. The blob may be missing."
562
+ }
563
+ ],
564
+ isError: true
565
+ };
566
+ }
567
+ const content = blobs[0].content;
568
+ const wordInfo = file.wordCount ? ` (${file.wordCount} words)` : "";
569
+ return {
570
+ content: [
571
+ {
572
+ type: "text",
573
+ text: `# ${file.name}${wordInfo}
574
+
575
+ ${content}`
576
+ }
577
+ ]
578
+ };
579
+ } catch (error) {
580
+ return {
581
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
582
+ isError: true
583
+ };
584
+ }
585
+ }
586
+ );
587
+ }
588
+ function findFileInSnapshot(files, path) {
589
+ const normalizedPath = path.replace(/^\//, "").toLowerCase();
590
+ const pathParts = normalizedPath.split("/");
591
+ function search(items, remainingParts) {
592
+ for (const item of items) {
593
+ const itemNameLower = item.name.toLowerCase();
594
+ const itemTokenLower = item.token.toLowerCase();
595
+ if (remainingParts.length === 1) {
596
+ const target = remainingParts[0];
597
+ if (itemNameLower === target || itemTokenLower === target || itemNameLower.replace(/\.[^.]+$/, "") === target.replace(/\.[^.]+$/, "")) {
598
+ return item;
599
+ }
600
+ }
601
+ if (item.kind === "folder" && item.children) {
602
+ if (itemNameLower === remainingParts[0] || itemTokenLower === remainingParts[0]) {
603
+ const result3 = search(item.children, remainingParts.slice(1));
604
+ if (result3) return result3;
605
+ }
606
+ const result2 = search(item.children, remainingParts);
607
+ if (result2) return result2;
608
+ }
609
+ }
610
+ return null;
611
+ }
612
+ let result = search(files, pathParts);
613
+ if (result) return result;
614
+ result = searchByName(files, pathParts[pathParts.length - 1]);
615
+ return result;
616
+ }
617
+ function searchByName(files, name) {
618
+ const nameLower = name.toLowerCase();
619
+ for (const item of files) {
620
+ const itemNameLower = item.name.toLowerCase();
621
+ const itemTokenLower = item.token.toLowerCase();
622
+ if (itemNameLower === nameLower || itemTokenLower === nameLower || itemNameLower.replace(/\.[^.]+$/, "") === nameLower.replace(/\.[^.]+$/, "")) {
623
+ return item;
624
+ }
625
+ if (item.kind === "folder" && item.children) {
626
+ const result = searchByName(item.children, name);
627
+ if (result) return result;
628
+ }
629
+ }
630
+ return null;
631
+ }
632
+ function listAllFiles(files, prefix = "") {
633
+ const result = [];
634
+ for (const item of files) {
635
+ const path = prefix ? `${prefix}/${item.name}` : item.name;
636
+ if (item.kind === "file") {
637
+ result.push(path);
638
+ } else if (item.children) {
639
+ result.push(...listAllFiles(item.children, path));
640
+ }
641
+ }
642
+ return result;
643
+ }
644
+
645
+ // src/tools/beta-reader.ts
646
+ import { z as z3 } from "zod";
647
+ function registerBetaReaderTools(server, client) {
648
+ server.tool(
649
+ "list_personas",
650
+ "List available virtual reader personas for beta reading feedback",
651
+ {
652
+ genre: z3.string().optional().describe("Filter by genre (e.g., fantasy, romance, scifi)")
653
+ },
654
+ async ({ genre }) => {
655
+ try {
656
+ const personas = await client.listPersonas(genre);
657
+ if (personas.length === 0) {
658
+ const genreText = genre ? ` for genre "${genre}"` : "";
659
+ return {
660
+ content: [
661
+ {
662
+ type: "text",
663
+ text: `No personas found${genreText}.`
664
+ }
665
+ ]
666
+ };
667
+ }
668
+ const formatted = personas.map((p) => {
669
+ const name = p.displayLabels?.zh || p.displayLabels?.en || p.slug;
670
+ const tags = p.genreTags?.join(", ") || "General";
671
+ const demo = p.demographics || {};
672
+ const gender = demo.gender || "N/A";
673
+ const age = demo.ageRange || "N/A";
674
+ return `- **${name}** (\`${p.token}\`)
675
+ Gender: ${gender} | Age: ${age}
676
+ Genres: ${tags}`;
677
+ }).join("\n\n");
678
+ return {
679
+ content: [
680
+ {
681
+ type: "text",
682
+ text: `Available Personas (${personas.length}):
683
+
684
+ ${formatted}
685
+
686
+ **Tip**: Use the persona token with \`analyze_chapter\` to get feedback from a specific reader.`
687
+ }
688
+ ]
689
+ };
690
+ } catch (error) {
691
+ return {
692
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
693
+ isError: true
694
+ };
695
+ }
696
+ }
697
+ );
698
+ server.tool(
699
+ "analyze_chapter",
700
+ "Get AI Beta Reader feedback on a chapter from a specific reader persona. This may take a moment to complete.",
701
+ {
702
+ book_token: z3.string().describe("Book token (e.g., bk_abc123)"),
703
+ file_path: z3.string().describe("Chapter file path (e.g., /chapters/01.md)"),
704
+ persona_token: z3.string().describe("Persona token (e.g., psn_xxx). Use list_personas to find available personas.")
705
+ },
706
+ async ({ book_token, file_path, persona_token }) => {
707
+ try {
708
+ const commits = await client.listCommits(book_token, 1);
709
+ if (commits.length === 0) {
710
+ return {
711
+ content: [
712
+ {
713
+ type: "text",
714
+ text: "No commits found. The book may be empty."
715
+ }
716
+ ],
717
+ isError: true
718
+ };
719
+ }
720
+ const file = findFileInSnapshot2(commits[0].filesSnapshot, file_path);
721
+ if (!file) {
722
+ return {
723
+ content: [
724
+ {
725
+ type: "text",
726
+ text: `File not found: ${file_path}. Use get_book_structure to see available files.`
727
+ }
728
+ ],
729
+ isError: true
730
+ };
731
+ }
732
+ if (!file.blobHash) {
733
+ return {
734
+ content: [
735
+ {
736
+ type: "text",
737
+ text: `File "${file_path}" has no content (empty file).`
738
+ }
739
+ ],
740
+ isError: true
741
+ };
742
+ }
743
+ const blobs = await client.downloadBlobs(book_token, [file.blobHash]);
744
+ if (blobs.length === 0) {
745
+ return {
746
+ content: [
747
+ {
748
+ type: "text",
749
+ text: "Could not retrieve file content."
750
+ }
751
+ ],
752
+ isError: true
753
+ };
754
+ }
755
+ const content = blobs[0].content;
756
+ logger.info(`Creating reader test for ${file_path} with persona ${persona_token}`);
757
+ const test = await client.createReaderTest(book_token, {
758
+ reportType: "chapter",
759
+ personaTokens: [persona_token],
760
+ commitToken: commits[0].token,
761
+ scopeConfig: { chapters: [file_path] },
762
+ content
763
+ });
764
+ const result = await pollForCompletion(
765
+ () => client.getReaderTestProgress(book_token, test.token),
766
+ 9e4
767
+ );
768
+ if (result.status === "failed") {
769
+ return {
770
+ content: [
771
+ {
772
+ type: "text",
773
+ text: "Analysis failed. Please try again later."
774
+ }
775
+ ],
776
+ isError: true
777
+ };
778
+ }
779
+ if (result.status !== "completed") {
780
+ return {
781
+ content: [
782
+ {
783
+ type: "text",
784
+ text: `Analysis is still in progress. You can check the status later.
785
+
786
+ Test ID: ${test.token}`
787
+ }
788
+ ]
789
+ };
790
+ }
791
+ const fullResult = await client.getReaderTest(book_token, test.token);
792
+ const feedback = formatBetaReaderFeedback(fullResult, file.name);
793
+ return {
794
+ content: [{ type: "text", text: feedback }]
795
+ };
796
+ } catch (error) {
797
+ return {
798
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
799
+ isError: true
800
+ };
801
+ }
802
+ }
803
+ );
804
+ }
805
+ async function pollForCompletion(checkFn, timeoutMs) {
806
+ const startTime = Date.now();
807
+ const pollInterval = 3e3;
808
+ while (Date.now() - startTime < timeoutMs) {
809
+ const result = await checkFn();
810
+ logger.debug(`Poll status: ${result.status}, progress: ${result.progress || "N/A"}`);
811
+ if (result.status === "completed" || result.status === "failed") {
812
+ return result;
813
+ }
814
+ await sleep(pollInterval);
815
+ }
816
+ return { status: "timeout" };
817
+ }
818
+ function sleep(ms) {
819
+ return new Promise((resolve) => setTimeout(resolve, ms));
820
+ }
821
+ function formatBetaReaderFeedback(test, fileName) {
822
+ const feedbacks = test.individualFeedbacks || [];
823
+ const metrics = test.aggregatedMetrics;
824
+ if (feedbacks.length === 0 && !metrics) {
825
+ return "No feedback available.";
826
+ }
827
+ let output = `# Beta Reader Feedback: ${fileName}
828
+
829
+ `;
830
+ if (metrics) {
831
+ output += formatAggregatedMetrics(metrics);
832
+ }
833
+ if (feedbacks.length > 0) {
834
+ output += `
835
+ ---
836
+
837
+ ## Individual Reader Feedback
838
+
839
+ `;
840
+ output += feedbacks.map(formatIndividualFeedback).join("\n---\n\n");
841
+ }
842
+ return output;
843
+ }
844
+ function formatAggregatedMetrics(metrics) {
845
+ let output = `## Summary Metrics
846
+
847
+ `;
848
+ if (metrics.overall) {
849
+ const o = metrics.overall;
850
+ output += `### Overall Scores
851
+ `;
852
+ if (o.avgContinueReading) output += `- Continue Reading Intent: ${o.avgContinueReading.toFixed(1)}/10
853
+ `;
854
+ if (o.avgRecommendation) output += `- Recommendation Score: ${o.avgRecommendation.toFixed(1)}/10
855
+ `;
856
+ if (o.avgWantMore) output += `- Want More: ${o.avgWantMore.toFixed(1)}/10
857
+ `;
858
+ if (o.dnfRisk) output += `- DNF Risk: ${o.dnfRisk}
859
+ `;
860
+ output += "\n";
861
+ }
862
+ if (metrics.kindleRating) {
863
+ output += `### Predicted Kindle Rating
864
+ `;
865
+ output += `- Average Score: \u2B50 ${metrics.kindleRating.avgScore.toFixed(1)}/5
866
+ `;
867
+ if (metrics.kindleRating.individualScores) {
868
+ const scores = metrics.kindleRating.individualScores.map((s) => `${s.personaName || "Reader"}: ${s.score}/5 (${s.confidence})`).join(", ");
869
+ output += `- Individual Scores: ${scores}
870
+ `;
871
+ }
872
+ output += "\n";
873
+ }
874
+ if (metrics.characters) {
875
+ const c = metrics.characters;
876
+ output += `### Character Evaluation
877
+ `;
878
+ if (c.avgProtagonistLikability) output += `- Protagonist Likability: ${c.avgProtagonistLikability.toFixed(1)}/10
879
+ `;
880
+ if (c.avgMotivationClarity) output += `- Motivation Clarity: ${c.avgMotivationClarity.toFixed(1)}/10
881
+ `;
882
+ if (c.avgDialogueNaturalness) output += `- Dialogue Naturalness: ${c.avgDialogueNaturalness.toFixed(1)}/10
883
+ `;
884
+ output += "\n";
885
+ }
886
+ if (metrics.pacing) {
887
+ const p = metrics.pacing;
888
+ output += `### Pacing Evaluation
889
+ `;
890
+ if (p.avgOverall) output += `- Overall Pacing: ${p.avgOverall.toFixed(1)}/10
891
+ `;
892
+ if (p.consensus) output += `- Consensus: ${p.consensus}
893
+ `;
894
+ if (p.commonDnfTriggers && p.commonDnfTriggers.length > 0) {
895
+ output += `- Common DNF Triggers:
896
+ `;
897
+ p.commonDnfTriggers.slice(0, 3).forEach((t) => {
898
+ output += ` - ${t.trigger} (${t.count}x)
899
+ `;
900
+ });
901
+ }
902
+ output += "\n";
903
+ }
904
+ if (metrics.market) {
905
+ const m = metrics.market;
906
+ output += `### Market Positioning
907
+ `;
908
+ if (m.avgWordOfMouth) output += `- Word of Mouth Potential: ${m.avgWordOfMouth.toFixed(1)}/10
909
+ `;
910
+ if (m.topComparableBooks && m.topComparableBooks.length > 0) {
911
+ const books = m.topComparableBooks.slice(0, 3).map((b) => b.title).join(", ");
912
+ output += `- Comparable Books: ${books}
913
+ `;
914
+ }
915
+ output += "\n";
916
+ }
917
+ return output;
918
+ }
919
+ function formatIndividualFeedback(feedback) {
920
+ const name = feedback.personaName || feedback.personaToken;
921
+ let output = `### ${name}
922
+
923
+ `;
924
+ if (feedback.overall) {
925
+ const o = feedback.overall;
926
+ if (o.continueReading) output += `**Continue Reading Intent**: ${o.continueReading}/10
927
+ `;
928
+ if (o.recommendation) output += `**Recommendation**: ${o.recommendation}/10
929
+ `;
930
+ if (o.dnfRisk) output += `**DNF Risk**: ${o.dnfRisk}
931
+ `;
932
+ output += "\n";
933
+ }
934
+ if (feedback.kindleRating) {
935
+ output += `**Kindle Rating**: \u2B50 ${feedback.kindleRating.score}/5`;
936
+ if (feedback.kindleRating.rationale) {
937
+ output += ` - ${feedback.kindleRating.rationale}`;
938
+ }
939
+ output += "\n\n";
940
+ }
941
+ if (feedback.detailedFeedback) {
942
+ const d = feedback.detailedFeedback;
943
+ if (d.whatWorked) {
944
+ output += `**What Worked**:
945
+ ${d.whatWorked}
946
+
947
+ `;
948
+ }
949
+ if (d.whatDidntWork) {
950
+ output += `**What Didn't Work**:
951
+ ${d.whatDidntWork}
952
+
953
+ `;
954
+ }
955
+ if (d.strongestElement) {
956
+ output += `**Strongest Element**: ${d.strongestElement}
957
+ `;
958
+ }
959
+ if (d.weakestElement) {
960
+ output += `**Weakest Element**: ${d.weakestElement}
961
+ `;
962
+ }
963
+ if (d.wouldYouBuy) {
964
+ output += `**Would You Buy**: ${d.wouldYouBuy}
965
+ `;
966
+ }
967
+ if (d.specificSuggestions && d.specificSuggestions.length > 0) {
968
+ output += `
969
+ **Specific Suggestions**:
970
+ `;
971
+ d.specificSuggestions.forEach((s, i) => {
972
+ output += `${i + 1}. ${s}
973
+ `;
974
+ });
975
+ }
976
+ if (d.finalThoughts) {
977
+ output += `
978
+ **Final Thoughts**:
979
+ ${d.finalThoughts}
980
+ `;
981
+ }
982
+ }
983
+ if (feedback.emotionalJourney) {
984
+ const e = feedback.emotionalJourney;
985
+ if (e.emotionalArc) {
986
+ output += `
987
+ **Emotional Arc**: ${e.emotionalArc}
988
+ `;
989
+ }
990
+ if (e.peakMoments && e.peakMoments.length > 0) {
991
+ const moments = e.peakMoments.map(
992
+ (m) => typeof m === "string" ? m : m.moment || m.description || JSON.stringify(m)
993
+ );
994
+ output += `**Peak Moments**: ${moments.join(", ")}
995
+ `;
996
+ }
997
+ if (e.overallMood) {
998
+ output += `**Overall Mood**: ${e.overallMood}
999
+ `;
1000
+ }
1001
+ }
1002
+ return output;
1003
+ }
1004
+ function findFileInSnapshot2(files, path) {
1005
+ const normalizedPath = path.replace(/^\//, "").toLowerCase();
1006
+ const pathParts = normalizedPath.split("/");
1007
+ function search(items, remainingParts) {
1008
+ for (const item of items) {
1009
+ const itemNameLower = item.name.toLowerCase();
1010
+ const itemTokenLower = item.token.toLowerCase();
1011
+ if (remainingParts.length === 1) {
1012
+ const target = remainingParts[0];
1013
+ if (itemNameLower === target || itemTokenLower === target || itemNameLower.replace(/\.[^.]+$/, "") === target.replace(/\.[^.]+$/, "")) {
1014
+ return item;
1015
+ }
1016
+ }
1017
+ if (item.kind === "folder" && item.children) {
1018
+ if (itemNameLower === remainingParts[0] || itemTokenLower === remainingParts[0]) {
1019
+ const result3 = search(item.children, remainingParts.slice(1));
1020
+ if (result3) return result3;
1021
+ }
1022
+ const result2 = search(item.children, remainingParts);
1023
+ if (result2) return result2;
1024
+ }
1025
+ }
1026
+ return null;
1027
+ }
1028
+ let result = search(files, pathParts);
1029
+ if (result) return result;
1030
+ result = searchByName2(files, pathParts[pathParts.length - 1]);
1031
+ return result;
1032
+ }
1033
+ function searchByName2(files, name) {
1034
+ const nameLower = name.toLowerCase();
1035
+ for (const item of files) {
1036
+ const itemNameLower = item.name.toLowerCase();
1037
+ const itemTokenLower = item.token.toLowerCase();
1038
+ if (itemNameLower === nameLower || itemTokenLower === nameLower || itemNameLower.replace(/\.[^.]+$/, "") === nameLower.replace(/\.[^.]+$/, "")) {
1039
+ return item;
1040
+ }
1041
+ if (item.kind === "folder" && item.children) {
1042
+ const result = searchByName2(item.children, name);
1043
+ if (result) return result;
1044
+ }
1045
+ }
1046
+ return null;
1047
+ }
1048
+
1049
+ // src/tools/files.ts
1050
+ import { z as z4 } from "zod";
1051
+ function registerFileTools(server, client) {
1052
+ server.tool(
1053
+ "read_file",
1054
+ "Read the content of any file in a book by its path",
1055
+ {
1056
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1057
+ path: z4.string().describe('File path (e.g., "characters/protagonist.md" or "chapter-01.md")')
1058
+ },
1059
+ async ({ book_token, path }) => {
1060
+ try {
1061
+ const result = await client.readFile(book_token, path);
1062
+ const fileInfo = [
1063
+ `# ${result.file.name}`,
1064
+ "",
1065
+ `**Path:** ${result.file.path}`,
1066
+ `**Words:** ${result.file.wordCount}`,
1067
+ `**Type:** ${result.file.fileType || "unknown"}`,
1068
+ "",
1069
+ "---",
1070
+ "",
1071
+ result.content || "(empty file)"
1072
+ ].join("\n");
1073
+ return {
1074
+ content: [{ type: "text", text: fileInfo }]
1075
+ };
1076
+ } catch (error) {
1077
+ return {
1078
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1079
+ isError: true
1080
+ };
1081
+ }
1082
+ }
1083
+ );
1084
+ server.tool(
1085
+ "write_file",
1086
+ "Replace the entire content of an existing file (creates a new commit)",
1087
+ {
1088
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1089
+ path: z4.string().describe('File path (e.g., "characters/protagonist.md")'),
1090
+ content: z4.string().describe("New content to write"),
1091
+ commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1092
+ },
1093
+ async ({ book_token, path, content, commit_message }) => {
1094
+ try {
1095
+ const result = await client.updateFile(book_token, {
1096
+ path,
1097
+ content,
1098
+ commitMessage: commit_message
1099
+ });
1100
+ return {
1101
+ content: [
1102
+ {
1103
+ type: "text",
1104
+ text: `File updated successfully.
1105
+
1106
+ Commit: ${result.commit.token}
1107
+ Message: ${result.commit.name || result.commit.message}`
1108
+ }
1109
+ ]
1110
+ };
1111
+ } catch (error) {
1112
+ return {
1113
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1114
+ isError: true
1115
+ };
1116
+ }
1117
+ }
1118
+ );
1119
+ server.tool(
1120
+ "create_file",
1121
+ "Create a new file in a book (creates a new commit)",
1122
+ {
1123
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1124
+ path: z4.string().describe('File path including parent folders (e.g., "characters/new-character.md")'),
1125
+ content: z4.string().optional().describe("Initial content (optional, defaults to empty)"),
1126
+ commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1127
+ },
1128
+ async ({ book_token, path, content, commit_message }) => {
1129
+ try {
1130
+ const result = await client.createFile(book_token, {
1131
+ path,
1132
+ content: content || "",
1133
+ commitMessage: commit_message
1134
+ });
1135
+ return {
1136
+ content: [
1137
+ {
1138
+ type: "text",
1139
+ text: `File created successfully.
1140
+
1141
+ File Token: ${result.fileToken}
1142
+ Commit: ${result.commit.token}
1143
+ Message: ${result.commit.name || result.commit.message}`
1144
+ }
1145
+ ]
1146
+ };
1147
+ } catch (error) {
1148
+ return {
1149
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1150
+ isError: true
1151
+ };
1152
+ }
1153
+ }
1154
+ );
1155
+ server.tool(
1156
+ "delete_file",
1157
+ "Delete a file from a book (creates a new commit)",
1158
+ {
1159
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1160
+ path: z4.string().describe('File path to delete (e.g., "old-notes.md")'),
1161
+ commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1162
+ },
1163
+ async ({ book_token, path, commit_message }) => {
1164
+ try {
1165
+ const result = await client.deleteFile(book_token, {
1166
+ path,
1167
+ commitMessage: commit_message
1168
+ });
1169
+ return {
1170
+ content: [
1171
+ {
1172
+ type: "text",
1173
+ text: `File deleted successfully.
1174
+
1175
+ Commit: ${result.commit.token}
1176
+ Message: ${result.commit.name || result.commit.message}`
1177
+ }
1178
+ ]
1179
+ };
1180
+ } catch (error) {
1181
+ return {
1182
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1183
+ isError: true
1184
+ };
1185
+ }
1186
+ }
1187
+ );
1188
+ server.tool(
1189
+ "append_to_file",
1190
+ "Append content to the end of an existing file (creates a new commit)",
1191
+ {
1192
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1193
+ path: z4.string().describe('File path (e.g., "notes.md")'),
1194
+ content: z4.string().describe("Content to append"),
1195
+ commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1196
+ },
1197
+ async ({ book_token, path, content, commit_message }) => {
1198
+ try {
1199
+ const result = await client.appendToFile(book_token, {
1200
+ path,
1201
+ content,
1202
+ commitMessage: commit_message
1203
+ });
1204
+ return {
1205
+ content: [
1206
+ {
1207
+ type: "text",
1208
+ text: `Content appended successfully.
1209
+
1210
+ Commit: ${result.commit.token}
1211
+ Message: ${result.commit.name || result.commit.message}`
1212
+ }
1213
+ ]
1214
+ };
1215
+ } catch (error) {
1216
+ return {
1217
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1218
+ isError: true
1219
+ };
1220
+ }
1221
+ }
1222
+ );
1223
+ server.tool(
1224
+ "search_content",
1225
+ "Search for content across all files in a book",
1226
+ {
1227
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1228
+ query: z4.string().describe('Search query (e.g., "blue eyes" or "protagonist name")'),
1229
+ file_types: z4.array(z4.string()).optional().describe('Filter by file types (e.g., ["markdown", "txt"])'),
1230
+ limit: z4.number().optional().describe("Maximum number of matches to return (default: 20)")
1231
+ },
1232
+ async ({ book_token, query, file_types, limit }) => {
1233
+ try {
1234
+ const result = await client.searchFiles(book_token, {
1235
+ query,
1236
+ fileTypes: file_types,
1237
+ limit
1238
+ });
1239
+ if (result.matches.length === 0) {
1240
+ return {
1241
+ content: [
1242
+ {
1243
+ type: "text",
1244
+ text: `No matches found for "${query}".`
1245
+ }
1246
+ ]
1247
+ };
1248
+ }
1249
+ const output = [
1250
+ `# Search Results for "${query}"`,
1251
+ "",
1252
+ `Found ${result.matches.length} file(s) with matches:`,
1253
+ ""
1254
+ ];
1255
+ for (const match of result.matches) {
1256
+ output.push(`## ${match.file.path}`);
1257
+ output.push(`Words: ${match.file.wordCount} | Matches: ${match.matchCount}`);
1258
+ output.push("");
1259
+ for (const snippet of match.snippets) {
1260
+ output.push(`> ${snippet.text}`);
1261
+ output.push("");
1262
+ }
1263
+ }
1264
+ return {
1265
+ content: [{ type: "text", text: output.join("\n") }]
1266
+ };
1267
+ } catch (error) {
1268
+ return {
1269
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1270
+ isError: true
1271
+ };
1272
+ }
1273
+ }
1274
+ );
1275
+ }
1276
+
1277
+ // src/server.ts
1278
+ function createSlimaServer(config) {
1279
+ const client = new SlimaApiClient({
1280
+ token: config.apiToken,
1281
+ baseUrl: config.baseUrl || "https://api.slima.ai"
1282
+ });
1283
+ const server = new McpServer({
1284
+ name: "slima",
1285
+ version: "0.1.0"
1286
+ });
1287
+ registerBookTools(server, client);
1288
+ registerContentTools(server, client);
1289
+ registerBetaReaderTools(server, client);
1290
+ registerFileTools(server, client);
1291
+ logger.info("Slima MCP Server initialized");
1292
+ logger.info(`API endpoint: ${config.baseUrl || "https://api.slima.ai"}`);
1293
+ return server;
1294
+ }
1295
+
1296
+ // src/utils/token-storage.ts
1297
+ import { promises as fs } from "fs";
1298
+ import { homedir } from "os";
1299
+ import { join } from "path";
1300
+ var SLIMA_DIR = ".slima";
1301
+ var CREDENTIALS_FILE = "credentials.json";
1302
+ function getCredentialsPath() {
1303
+ return join(homedir(), SLIMA_DIR, CREDENTIALS_FILE);
1304
+ }
1305
+ function getSlimaDir() {
1306
+ return join(homedir(), SLIMA_DIR);
1307
+ }
1308
+ async function saveToken(apiToken, baseUrl) {
1309
+ const slimaDir = getSlimaDir();
1310
+ await fs.mkdir(slimaDir, { recursive: true });
1311
+ const credentials = {
1312
+ apiToken,
1313
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1314
+ ...baseUrl && { baseUrl }
1315
+ };
1316
+ const credentialsPath = getCredentialsPath();
1317
+ await fs.writeFile(credentialsPath, JSON.stringify(credentials, null, 2), "utf-8");
1318
+ await fs.chmod(credentialsPath, 384);
1319
+ }
1320
+ async function loadToken() {
1321
+ try {
1322
+ const credentialsPath = getCredentialsPath();
1323
+ const content = await fs.readFile(credentialsPath, "utf-8");
1324
+ return JSON.parse(content);
1325
+ } catch {
1326
+ return null;
1327
+ }
1328
+ }
1329
+ async function deleteToken() {
1330
+ try {
1331
+ const credentialsPath = getCredentialsPath();
1332
+ await fs.unlink(credentialsPath);
1333
+ } catch {
1334
+ }
1335
+ }
1336
+ function getCredentialsFilePath() {
1337
+ return getCredentialsPath();
1338
+ }
1339
+
1340
+ // src/cli/auth.ts
1341
+ import { createServer } from "http";
1342
+ import { URL } from "url";
1343
+ import { randomBytes } from "crypto";
1344
+ var DEFAULT_API_URL = "https://api.slima.ai";
1345
+ var CALLBACK_PORT_START = 8765;
1346
+ var CALLBACK_PORT_END = 8775;
1347
+ async function openBrowser(url) {
1348
+ const { exec } = await import("child_process");
1349
+ const { promisify } = await import("util");
1350
+ const execAsync = promisify(exec);
1351
+ const platform = process.platform;
1352
+ try {
1353
+ if (platform === "darwin") {
1354
+ await execAsync(`open "${url}"`);
1355
+ } else if (platform === "win32") {
1356
+ await execAsync(`start "" "${url}"`);
1357
+ } else {
1358
+ const commands = ["xdg-open", "sensible-browser", "wslview"];
1359
+ for (const cmd of commands) {
1360
+ try {
1361
+ await execAsync(`${cmd} "${url}"`);
1362
+ return;
1363
+ } catch {
1364
+ }
1365
+ }
1366
+ console.log(`
1367
+ Please open this URL in your browser:
1368
+ ${url}
1369
+ `);
1370
+ }
1371
+ } catch {
1372
+ console.log(`
1373
+ Please open this URL in your browser:
1374
+ ${url}
1375
+ `);
1376
+ }
1377
+ }
1378
+ async function findAvailablePort() {
1379
+ for (let port = CALLBACK_PORT_START; port <= CALLBACK_PORT_END; port++) {
1380
+ const available = await isPortAvailable(port);
1381
+ if (available) {
1382
+ return port;
1383
+ }
1384
+ }
1385
+ throw new Error(`No available ports between ${CALLBACK_PORT_START} and ${CALLBACK_PORT_END}`);
1386
+ }
1387
+ function isPortAvailable(port) {
1388
+ return new Promise((resolve) => {
1389
+ const server = createServer();
1390
+ server.once("error", () => resolve(false));
1391
+ server.once("listening", () => {
1392
+ server.close(() => resolve(true));
1393
+ });
1394
+ server.listen(port, "127.0.0.1");
1395
+ });
1396
+ }
1397
+ function generateState() {
1398
+ return randomBytes(16).toString("hex");
1399
+ }
1400
+ function getSuccessHtml() {
1401
+ return `<!DOCTYPE html>
1402
+ <html>
1403
+ <head>
1404
+ <meta charset="utf-8">
1405
+ <title>Slima MCP - Authenticated</title>
1406
+ <style>
1407
+ body {
1408
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1409
+ display: flex;
1410
+ justify-content: center;
1411
+ align-items: center;
1412
+ height: 100vh;
1413
+ margin: 0;
1414
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1415
+ }
1416
+ .container {
1417
+ text-align: center;
1418
+ background: white;
1419
+ padding: 60px;
1420
+ border-radius: 16px;
1421
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
1422
+ }
1423
+ .checkmark {
1424
+ font-size: 64px;
1425
+ margin-bottom: 20px;
1426
+ }
1427
+ h1 {
1428
+ color: #333;
1429
+ margin-bottom: 10px;
1430
+ }
1431
+ p {
1432
+ color: #666;
1433
+ font-size: 16px;
1434
+ }
1435
+ .hint {
1436
+ margin-top: 20px;
1437
+ color: #999;
1438
+ font-size: 14px;
1439
+ }
1440
+ </style>
1441
+ </head>
1442
+ <body>
1443
+ <div class="container">
1444
+ <div class="checkmark">\u2705</div>
1445
+ <h1>Authentication Successful!</h1>
1446
+ <p>You can now close this window and return to your terminal.</p>
1447
+ <p class="hint">Slima MCP is ready to use.</p>
1448
+ </div>
1449
+ </body>
1450
+ </html>`;
1451
+ }
1452
+ function escapeHtml(text) {
1453
+ const map = {
1454
+ "&": "&amp;",
1455
+ "<": "&lt;",
1456
+ ">": "&gt;",
1457
+ '"': "&quot;",
1458
+ "'": "&#039;"
1459
+ };
1460
+ return text.replace(/[&<>"']/g, (char) => map[char]);
1461
+ }
1462
+ function getErrorHtml(message) {
1463
+ const safeMessage = escapeHtml(message);
1464
+ return `<!DOCTYPE html>
1465
+ <html>
1466
+ <head>
1467
+ <meta charset="utf-8">
1468
+ <title>Slima MCP - Error</title>
1469
+ <style>
1470
+ body {
1471
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1472
+ display: flex;
1473
+ justify-content: center;
1474
+ align-items: center;
1475
+ height: 100vh;
1476
+ margin: 0;
1477
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
1478
+ }
1479
+ .container {
1480
+ text-align: center;
1481
+ background: white;
1482
+ padding: 60px;
1483
+ border-radius: 16px;
1484
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
1485
+ }
1486
+ .error-icon {
1487
+ font-size: 64px;
1488
+ margin-bottom: 20px;
1489
+ }
1490
+ h1 {
1491
+ color: #e74c3c;
1492
+ margin-bottom: 10px;
1493
+ }
1494
+ p {
1495
+ color: #666;
1496
+ font-size: 16px;
1497
+ }
1498
+ </style>
1499
+ </head>
1500
+ <body>
1501
+ <div class="container">
1502
+ <div class="error-icon">\u274C</div>
1503
+ <h1>Authentication Failed</h1>
1504
+ <p>${safeMessage}</p>
1505
+ </div>
1506
+ </body>
1507
+ </html>`;
1508
+ }
1509
+ async function runAuth(options = {}) {
1510
+ const baseUrl = options.baseUrl || process.env.SLIMA_API_URL || DEFAULT_API_URL;
1511
+ console.log("\n\u{1F510} Slima MCP Authentication\n");
1512
+ const existingCredentials = await loadToken();
1513
+ if (existingCredentials) {
1514
+ console.log("You are already authenticated.");
1515
+ console.log(`Token stored at: ${getCredentialsFilePath()}`);
1516
+ console.log("\nTo re-authenticate, first run: slima-mcp logout\n");
1517
+ return;
1518
+ }
1519
+ const state = generateState();
1520
+ const port = await findAvailablePort();
1521
+ console.log("Starting local server for authentication callback...");
1522
+ const tokenPromise = new Promise((resolve, reject) => {
1523
+ const server = createServer((req, res) => {
1524
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
1525
+ if (url.pathname === "/callback") {
1526
+ const token = url.searchParams.get("token");
1527
+ const returnedState = url.searchParams.get("state");
1528
+ const error = url.searchParams.get("error");
1529
+ if (error) {
1530
+ res.writeHead(200, { "Content-Type": "text/html" });
1531
+ res.end(getErrorHtml(error));
1532
+ server.close();
1533
+ reject(new Error(error));
1534
+ return;
1535
+ }
1536
+ if (returnedState !== state) {
1537
+ res.writeHead(200, { "Content-Type": "text/html" });
1538
+ res.end(getErrorHtml("Invalid state parameter. Please try again."));
1539
+ server.close();
1540
+ reject(new Error("State mismatch"));
1541
+ return;
1542
+ }
1543
+ if (!token) {
1544
+ res.writeHead(200, { "Content-Type": "text/html" });
1545
+ res.end(getErrorHtml("No token received. Please try again."));
1546
+ server.close();
1547
+ reject(new Error("No token received"));
1548
+ return;
1549
+ }
1550
+ res.writeHead(200, { "Content-Type": "text/html" });
1551
+ res.end(getSuccessHtml());
1552
+ server.close();
1553
+ resolve(token);
1554
+ } else {
1555
+ res.writeHead(404);
1556
+ res.end("Not found");
1557
+ }
1558
+ });
1559
+ server.listen(port, "127.0.0.1", () => {
1560
+ console.log(`Listening on http://127.0.0.1:${port}`);
1561
+ });
1562
+ setTimeout(() => {
1563
+ server.close();
1564
+ reject(new Error("Authentication timed out. Please try again."));
1565
+ }, 3e4);
1566
+ });
1567
+ const authUrl = `${baseUrl}/api/v1/auth/cli?state=${state}&callback_port=${port}`;
1568
+ console.log("\nOpening browser for authentication...");
1569
+ await openBrowser(authUrl);
1570
+ console.log("\nWaiting for authentication...");
1571
+ console.log("(Press Ctrl+C to cancel)\n");
1572
+ try {
1573
+ const token = await tokenPromise;
1574
+ await saveToken(token, baseUrl !== DEFAULT_API_URL ? baseUrl : void 0);
1575
+ console.log("\n\u2705 Authentication successful!");
1576
+ console.log(`Token saved to: ${getCredentialsFilePath()}`);
1577
+ console.log("\nYou can now use Slima MCP with any MCP-compatible AI tool.\n");
1578
+ } catch (error) {
1579
+ console.error("\n\u274C Authentication failed:", error instanceof Error ? error.message : error);
1580
+ process.exit(1);
1581
+ }
1582
+ }
1583
+ async function runLogout() {
1584
+ console.log("\n\u{1F513} Slima MCP Logout\n");
1585
+ const credentials = await loadToken();
1586
+ if (!credentials) {
1587
+ console.log("You are not authenticated.\n");
1588
+ return;
1589
+ }
1590
+ await deleteToken();
1591
+ console.log("\u2705 Successfully logged out.");
1592
+ console.log(`Removed credentials from: ${getCredentialsFilePath()}
1593
+ `);
1594
+ }
1595
+ async function runStatus() {
1596
+ console.log("\n\u{1F4CA} Slima MCP Status\n");
1597
+ const credentials = await loadToken();
1598
+ if (!credentials) {
1599
+ console.log("Status: Not authenticated");
1600
+ console.log("\nRun `slima-mcp auth` to authenticate.\n");
1601
+ return;
1602
+ }
1603
+ console.log("Status: Authenticated");
1604
+ console.log(`Token: ${credentials.apiToken.substring(0, 15)}...`);
1605
+ console.log(`Authenticated at: ${credentials.createdAt}`);
1606
+ if (credentials.baseUrl) {
1607
+ console.log(`API URL: ${credentials.baseUrl}`);
1608
+ }
1609
+ console.log(`Credentials file: ${getCredentialsFilePath()}
1610
+ `);
1611
+ }
1612
+
1613
+ // src/index.ts
1614
+ var VERSION = "0.1.0";
1615
+ function showHelp() {
1616
+ console.log(`
1617
+ Slima MCP v${VERSION}
1618
+ MCP Server for Slima - AI Writing IDE for Novel Authors
1619
+
1620
+ USAGE:
1621
+ slima-mcp [command]
1622
+
1623
+ COMMANDS:
1624
+ (none) Start the MCP server
1625
+ auth Authenticate with Slima (opens browser)
1626
+ logout Remove saved credentials
1627
+ status Show authentication status
1628
+ --help Show this help message
1629
+ --version Show version
1630
+
1631
+ AUTHENTICATION:
1632
+ 1. Run: slima-mcp auth
1633
+ 2. Complete authentication in browser
1634
+ 3. Token is automatically saved
1635
+
1636
+ Or set SLIMA_API_TOKEN environment variable.
1637
+
1638
+ EXAMPLES:
1639
+ slima-mcp auth # Authenticate with browser
1640
+ slima-mcp status # Check auth status
1641
+ SLIMA_API_TOKEN=xxx slima-mcp # Use env var
1642
+
1643
+ CONFIGURATION (for AI tools):
1644
+ Claude Desktop, Cursor, etc.:
1645
+ {
1646
+ "mcpServers": {
1647
+ "slima": {
1648
+ "command": "npx",
1649
+ "args": ["-y", "slima-mcp"]
1650
+ }
1651
+ }
1652
+ }
1653
+
1654
+ MORE INFO:
1655
+ https://github.com/slima-ai/slima-mcp
1656
+ `);
1657
+ }
1658
+ function showVersion() {
1659
+ console.log(`slima-mcp v${VERSION}`);
1660
+ }
1661
+ async function startServer() {
1662
+ let apiToken = process.env.SLIMA_API_TOKEN;
1663
+ let baseUrl = process.env.SLIMA_API_URL;
1664
+ if (!apiToken) {
1665
+ const credentials = await loadToken();
1666
+ if (credentials) {
1667
+ apiToken = credentials.apiToken;
1668
+ baseUrl = baseUrl || credentials.baseUrl;
1669
+ }
1670
+ }
1671
+ if (!apiToken) {
1672
+ logger.error("Not authenticated and SLIMA_API_TOKEN not set");
1673
+ logger.error("");
1674
+ logger.error("To authenticate, run:");
1675
+ logger.error(" slima-mcp auth");
1676
+ logger.error("");
1677
+ logger.error("Or set environment variable:");
1678
+ logger.error(" SLIMA_API_TOKEN=your_token slima-mcp");
1679
+ logger.error("");
1680
+ logger.error("Get your API token from: https://app.slima.ai/settings/api-tokens");
1681
+ process.exit(1);
1682
+ }
1683
+ try {
1684
+ const server = createSlimaServer({
1685
+ apiToken,
1686
+ baseUrl
1687
+ });
1688
+ const transport = new StdioServerTransport();
1689
+ await server.connect(transport);
1690
+ logger.info("Slima MCP Server running on stdio");
1691
+ process.on("SIGINT", async () => {
1692
+ logger.info("Shutting down...");
1693
+ await server.close();
1694
+ process.exit(0);
1695
+ });
1696
+ process.on("SIGTERM", async () => {
1697
+ logger.info("Shutting down...");
1698
+ await server.close();
1699
+ process.exit(0);
1700
+ });
1701
+ } catch (error) {
1702
+ logger.error("Failed to start server", error);
1703
+ process.exit(1);
1704
+ }
1705
+ }
1706
+ async function main() {
1707
+ const args = process.argv.slice(2);
1708
+ const command = args[0];
1709
+ switch (command) {
1710
+ case "auth":
1711
+ await runAuth();
1712
+ break;
1713
+ case "logout":
1714
+ await runLogout();
1715
+ break;
1716
+ case "status":
1717
+ await runStatus();
1718
+ break;
1719
+ case "--help":
1720
+ case "-h":
1721
+ case "help":
1722
+ showHelp();
1723
+ break;
1724
+ case "--version":
1725
+ case "-v":
1726
+ showVersion();
1727
+ break;
1728
+ case void 0:
1729
+ await startServer();
1730
+ break;
1731
+ default:
1732
+ console.error(`Unknown command: ${command}`);
1733
+ console.error("Run `slima-mcp --help` for usage.");
1734
+ process.exit(1);
1735
+ }
1736
+ }
1737
+ main();
1738
+ //# sourceMappingURL=index.js.map