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/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1738 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
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
|
+
"&": "&",
|
|
1455
|
+
"<": "<",
|
|
1456
|
+
">": ">",
|
|
1457
|
+
'"': """,
|
|
1458
|
+
"'": "'"
|
|
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
|