slima-mcp 0.1.15 → 0.3.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/README.md +81 -74
- package/dist/index.js +1355 -301
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -3,6 +3,46 @@
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
|
+
// src/core/schema-compat.ts
|
|
7
|
+
var STRIPPED_SCHEMA_KEYS = ["$schema", "additionalProperties"];
|
|
8
|
+
var STRIPPED = new Set(STRIPPED_SCHEMA_KEYS);
|
|
9
|
+
function stripIncompatibleSchemaKeys(node) {
|
|
10
|
+
if (Array.isArray(node)) {
|
|
11
|
+
return node.map((item) => stripIncompatibleSchemaKeys(item));
|
|
12
|
+
}
|
|
13
|
+
if (node !== null && typeof node === "object") {
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [key, value] of Object.entries(node)) {
|
|
16
|
+
if (STRIPPED.has(key)) continue;
|
|
17
|
+
out[key] = stripIncompatibleSchemaKeys(value);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
return node;
|
|
22
|
+
}
|
|
23
|
+
function sanitizeOutgoingMessage(message) {
|
|
24
|
+
const result = message.result;
|
|
25
|
+
if (!result || !Array.isArray(result.tools)) return message;
|
|
26
|
+
return {
|
|
27
|
+
...message,
|
|
28
|
+
result: {
|
|
29
|
+
...result,
|
|
30
|
+
tools: result.tools.map((tool) => {
|
|
31
|
+
const sanitized = { ...tool };
|
|
32
|
+
for (const field of ["inputSchema", "outputSchema"]) {
|
|
33
|
+
if (sanitized[field]) sanitized[field] = stripIncompatibleSchemaKeys(sanitized[field]);
|
|
34
|
+
}
|
|
35
|
+
return sanitized;
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function withGeminiSchemaCompat(transport) {
|
|
41
|
+
const send = transport.send.bind(transport);
|
|
42
|
+
transport.send = (message, options) => send(sanitizeOutgoingMessage(message), options);
|
|
43
|
+
return transport;
|
|
44
|
+
}
|
|
45
|
+
|
|
6
46
|
// src/cli/server.ts
|
|
7
47
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
48
|
|
|
@@ -33,7 +73,28 @@ var InsufficientCreditsError = class extends SlimaApiError {
|
|
|
33
73
|
this.name = "InsufficientCreditsError";
|
|
34
74
|
}
|
|
35
75
|
};
|
|
76
|
+
var RateLimitedError = class extends SlimaApiError {
|
|
77
|
+
constructor(retryAfterSeconds, message) {
|
|
78
|
+
super(429, "RATE_LIMITED", message || "Rate limited");
|
|
79
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
80
|
+
this.name = "RateLimitedError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
var RequestTimeoutError = class extends SlimaApiError {
|
|
84
|
+
constructor(timeoutMs) {
|
|
85
|
+
super(408, "REQUEST_TIMEOUT", `Request timed out after ${Math.round(timeoutMs / 1e3)}s`);
|
|
86
|
+
this.timeoutMs = timeoutMs;
|
|
87
|
+
this.name = "RequestTimeoutError";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
36
90
|
function formatErrorForMcp(error) {
|
|
91
|
+
if (error instanceof RateLimitedError) {
|
|
92
|
+
const wait = error.retryAfterSeconds;
|
|
93
|
+
return wait ? `Rate limited. Wait ${wait} second${wait === 1 ? "" : "s"} and try the same call again \u2014 this is a temporary throttle, not a rejected request.` : "Rate limited. Wait a few seconds and try the same call again \u2014 this is a temporary throttle, not a rejected request.";
|
|
94
|
+
}
|
|
95
|
+
if (error instanceof RequestTimeoutError) {
|
|
96
|
+
return `${error.message}. The server may still be processing it, so re-running a write could duplicate work \u2014 prefer re-reading the current state first.`;
|
|
97
|
+
}
|
|
37
98
|
if (error instanceof SlimaApiError) {
|
|
38
99
|
switch (error.code) {
|
|
39
100
|
case "UNAUTHORIZED":
|
|
@@ -55,6 +116,16 @@ function formatErrorForMcp(error) {
|
|
|
55
116
|
}
|
|
56
117
|
|
|
57
118
|
// src/core/api/client.ts
|
|
119
|
+
var REQUEST_TIMEOUT_MS = 6e4;
|
|
120
|
+
function parseRetryAfter(response) {
|
|
121
|
+
const header = response.headers?.get?.("retry-after");
|
|
122
|
+
if (!header) return void 0;
|
|
123
|
+
const seconds = Number(header);
|
|
124
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
|
|
125
|
+
const when = Date.parse(header);
|
|
126
|
+
if (Number.isNaN(when)) return void 0;
|
|
127
|
+
return Math.max(0, Math.ceil((when - Date.now()) / 1e3));
|
|
128
|
+
}
|
|
58
129
|
var defaultLogger = {
|
|
59
130
|
debug: () => {
|
|
60
131
|
},
|
|
@@ -86,24 +157,69 @@ var SlimaApiClient = class {
|
|
|
86
157
|
/**
|
|
87
158
|
* Send API request
|
|
88
159
|
*/
|
|
89
|
-
async request(method, path, body) {
|
|
160
|
+
async request(method, path, body, options = {}) {
|
|
90
161
|
const url = `${this.baseUrl}${path}`;
|
|
91
162
|
const token = await this.getToken();
|
|
92
163
|
this.logger.debug(`${method} ${path}`);
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const deadline = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
166
|
+
let response;
|
|
167
|
+
try {
|
|
168
|
+
response = await fetch(url, {
|
|
169
|
+
method,
|
|
170
|
+
headers: {
|
|
171
|
+
Authorization: `Bearer ${token}`,
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"User-Agent": `slima-mcp-server/${"0.3.0"}`
|
|
174
|
+
},
|
|
175
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
176
|
+
signal: controller.signal
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (controller.signal.aborted || error instanceof Error && error.name === "AbortError") {
|
|
180
|
+
throw new RequestTimeoutError(REQUEST_TIMEOUT_MS);
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(deadline);
|
|
185
|
+
}
|
|
186
|
+
this.handleUpgradeHint(response);
|
|
102
187
|
if (!response.ok) {
|
|
103
188
|
await this.handleErrorResponse(response);
|
|
104
189
|
}
|
|
105
|
-
const
|
|
106
|
-
return
|
|
190
|
+
const json3 = await response.json();
|
|
191
|
+
return options.envelope ? json3 : json3.data;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Like #request but keeps the whole envelope. Paginated endpoints carry the
|
|
195
|
+
* real total in `meta`, and dropping it is how `list_books` came to report a
|
|
196
|
+
* capped page as if it were the whole library.
|
|
197
|
+
*
|
|
198
|
+
* Delegates to #request rather than re-implementing fetch/auth/error handling:
|
|
199
|
+
* the first version copied those ~20 lines and had already lost request()'s
|
|
200
|
+
* debug log, so list_books was the one call invisible at debug level.
|
|
201
|
+
*/
|
|
202
|
+
async requestWithMeta(method, path) {
|
|
203
|
+
return this.request(method, path, void 0, {
|
|
204
|
+
envelope: true
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Surface the X-Slima-MCP-Upgrade-Hint header (if present) as a warn log.
|
|
209
|
+
* Deduped per (hint, baseUrl) to keep stderr quiet during long sessions.
|
|
210
|
+
* Defensive: mocked Response objects in tests may not carry a full Headers
|
|
211
|
+
* interface, so guard the access rather than hard-crashing the request.
|
|
212
|
+
*/
|
|
213
|
+
loggedUpgradeHints = /* @__PURE__ */ new Set();
|
|
214
|
+
handleUpgradeHint(response) {
|
|
215
|
+
const hint = response?.headers?.get?.("X-Slima-MCP-Upgrade-Hint");
|
|
216
|
+
if (!hint) return;
|
|
217
|
+
const key = `${hint}::${this.baseUrl}`;
|
|
218
|
+
if (this.loggedUpgradeHints.has(key)) return;
|
|
219
|
+
this.loggedUpgradeHints.add(key);
|
|
220
|
+
this.logger.warn(
|
|
221
|
+
`[slima-mcp] ${hint} \u2014 upgrade options: npm install -g slima-mcp@latest, or change your Claude Desktop config to "npx -y slima-mcp@0".`
|
|
222
|
+
);
|
|
107
223
|
}
|
|
108
224
|
/**
|
|
109
225
|
* Handle error response
|
|
@@ -123,20 +239,87 @@ var SlimaApiClient = class {
|
|
|
123
239
|
throw new NotFoundError(message);
|
|
124
240
|
case 402:
|
|
125
241
|
throw new InsufficientCreditsError();
|
|
242
|
+
case 429:
|
|
243
|
+
throw new RateLimitedError(parseRetryAfter(response), message);
|
|
126
244
|
default:
|
|
127
245
|
throw new SlimaApiError(response.status, code, message);
|
|
128
246
|
}
|
|
129
247
|
}
|
|
130
248
|
// === Book Operations ===
|
|
131
249
|
/**
|
|
132
|
-
* List
|
|
250
|
+
* List books (one page). `meta` carries the true total — callers must report
|
|
251
|
+
* it rather than presenting a page as the whole library.
|
|
133
252
|
*/
|
|
134
|
-
async listBooks() {
|
|
135
|
-
|
|
253
|
+
async listBooks(options = {}) {
|
|
254
|
+
const page = options.page && options.page > 1 ? `&page=${options.page}` : "";
|
|
255
|
+
const response = await this.requestWithMeta(
|
|
256
|
+
"GET",
|
|
257
|
+
`/api/v1/books?status=active${page}`
|
|
258
|
+
);
|
|
259
|
+
return { books: response.data ?? [], meta: response.meta ?? {} };
|
|
136
260
|
}
|
|
137
261
|
/**
|
|
138
262
|
* Get book details
|
|
139
263
|
*/
|
|
264
|
+
// === Teaching surface =====================================================
|
|
265
|
+
//
|
|
266
|
+
// These are why the client stays thin. Everything they return is authored and
|
|
267
|
+
// served by Rails, so the guidance an AI reads updates when the server
|
|
268
|
+
// deploys — including for the users who pinned an old version of this package,
|
|
269
|
+
// who are exactly the ones stale guidance would mislead.
|
|
270
|
+
async getCapabilities() {
|
|
271
|
+
return this.request("GET", "/api/v1/mcp/capabilities");
|
|
272
|
+
}
|
|
273
|
+
async listGuides() {
|
|
274
|
+
return this.request("GET", "/api/v1/mcp/guides");
|
|
275
|
+
}
|
|
276
|
+
async loadGuide(slug) {
|
|
277
|
+
return this.request("GET", `/api/v1/mcp/guides/${encodeURIComponent(slug)}`);
|
|
278
|
+
}
|
|
279
|
+
async listWritingSkills() {
|
|
280
|
+
return this.request("GET", "/api/v1/mcp/skills");
|
|
281
|
+
}
|
|
282
|
+
async loadWritingSkill(key) {
|
|
283
|
+
return this.request("GET", `/api/v1/mcp/skills/${encodeURIComponent(key)}`);
|
|
284
|
+
}
|
|
285
|
+
// === Teams ================================================================
|
|
286
|
+
async listTeams() {
|
|
287
|
+
return this.request("GET", "/api/v1/teams");
|
|
288
|
+
}
|
|
289
|
+
async getTeam(token) {
|
|
290
|
+
return this.request("GET", `/api/v1/teams/${token}`);
|
|
291
|
+
}
|
|
292
|
+
async listTeamSkills(token) {
|
|
293
|
+
return this.request("GET", `/api/v1/teams/${token}/skills`);
|
|
294
|
+
}
|
|
295
|
+
async listTeamMemories(token) {
|
|
296
|
+
return this.request("GET", `/api/v1/teams/${token}/memories`);
|
|
297
|
+
}
|
|
298
|
+
async listTeamRules(token) {
|
|
299
|
+
return this.request("GET", `/api/v1/teams/${token}/rules`);
|
|
300
|
+
}
|
|
301
|
+
async semanticSearch(bookToken, query, limit) {
|
|
302
|
+
return this.request("POST", `/api/v1/books/${bookToken}/semantic-search`, { query, limit });
|
|
303
|
+
}
|
|
304
|
+
// === Notes and comments ===================================================
|
|
305
|
+
async listNotes(bookToken) {
|
|
306
|
+
return this.request("GET", `/api/v1/books/${bookToken}/notes`);
|
|
307
|
+
}
|
|
308
|
+
async createNote(bookToken, note) {
|
|
309
|
+
return this.request("POST", `/api/v1/books/${bookToken}/notes`, { note });
|
|
310
|
+
}
|
|
311
|
+
async listComments(bookToken) {
|
|
312
|
+
return this.request("GET", `/api/v1/books/${bookToken}/comments`);
|
|
313
|
+
}
|
|
314
|
+
async createComment(bookToken, comment) {
|
|
315
|
+
return this.request("POST", `/api/v1/books/${bookToken}/comments`, { comment });
|
|
316
|
+
}
|
|
317
|
+
async resolveComment(bookToken, commentToken) {
|
|
318
|
+
return this.request("POST", `/api/v1/books/${bookToken}/comments/${commentToken}/resolve`);
|
|
319
|
+
}
|
|
320
|
+
async reopenComment(bookToken, commentToken) {
|
|
321
|
+
return this.request("POST", `/api/v1/books/${bookToken}/comments/${commentToken}/reopen`);
|
|
322
|
+
}
|
|
140
323
|
async getBook(token) {
|
|
141
324
|
return this.request("GET", `/api/v1/books/${token}`);
|
|
142
325
|
}
|
|
@@ -263,7 +446,8 @@ var SlimaApiClient = class {
|
|
|
263
446
|
path: params.path,
|
|
264
447
|
content: params.content || "",
|
|
265
448
|
parent_path: params.parentPath,
|
|
266
|
-
commit_message: params.commitMessage
|
|
449
|
+
commit_message: params.commitMessage,
|
|
450
|
+
content_type: params.contentType
|
|
267
451
|
}
|
|
268
452
|
);
|
|
269
453
|
}
|
|
@@ -281,6 +465,42 @@ var SlimaApiClient = class {
|
|
|
281
465
|
}
|
|
282
466
|
);
|
|
283
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Field-level operations on a structured file — the ONLY write path for
|
|
470
|
+
* `.beats` / `.timeline` / `.script`.
|
|
471
|
+
*
|
|
472
|
+
* One endpoint takes every type; the type comes from the path's extension.
|
|
473
|
+
* The separate tool names on this side exist because a tool that writes must
|
|
474
|
+
* start with a verb, or the desktop agent will not know to ask the human.
|
|
475
|
+
*/
|
|
476
|
+
async applyStructuredOps(bookToken, params) {
|
|
477
|
+
return this.request(
|
|
478
|
+
"POST",
|
|
479
|
+
`/api/v1/books/${bookToken}/mcp/files/structured`,
|
|
480
|
+
{ path: params.path, intent: params.intent, operations: params.operations }
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Search-and-replace inside a file, applied SERVER-side.
|
|
485
|
+
*
|
|
486
|
+
* The old client-side read-modify-write reported a replacement count this
|
|
487
|
+
* process computed and blind-wrote the whole file back, so concurrent edits
|
|
488
|
+
* silently clobbered each other while both callers saw a success
|
|
489
|
+
* (iss_gkqwnovhajzl7xvz). Rails re-reads inside its branch lock.
|
|
490
|
+
*/
|
|
491
|
+
async editFile(bookToken, params) {
|
|
492
|
+
return this.request(
|
|
493
|
+
"POST",
|
|
494
|
+
`/api/v1/books/${bookToken}/mcp/files/edit`,
|
|
495
|
+
{
|
|
496
|
+
path: params.path,
|
|
497
|
+
old_string: params.oldString,
|
|
498
|
+
new_string: params.newString,
|
|
499
|
+
replace_all: params.replaceAll,
|
|
500
|
+
commit_message: params.commitMessage
|
|
501
|
+
}
|
|
502
|
+
);
|
|
503
|
+
}
|
|
284
504
|
/**
|
|
285
505
|
* Delete file
|
|
286
506
|
*/
|
|
@@ -290,7 +510,8 @@ var SlimaApiClient = class {
|
|
|
290
510
|
`/api/v1/books/${bookToken}/mcp/files/delete`,
|
|
291
511
|
{
|
|
292
512
|
path: params.path,
|
|
293
|
-
commit_message: params.commitMessage
|
|
513
|
+
commit_message: params.commitMessage,
|
|
514
|
+
recursive: params.recursive
|
|
294
515
|
}
|
|
295
516
|
);
|
|
296
517
|
}
|
|
@@ -308,6 +529,17 @@ var SlimaApiClient = class {
|
|
|
308
529
|
}
|
|
309
530
|
);
|
|
310
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Render a structured file (today: .map relationship graphs) as an image
|
|
534
|
+
* plus a text summary.
|
|
535
|
+
*/
|
|
536
|
+
async visualizeFile(bookToken, path) {
|
|
537
|
+
return this.request(
|
|
538
|
+
"POST",
|
|
539
|
+
`/api/v1/books/${bookToken}/mcp/files/visualize`,
|
|
540
|
+
{ path }
|
|
541
|
+
);
|
|
542
|
+
}
|
|
311
543
|
/**
|
|
312
544
|
* Search file content
|
|
313
545
|
*/
|
|
@@ -409,10 +641,32 @@ function formatFileTree(files, indent = 0) {
|
|
|
409
641
|
}
|
|
410
642
|
|
|
411
643
|
// src/core/tools/books.ts
|
|
644
|
+
function studioLabel(book) {
|
|
645
|
+
if (book.bookType === "script") return "\u{1F4DD} Script Studio";
|
|
646
|
+
if (book.bookType === "knowledge") return "\u{1F4DA} Team Knowledge Base";
|
|
647
|
+
return "\u{1F4D6} Writing Studio";
|
|
648
|
+
}
|
|
649
|
+
function scriptStudioWriteRulesBlock(book) {
|
|
650
|
+
return `### \u{1F512} Write Restrictions (Script Studio)
|
|
651
|
+
|
|
652
|
+
This book uses **structured schemas**. Via MCP you can:
|
|
653
|
+
- \u2705 **Read** any file (series.json, *.character, *.scene, etc.)
|
|
654
|
+
- \u2705 **Write / Edit / Delete** under \`.script_studio/planning/**/*\` only (free-form reference tree)
|
|
655
|
+
- \u274C **Cannot modify** structured files (\`series.json\`, \`season.json\`, \`episode.json\`, \`*.character\`, \`*.location\`, \`*.scene\`, \`*.storyline\`, \`*.note\`)
|
|
656
|
+
- \u274C **Cannot modify** \`.script_studio/planning/.initialized\` (frontend bootstrap sentinel)
|
|
657
|
+
|
|
658
|
+
To add characters / scenes / storylines, use the Script Studio UI in the Slima app.
|
|
659
|
+
|
|
660
|
+
For full per-book details, read the MCP resource: \`slima://books/${book.token}/schema\`.`;
|
|
661
|
+
}
|
|
412
662
|
function registerBookTools(server, client) {
|
|
413
663
|
server.tool(
|
|
414
664
|
"create_book",
|
|
415
|
-
|
|
665
|
+
`Create a new **Writing Studio** book in your Slima library.
|
|
666
|
+
|
|
667
|
+
Only Writing Studio (free-form novels / long-form prose) books can be created via MCP.
|
|
668
|
+
To create a **Script Studio** book (screenwriting with structured series/seasons/episodes/scenes),
|
|
669
|
+
please use the Slima app UI \u2014 MCP does not yet support Script Studio scaffolding.`,
|
|
416
670
|
{
|
|
417
671
|
title: z.string().describe("Book title (required)"),
|
|
418
672
|
author_name: z.string().optional().describe("Author name (optional)"),
|
|
@@ -450,12 +704,14 @@ You can now add files using create_file with book_token: ${book.token}`
|
|
|
450
704
|
);
|
|
451
705
|
server.tool(
|
|
452
706
|
"list_books",
|
|
453
|
-
"List
|
|
454
|
-
{
|
|
707
|
+
"List the books in your Slima library. Results are paginated; if the response says more books exist, call again with the next `page` before concluding a book is not there.",
|
|
708
|
+
{
|
|
709
|
+
page: z.number().optional().describe("Page number (default 1). Only needed when the response says more books exist.")
|
|
710
|
+
},
|
|
455
711
|
{ readOnlyHint: true, openWorldHint: true },
|
|
456
|
-
async () => {
|
|
712
|
+
async ({ page }) => {
|
|
457
713
|
try {
|
|
458
|
-
const books = await client.listBooks();
|
|
714
|
+
const { books, meta } = await client.listBooks({ page });
|
|
459
715
|
if (books.length === 0) {
|
|
460
716
|
return {
|
|
461
717
|
content: [
|
|
@@ -468,16 +724,22 @@ You can now add files using create_file with book_token: ${book.token}`
|
|
|
468
724
|
}
|
|
469
725
|
const formatted = books.map((book) => {
|
|
470
726
|
const words = book.totalWordCount?.toLocaleString() || "0";
|
|
471
|
-
return `- **${book.title}** (${book.token})
|
|
727
|
+
return `- **${book.title}** (${book.token}) \u2014 ${studioLabel(book)}
|
|
472
728
|
Author: ${book.authorName || "N/A"} | Words: ${words}`;
|
|
473
729
|
}).join("\n\n");
|
|
730
|
+
const currentPage = meta.page ?? 1;
|
|
731
|
+
const totalPages = meta.totalPages;
|
|
732
|
+
const hasMore = totalPages !== void 0 && currentPage < totalPages;
|
|
733
|
+
const header = hasMore ? `Showing ${books.length} of ${meta.total} book(s) \u2014 page ${currentPage} of ${totalPages}. Call list_books again with \`page: ${currentPage + 1}\` for the rest.` : meta.total !== void 0 && meta.total > books.length ? `Showing ${books.length} of ${meta.total} book(s) \u2014 page ${currentPage} of ${totalPages ?? "?"} (this is the last page).` : `Found ${books.length} book(s):`;
|
|
474
734
|
return {
|
|
475
735
|
content: [
|
|
476
736
|
{
|
|
477
737
|
type: "text",
|
|
478
|
-
text:
|
|
738
|
+
text: `${header}
|
|
739
|
+
|
|
740
|
+
${formatted}
|
|
479
741
|
|
|
480
|
-
|
|
742
|
+
_\u{1F4DD} Script Studio books have write restrictions via MCP (see \`get_book\` or resource \`slima://books/{token}/schema\`). \u{1F4D6} Writing Studio books are fully writable._`
|
|
481
743
|
}
|
|
482
744
|
]
|
|
483
745
|
};
|
|
@@ -499,7 +761,7 @@ ${formatted}`
|
|
|
499
761
|
async ({ book_token }) => {
|
|
500
762
|
try {
|
|
501
763
|
const book = await client.getBook(book_token);
|
|
502
|
-
const
|
|
764
|
+
const header = `# ${studioLabel(book)} \u2014 ${book.title}
|
|
503
765
|
|
|
504
766
|
- **Token**: ${book.token}
|
|
505
767
|
- **Author**: ${book.authorName || "N/A"}
|
|
@@ -510,8 +772,11 @@ ${formatted}`
|
|
|
510
772
|
- **Reference Words**: ${book.referenceWordCount?.toLocaleString() || "0"}
|
|
511
773
|
- **Created**: ${book.createdAt}
|
|
512
774
|
- **Updated**: ${book.updatedAt}`;
|
|
775
|
+
const text2 = book.bookType === "script" ? `${header}
|
|
776
|
+
|
|
777
|
+
${scriptStudioWriteRulesBlock(book)}` : header;
|
|
513
778
|
return {
|
|
514
|
-
content: [{ type: "text", text }]
|
|
779
|
+
content: [{ type: "text", text: text2 }]
|
|
515
780
|
};
|
|
516
781
|
} catch (error) {
|
|
517
782
|
return {
|
|
@@ -530,7 +795,10 @@ ${formatted}`
|
|
|
530
795
|
{ readOnlyHint: true, openWorldHint: true },
|
|
531
796
|
async ({ book_token }) => {
|
|
532
797
|
try {
|
|
533
|
-
const commits = await
|
|
798
|
+
const [book, commits] = await Promise.all([
|
|
799
|
+
client.getBook(book_token),
|
|
800
|
+
client.listCommits(book_token, 1)
|
|
801
|
+
]);
|
|
534
802
|
if (commits.length === 0) {
|
|
535
803
|
return {
|
|
536
804
|
content: [
|
|
@@ -544,13 +812,16 @@ ${formatted}`
|
|
|
544
812
|
const flatSnapshot = commits[0].filesSnapshot;
|
|
545
813
|
const nestedTree = buildFileTree(flatSnapshot);
|
|
546
814
|
const tree = formatFileTree(nestedTree);
|
|
815
|
+
const footer = book.bookType === "script" ? `
|
|
816
|
+
|
|
817
|
+
_\u{1F4DD} This is a Script Studio book. Via MCP, writes are restricted to \`.script_studio/planning/**/*\`. For the full write-restriction spec, read resource \`slima://books/${book.token}/schema\`._` : "";
|
|
547
818
|
return {
|
|
548
819
|
content: [
|
|
549
820
|
{
|
|
550
821
|
type: "text",
|
|
551
|
-
text: `File structure for book ${book_token}:
|
|
822
|
+
text: `File structure for ${studioLabel(book)} \u2014 ${book.title} (${book_token}):
|
|
552
823
|
|
|
553
|
-
${tree}`
|
|
824
|
+
${tree}${footer}`
|
|
554
825
|
}
|
|
555
826
|
]
|
|
556
827
|
};
|
|
@@ -564,35 +835,31 @@ ${tree}`
|
|
|
564
835
|
);
|
|
565
836
|
server.tool(
|
|
566
837
|
"get_writing_stats",
|
|
567
|
-
|
|
838
|
+
`Get word-count totals for a book.
|
|
839
|
+
|
|
840
|
+
Reports the book's authoritative totals only. Do NOT compute "progress", "words
|
|
841
|
+
written today", or a writing streak from commit history \u2014 Slima prunes intra-day
|
|
842
|
+
commits after 30 days, so any delta derived from "the last N commits" silently
|
|
843
|
+
changes span over time and will not match what the author sees in the app. Those
|
|
844
|
+
figures are account-level and are not exposed here.`,
|
|
568
845
|
{
|
|
569
846
|
book_token: z.string().describe("Book token")
|
|
570
847
|
},
|
|
571
848
|
{ readOnlyHint: true, openWorldHint: true },
|
|
572
849
|
async ({ book_token }) => {
|
|
573
850
|
try {
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
client.listCommits(book_token, 10)
|
|
577
|
-
]);
|
|
578
|
-
let wordChange = 0;
|
|
579
|
-
if (commits.length > 1) {
|
|
580
|
-
const latest = commits[0].totalWordCount || 0;
|
|
581
|
-
const oldest = commits[commits.length - 1].totalWordCount || 0;
|
|
582
|
-
wordChange = latest - oldest;
|
|
583
|
-
}
|
|
584
|
-
const sign = wordChange >= 0 ? "+" : "";
|
|
585
|
-
const progressText = commits.length > 1 ? `${sign}${wordChange.toLocaleString()} words (last ${commits.length} commits)` : "N/A (not enough history)";
|
|
586
|
-
const text = `# Writing Stats: ${book.title}
|
|
851
|
+
const book = await client.getBook(book_token);
|
|
852
|
+
const text2 = `# Writing Stats: ${book.title}
|
|
587
853
|
|
|
588
854
|
- **Total Words**: ${book.totalWordCount?.toLocaleString() || "0"}
|
|
589
|
-
- **Manuscript Words**: ${book.manuscriptWordCount?.toLocaleString() || "0"}
|
|
590
|
-
- **Reference Words**: ${book.referenceWordCount?.toLocaleString() || "0"}
|
|
591
|
-
- **
|
|
592
|
-
- **
|
|
593
|
-
|
|
855
|
+
- **Manuscript Words**: ${book.manuscriptWordCount?.toLocaleString() || "0"} (the book itself \u2014 what counts toward the author's word count)
|
|
856
|
+
- **Reference Words**: ${book.referenceWordCount?.toLocaleString() || "0"} (notes, worldbuilding \u2014 never counted)
|
|
857
|
+
- **Language**: ${book.language || "N/A"}
|
|
858
|
+
- **Last updated**: ${book.updatedAt}
|
|
859
|
+
|
|
860
|
+
_Daily word count, writing streak and rest protection are account-level in Slima and are not available through this tool. Do not estimate them from commit history \u2014 the figure would not match the author's._`;
|
|
594
861
|
return {
|
|
595
|
-
content: [{ type: "text", text }]
|
|
862
|
+
content: [{ type: "text", text: text2 }]
|
|
596
863
|
};
|
|
597
864
|
} catch (error) {
|
|
598
865
|
return {
|
|
@@ -606,94 +873,99 @@ ${tree}`
|
|
|
606
873
|
|
|
607
874
|
// src/core/tools/content.ts
|
|
608
875
|
import { z as z2 } from "zod";
|
|
876
|
+
|
|
877
|
+
// src/core/tools/file-read-rendering.ts
|
|
878
|
+
function renderFileRead(result) {
|
|
879
|
+
if (result.contentWithheld) {
|
|
880
|
+
return [
|
|
881
|
+
`# ${result.file.name} \u{1F512}`,
|
|
882
|
+
"",
|
|
883
|
+
`**Path:** ${result.file.path}`,
|
|
884
|
+
`**Words:** ${result.file.wordCount}`,
|
|
885
|
+
"",
|
|
886
|
+
"---",
|
|
887
|
+
"",
|
|
888
|
+
`The content of this file is withheld from you (reason: ${result.withheldReason ?? "confidential"}).`,
|
|
889
|
+
"It is a confidential team skill: your team allows it to be USED but not READ,",
|
|
890
|
+
"so Slima applies it on the server without showing you the source.",
|
|
891
|
+
"",
|
|
892
|
+
"This is intentional, not an error and not an empty file. Do not try to rewrite,",
|
|
893
|
+
'restore or "fix" it \u2014 writes to this path are rejected. If you need its contents,',
|
|
894
|
+
"ask a team manager."
|
|
895
|
+
].join("\n");
|
|
896
|
+
}
|
|
897
|
+
if (result.throttled) {
|
|
898
|
+
return [
|
|
899
|
+
`# ${result.file.name} \u23F3`,
|
|
900
|
+
"",
|
|
901
|
+
`**Path:** ${result.file.path}`,
|
|
902
|
+
`**Words:** ${result.file.wordCount}`,
|
|
903
|
+
"",
|
|
904
|
+
"---",
|
|
905
|
+
"",
|
|
906
|
+
`This team skill's read budget is exhausted for now (${result.throttledReason ?? "daily_read_limit"}).`,
|
|
907
|
+
"The file is not empty \u2014 try again later, or ask a team manager."
|
|
908
|
+
].join("\n");
|
|
909
|
+
}
|
|
910
|
+
return [
|
|
911
|
+
`# ${result.file.name}`,
|
|
912
|
+
"",
|
|
913
|
+
`**Path:** ${result.file.path}`,
|
|
914
|
+
`**Words:** ${result.file.wordCount}`,
|
|
915
|
+
`**Type:** ${result.file.fileType || "unknown"}`,
|
|
916
|
+
"",
|
|
917
|
+
"---",
|
|
918
|
+
"",
|
|
919
|
+
result.content || "(empty file)"
|
|
920
|
+
].join("\n");
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/core/tools/content.ts
|
|
609
924
|
function registerContentTools(server, client) {
|
|
610
925
|
server.tool(
|
|
611
926
|
"get_chapter",
|
|
612
|
-
"Read
|
|
927
|
+
"Read a chapter or file from a book. Accepts a full path, or just a filename if you are not sure of the folder. If you already know the exact path, `read_file` is equivalent and more direct.",
|
|
613
928
|
{
|
|
614
929
|
book_token: z2.string().describe("Book token (e.g., bk_abc123)"),
|
|
615
|
-
file_path: z2.string().describe("File path (e.g., /chapters/01.md or
|
|
930
|
+
file_path: z2.string().describe("File path (e.g., /chapters/01.md) or just a filename (e.g., 01.md)")
|
|
616
931
|
},
|
|
617
932
|
{ readOnlyHint: true, openWorldHint: true },
|
|
618
933
|
async ({ book_token, file_path }) => {
|
|
934
|
+
const normalized = file_path.replace(/^\//, "");
|
|
619
935
|
try {
|
|
620
|
-
const
|
|
621
|
-
|
|
936
|
+
const result = await client.readFile(book_token, normalized);
|
|
937
|
+
return { content: [{ type: "text", text: renderFileRead(result) }] };
|
|
938
|
+
} catch (error) {
|
|
939
|
+
if (!(error instanceof NotFoundError)) {
|
|
622
940
|
return {
|
|
623
|
-
content: [
|
|
624
|
-
|
|
625
|
-
type: "text",
|
|
626
|
-
text: "No commits found. The book may be empty."
|
|
627
|
-
}
|
|
628
|
-
]
|
|
941
|
+
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
942
|
+
isError: true
|
|
629
943
|
};
|
|
630
944
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
return {
|
|
638
|
-
content: [
|
|
639
|
-
{
|
|
640
|
-
type: "text",
|
|
641
|
-
text: `File not found: ${file_path}
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
const { resolvedPath, availablePaths } = await resolveFromStructure(client, book_token, normalized);
|
|
948
|
+
if (!resolvedPath) {
|
|
949
|
+
const available = availablePaths;
|
|
950
|
+
const hint = available.length ? `
|
|
642
951
|
|
|
643
952
|
Available files:
|
|
644
|
-
- ${
|
|
645
|
-
|
|
646
|
-
Tip: Use get_book_structure to see the full file tree.`
|
|
647
|
-
}
|
|
648
|
-
],
|
|
649
|
-
isError: true
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
if (file.kind === "folder") {
|
|
653
|
-
return {
|
|
654
|
-
content: [
|
|
655
|
-
{
|
|
656
|
-
type: "text",
|
|
657
|
-
text: `"${file_path}" is a folder, not a file. Please specify a file path.`
|
|
658
|
-
}
|
|
659
|
-
],
|
|
660
|
-
isError: true
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
if (!file.blobHash) {
|
|
664
|
-
return {
|
|
665
|
-
content: [
|
|
666
|
-
{
|
|
667
|
-
type: "text",
|
|
668
|
-
text: `File "${file_path}" has no content (empty file).`
|
|
669
|
-
}
|
|
670
|
-
]
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
const blobs = await client.downloadBlobs(book_token, [file.blobHash]);
|
|
674
|
-
if (blobs.length === 0) {
|
|
953
|
+
- ${available.slice(0, 10).join("\n- ")}${available.length > 10 ? `
|
|
954
|
+
... and ${available.length - 10} more` : ""}` : "";
|
|
675
955
|
return {
|
|
676
956
|
content: [
|
|
677
957
|
{
|
|
678
958
|
type: "text",
|
|
679
|
-
text:
|
|
959
|
+
text: `File not found: ${file_path}${hint}
|
|
960
|
+
|
|
961
|
+
Tip: use get_book_structure to see the full file tree.`
|
|
680
962
|
}
|
|
681
963
|
],
|
|
682
964
|
isError: true
|
|
683
965
|
};
|
|
684
966
|
}
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
return {
|
|
688
|
-
content: [
|
|
689
|
-
{
|
|
690
|
-
type: "text",
|
|
691
|
-
text: `# ${file.name}${wordInfo}
|
|
692
|
-
|
|
693
|
-
${content}`
|
|
694
|
-
}
|
|
695
|
-
]
|
|
696
|
-
};
|
|
967
|
+
const result = await client.readFile(book_token, resolvedPath);
|
|
968
|
+
return { content: [{ type: "text", text: renderFileRead(result) }] };
|
|
697
969
|
} catch (error) {
|
|
698
970
|
return {
|
|
699
971
|
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
@@ -703,61 +975,36 @@ ${content}`
|
|
|
703
975
|
}
|
|
704
976
|
);
|
|
705
977
|
}
|
|
706
|
-
function
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (item.kind === "folder" && item.children) {
|
|
720
|
-
if (itemNameLower === remainingParts[0] || itemTokenLower === remainingParts[0]) {
|
|
721
|
-
const result3 = search(item.children, remainingParts.slice(1));
|
|
722
|
-
if (result3) return result3;
|
|
723
|
-
}
|
|
724
|
-
const result2 = search(item.children, remainingParts);
|
|
725
|
-
if (result2) return result2;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
return null;
|
|
729
|
-
}
|
|
730
|
-
let result = search(files, pathParts);
|
|
731
|
-
if (result) return result;
|
|
732
|
-
result = searchByName(files, pathParts[pathParts.length - 1]);
|
|
733
|
-
return result;
|
|
978
|
+
function fullPathOf(entry, byToken) {
|
|
979
|
+
const parts = [entry.name];
|
|
980
|
+
let current = entry;
|
|
981
|
+
let depth = 0;
|
|
982
|
+
while (current) {
|
|
983
|
+
const parentToken = current.parentToken;
|
|
984
|
+
if (!parentToken) break;
|
|
985
|
+
const parent = byToken.get(parentToken);
|
|
986
|
+
if (!parent || depth++ > byToken.size) break;
|
|
987
|
+
parts.unshift(parent.name);
|
|
988
|
+
current = parent;
|
|
989
|
+
}
|
|
990
|
+
return parts.join("/");
|
|
734
991
|
}
|
|
735
|
-
function
|
|
736
|
-
|
|
737
|
-
for (const item of files) {
|
|
738
|
-
const itemNameLower = item.name.toLowerCase();
|
|
739
|
-
const itemTokenLower = item.token.toLowerCase();
|
|
740
|
-
if (itemNameLower === nameLower || itemTokenLower === nameLower || itemNameLower.replace(/\.[^.]+$/, "") === nameLower.replace(/\.[^.]+$/, "")) {
|
|
741
|
-
return item;
|
|
742
|
-
}
|
|
743
|
-
if (item.kind === "folder" && item.children) {
|
|
744
|
-
const result = searchByName(item.children, name);
|
|
745
|
-
if (result) return result;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
return null;
|
|
992
|
+
function flatten(files) {
|
|
993
|
+
return files.flatMap((f) => [f, ...f.children ? flatten(f.children) : []]);
|
|
749
994
|
}
|
|
750
|
-
function
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
995
|
+
async function resolveFromStructure(client, bookToken, needle) {
|
|
996
|
+
const commits = await client.listCommits(bookToken, 1);
|
|
997
|
+
if (commits.length === 0) return { resolvedPath: null, availablePaths: [] };
|
|
998
|
+
const entries = flatten(commits[0].filesSnapshot ?? []);
|
|
999
|
+
const byToken = new Map(entries.map((e) => [e.token, e]));
|
|
1000
|
+
const wanted = needle.toLowerCase();
|
|
1001
|
+
const stripExt = (value) => value.replace(/\.[^./]+$/, "");
|
|
1002
|
+
const candidates = entries.filter((e) => e.kind !== "folder");
|
|
1003
|
+
const match = candidates.find((e) => fullPathOf(e, byToken).toLowerCase() === wanted) ?? candidates.find((e) => e.name.toLowerCase() === wanted) ?? candidates.find((e) => e.token.toLowerCase() === wanted) ?? candidates.find((e) => fullPathOf(e, byToken).toLowerCase().endsWith(`/${wanted}`)) ?? candidates.find((e) => stripExt(e.name.toLowerCase()) === stripExt(wanted));
|
|
1004
|
+
return {
|
|
1005
|
+
resolvedPath: match ? fullPathOf(match, byToken) : null,
|
|
1006
|
+
availablePaths: candidates.map((e) => fullPathOf(e, byToken))
|
|
1007
|
+
};
|
|
761
1008
|
}
|
|
762
1009
|
|
|
763
1010
|
// src/core/tools/beta-reader.ts
|
|
@@ -826,15 +1073,31 @@ ${formatted}
|
|
|
826
1073
|
);
|
|
827
1074
|
server.tool(
|
|
828
1075
|
"analyze_chapter",
|
|
829
|
-
|
|
1076
|
+
`Get AI Beta Reader feedback on a chapter from a specific reader persona. This may take a moment to complete.
|
|
1077
|
+
|
|
1078
|
+
**Not supported for Script Studio books** (\`book_type: "script"\`) \u2014 AI Beta Reader is a Writing Studio feature and does not yet parse \`.scene\` or \`series.json\` structures. Use the Script Studio UI's built-in analysis for screenplays.`,
|
|
830
1079
|
{
|
|
831
|
-
book_token: z3.string().describe(
|
|
1080
|
+
book_token: z3.string().describe('Book token (e.g., bk_abc123) \u2014 must be a Writing Studio book (book_type: "book").'),
|
|
832
1081
|
file_path: z3.string().describe("Chapter file path (e.g., /chapters/01.md)"),
|
|
833
1082
|
persona_token: z3.string().describe("Persona token (e.g., psn_xxx). Use list_personas to find available personas.")
|
|
834
1083
|
},
|
|
835
1084
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
836
1085
|
async ({ book_token, file_path, persona_token }) => {
|
|
837
1086
|
try {
|
|
1087
|
+
const book = await client.getBook(book_token);
|
|
1088
|
+
if (book.bookType === "script") {
|
|
1089
|
+
return {
|
|
1090
|
+
content: [
|
|
1091
|
+
{
|
|
1092
|
+
type: "text",
|
|
1093
|
+
text: `\u274C analyze_chapter is not available for Script Studio books.
|
|
1094
|
+
|
|
1095
|
+
Book "${book.title}" (${book.token}) is a Script Studio book. Please use the Script Studio UI's built-in analysis features (structural notes, character consistency, scene-level feedback) \u2014 AI Beta Reader is a Writing Studio feature.`
|
|
1096
|
+
}
|
|
1097
|
+
],
|
|
1098
|
+
isError: true
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
838
1101
|
const commits = await client.listCommits(book_token, 1);
|
|
839
1102
|
if (commits.length === 0) {
|
|
840
1103
|
return {
|
|
@@ -847,7 +1110,7 @@ ${formatted}
|
|
|
847
1110
|
isError: true
|
|
848
1111
|
};
|
|
849
1112
|
}
|
|
850
|
-
const file =
|
|
1113
|
+
const file = findFileInSnapshot(commits[0].filesSnapshot, file_path);
|
|
851
1114
|
if (!file) {
|
|
852
1115
|
return {
|
|
853
1116
|
content: [
|
|
@@ -912,9 +1175,14 @@ ${formatted}
|
|
|
912
1175
|
content: [
|
|
913
1176
|
{
|
|
914
1177
|
type: "text",
|
|
915
|
-
text: `
|
|
1178
|
+
text: `The report is still being written (${result.progress ?? 0}% complete). It is running on the server and has already been paid for.
|
|
1179
|
+
|
|
1180
|
+
Call \`get_reader_test\` with this token in a little while to collect it:
|
|
916
1181
|
|
|
917
|
-
|
|
1182
|
+
book_token: ${book_token}
|
|
1183
|
+
test_token: ${test.token}
|
|
1184
|
+
|
|
1185
|
+
Do NOT call analyze_chapter again for this chapter \u2014 that starts a separate report and charges for it a second time.`
|
|
918
1186
|
}
|
|
919
1187
|
]
|
|
920
1188
|
};
|
|
@@ -932,40 +1200,94 @@ Test ID: ${test.token}`
|
|
|
932
1200
|
}
|
|
933
1201
|
}
|
|
934
1202
|
);
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
const pollInterval = 3e3;
|
|
939
|
-
while (Date.now() - startTime < timeoutMs) {
|
|
940
|
-
const result = await checkFn();
|
|
941
|
-
logger2.debug(`Poll status: ${result.status}, progress: ${result.progress || "N/A"}`);
|
|
942
|
-
if (result.status === "completed" || result.status === "failed") {
|
|
943
|
-
return result;
|
|
944
|
-
}
|
|
945
|
-
await sleep(pollInterval);
|
|
946
|
-
}
|
|
947
|
-
return { status: "timeout" };
|
|
948
|
-
}
|
|
949
|
-
function sleep(ms) {
|
|
950
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
951
|
-
}
|
|
952
|
-
function formatBetaReaderFeedback(test, fileName) {
|
|
953
|
-
const feedbacks = test.individualFeedbacks || [];
|
|
954
|
-
const metrics = test.aggregatedMetrics;
|
|
955
|
-
if (feedbacks.length === 0 && !metrics) {
|
|
956
|
-
return "No feedback available.";
|
|
957
|
-
}
|
|
958
|
-
let output = `# Beta Reader Feedback: ${fileName}
|
|
959
|
-
|
|
960
|
-
`;
|
|
961
|
-
if (metrics) {
|
|
962
|
-
output += formatAggregatedMetrics(metrics);
|
|
963
|
-
}
|
|
964
|
-
if (feedbacks.length > 0) {
|
|
965
|
-
output += `
|
|
966
|
-
---
|
|
1203
|
+
server.tool(
|
|
1204
|
+
"get_reader_test",
|
|
1205
|
+
`Collect an AI Beta Reader report by its test token \u2014 for a report that analyze_chapter started but did not finish inside its wait window.
|
|
967
1206
|
|
|
968
|
-
|
|
1207
|
+
Use this rather than calling analyze_chapter again: the report is already running server-side and has already been charged. Re-running starts a second report and charges again.`,
|
|
1208
|
+
{
|
|
1209
|
+
book_token: z3.string().describe("Book token (e.g., bk_abc123)"),
|
|
1210
|
+
test_token: z3.string().describe("Reader test token, as handed back by analyze_chapter when it timed out")
|
|
1211
|
+
},
|
|
1212
|
+
{ readOnlyHint: true, openWorldHint: true },
|
|
1213
|
+
async ({ book_token, test_token }) => {
|
|
1214
|
+
try {
|
|
1215
|
+
const progress = await client.getReaderTestProgress(book_token, test_token);
|
|
1216
|
+
if (progress.status === "failed") {
|
|
1217
|
+
return {
|
|
1218
|
+
content: [
|
|
1219
|
+
{
|
|
1220
|
+
type: "text",
|
|
1221
|
+
text: "That report failed while running. Start a new one with analyze_chapter."
|
|
1222
|
+
}
|
|
1223
|
+
],
|
|
1224
|
+
isError: true
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (progress.status !== "completed") {
|
|
1228
|
+
return {
|
|
1229
|
+
content: [
|
|
1230
|
+
{
|
|
1231
|
+
type: "text",
|
|
1232
|
+
text: `Still being written (${progress.progress ?? 0}% complete). Call get_reader_test again with the same token shortly \u2014 do not start a new analysis, this one is still running and already paid for.`
|
|
1233
|
+
}
|
|
1234
|
+
]
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
const fullResult = await client.getReaderTest(book_token, test_token);
|
|
1238
|
+
return {
|
|
1239
|
+
content: [
|
|
1240
|
+
{ type: "text", text: formatBetaReaderFeedback(fullResult, describeScope(fullResult)) }
|
|
1241
|
+
]
|
|
1242
|
+
};
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
return {
|
|
1245
|
+
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
1246
|
+
isError: true
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
function describeScope(test) {
|
|
1253
|
+
const scopePath = test.scopeConfig?.filePath;
|
|
1254
|
+
return typeof scopePath === "string" && scopePath.length > 0 ? scopePath : `${test.reportType} report`;
|
|
1255
|
+
}
|
|
1256
|
+
async function pollForCompletion(checkFn, timeoutMs, logger2) {
|
|
1257
|
+
const startTime = Date.now();
|
|
1258
|
+
const pollInterval = 3e3;
|
|
1259
|
+
let lastProgress;
|
|
1260
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1261
|
+
const result = await checkFn();
|
|
1262
|
+
lastProgress = result.progress;
|
|
1263
|
+
logger2.debug(`Poll status: ${result.status}, progress: ${result.progress || "N/A"}`);
|
|
1264
|
+
if (result.status === "completed" || result.status === "failed") {
|
|
1265
|
+
return result;
|
|
1266
|
+
}
|
|
1267
|
+
await sleep(pollInterval);
|
|
1268
|
+
}
|
|
1269
|
+
return { status: "timeout", progress: lastProgress };
|
|
1270
|
+
}
|
|
1271
|
+
function sleep(ms) {
|
|
1272
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1273
|
+
}
|
|
1274
|
+
function formatBetaReaderFeedback(test, fileName) {
|
|
1275
|
+
const feedbacks = test.individualFeedbacks || [];
|
|
1276
|
+
const metrics = test.aggregatedMetrics;
|
|
1277
|
+
if (feedbacks.length === 0 && !metrics) {
|
|
1278
|
+
return "No feedback available.";
|
|
1279
|
+
}
|
|
1280
|
+
let output = `# Beta Reader Feedback: ${fileName}
|
|
1281
|
+
|
|
1282
|
+
`;
|
|
1283
|
+
if (metrics) {
|
|
1284
|
+
output += formatAggregatedMetrics(metrics);
|
|
1285
|
+
}
|
|
1286
|
+
if (feedbacks.length > 0) {
|
|
1287
|
+
output += `
|
|
1288
|
+
---
|
|
1289
|
+
|
|
1290
|
+
## Individual Reader Feedback
|
|
969
1291
|
|
|
970
1292
|
`;
|
|
971
1293
|
output += feedbacks.map(formatIndividualFeedback).join("\n---\n\n");
|
|
@@ -1132,7 +1454,7 @@ ${d.finalThoughts}
|
|
|
1132
1454
|
}
|
|
1133
1455
|
return output;
|
|
1134
1456
|
}
|
|
1135
|
-
function
|
|
1457
|
+
function findFileInSnapshot(files, path) {
|
|
1136
1458
|
const normalizedPath = path.replace(/^\//, "").toLowerCase();
|
|
1137
1459
|
const pathParts = normalizedPath.split("/");
|
|
1138
1460
|
function search(items, remainingParts) {
|
|
@@ -1158,10 +1480,10 @@ function findFileInSnapshot2(files, path) {
|
|
|
1158
1480
|
}
|
|
1159
1481
|
let result = search(files, pathParts);
|
|
1160
1482
|
if (result) return result;
|
|
1161
|
-
result =
|
|
1483
|
+
result = searchByName(files, pathParts[pathParts.length - 1]);
|
|
1162
1484
|
return result;
|
|
1163
1485
|
}
|
|
1164
|
-
function
|
|
1486
|
+
function searchByName(files, name) {
|
|
1165
1487
|
const nameLower = name.toLowerCase();
|
|
1166
1488
|
for (const item of files) {
|
|
1167
1489
|
const itemNameLower = item.name.toLowerCase();
|
|
@@ -1170,7 +1492,7 @@ function searchByName2(files, name) {
|
|
|
1170
1492
|
return item;
|
|
1171
1493
|
}
|
|
1172
1494
|
if (item.kind === "folder" && item.children) {
|
|
1173
|
-
const result =
|
|
1495
|
+
const result = searchByName(item.children, name);
|
|
1174
1496
|
if (result) return result;
|
|
1175
1497
|
}
|
|
1176
1498
|
}
|
|
@@ -1179,10 +1501,54 @@ function searchByName2(files, name) {
|
|
|
1179
1501
|
|
|
1180
1502
|
// src/core/tools/files.ts
|
|
1181
1503
|
import { z as z4 } from "zod";
|
|
1504
|
+
|
|
1505
|
+
// src/core/ai-file-policy.ts
|
|
1506
|
+
var AI_WRITABLE_EXTENSIONS = [".md", ".markdown", ".txt"];
|
|
1507
|
+
var AI_STRUCTURED_TOOLS = {
|
|
1508
|
+
".beats": "update_beat_board",
|
|
1509
|
+
".timeline": "update_timeline",
|
|
1510
|
+
".script": "update_script",
|
|
1511
|
+
".map": "update_relationship_map",
|
|
1512
|
+
".geomap": "update_world_map"
|
|
1513
|
+
};
|
|
1514
|
+
var AI_READONLY_EXTENSIONS = [
|
|
1515
|
+
".character",
|
|
1516
|
+
".location",
|
|
1517
|
+
".json",
|
|
1518
|
+
".yaml",
|
|
1519
|
+
".yml"
|
|
1520
|
+
];
|
|
1521
|
+
var AI_STRUCTURED_VIEWERS = {
|
|
1522
|
+
".map": "view_relationship_map \u2014 renders the graph as an image plus a cast/relationship summary"
|
|
1523
|
+
};
|
|
1524
|
+
var AI_WRITE_POLICY_NOTE = `
|
|
1525
|
+
**Not every file type takes a generic write.**
|
|
1526
|
+
- Field-level tools only: ${Object.entries(AI_STRUCTURED_TOOLS).map(([ext, tool]) => `\`${ext}\` \u2192 \`${tool}\``).join(", ")}
|
|
1527
|
+
- Not writable at all: ${AI_READONLY_EXTENSIONS.map((e) => `\`${e}\``).join(", ")}
|
|
1528
|
+
|
|
1529
|
+
A refusal names the file type and what to do instead \u2014 it is information, not
|
|
1530
|
+
an obstacle to route around. Call \`get_capabilities\` for the authoritative
|
|
1531
|
+
table; this list ships with the client and can lag a deploy.`;
|
|
1532
|
+
|
|
1533
|
+
// src/core/tools/files.ts
|
|
1534
|
+
var SCRIPT_STUDIO_WRITE_WARNING = `
|
|
1535
|
+
**IMPORTANT \u2014 Script Studio books (\`book_type: "script"\`):**
|
|
1536
|
+
Writes are restricted to \`.script_studio/planning/**/*\` only.
|
|
1537
|
+
Structured files (\`series.json\`, \`*.character\`, \`*.scene\`, \`*.storyline\`, \`*.note\`, \`*.location\`, \`season.json\`, \`episode.json\`) are READ-ONLY via MCP \u2014 structural edits must go through the Script Studio UI.
|
|
1538
|
+
The sentinel file \`.script_studio/planning/.initialized\` is also read-only (frontend bootstrap marker).
|
|
1539
|
+
Attempting to write outside \`planning/\` returns HTTP 400 with code \`INVALID_PATH\`.
|
|
1540
|
+
For per-book details, read resource \`slima://books/{book_token}/schema\`.
|
|
1541
|
+
|
|
1542
|
+
For Writing Studio books (\`book_type: "book"\`), all paths remain writable.
|
|
1543
|
+
Always check \`book_type\` via \`list_books\` or \`get_book\` before writing to an unfamiliar book.`;
|
|
1544
|
+
var AI_READONLY_WARNING = AI_WRITE_POLICY_NOTE;
|
|
1545
|
+
var CONTENT_TYPE_HINT = `How Slima classifies this file. "manuscript" = the book itself (prose/chapters) \u2014 this is what counts toward the author's word count, daily goal and writing streak. "reference" = notes, worldbuilding, research (never counted). "character" / "location" / "storyline" = planner cards, which are reference plus a label. Omit to inherit the containing folder (root-level files default to "reference"). If you were asked to WRITE a chapter, pass "manuscript" \u2014 otherwise those words will not appear in the author's word count.`;
|
|
1546
|
+
var PATH_PARAM_HINT = 'File path relative to book root. For Script Studio books: MUST be under ".script_studio/planning/" (e.g., ".script_studio/planning/references/outline.md"). For Writing Studio books: any path (e.g., "characters/protagonist.md").';
|
|
1547
|
+
var SCRIPT_STRUCTURED_EXTENSIONS = ["character", "scene", "storyline", "note", "location"];
|
|
1182
1548
|
function registerFileTools(server, client) {
|
|
1183
1549
|
server.tool(
|
|
1184
1550
|
"read_file",
|
|
1185
|
-
"Read the content of any file in a book by its path",
|
|
1551
|
+
"Read the content of any file in a book by its path. For `.map` relationship graphs, prefer `view_relationship_map` \u2014 this tool returns their raw JSON (node ids and pixel coordinates), which is hard to reason about.",
|
|
1186
1552
|
{
|
|
1187
1553
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1188
1554
|
path: z4.string().describe('File path (e.g., "characters/protagonist.md" or "chapter-01.md")')
|
|
@@ -1191,19 +1557,8 @@ function registerFileTools(server, client) {
|
|
|
1191
1557
|
async ({ book_token, path }) => {
|
|
1192
1558
|
try {
|
|
1193
1559
|
const result = await client.readFile(book_token, path);
|
|
1194
|
-
const fileInfo = [
|
|
1195
|
-
`# ${result.file.name}`,
|
|
1196
|
-
"",
|
|
1197
|
-
`**Path:** ${result.file.path}`,
|
|
1198
|
-
`**Words:** ${result.file.wordCount}`,
|
|
1199
|
-
`**Type:** ${result.file.fileType || "unknown"}`,
|
|
1200
|
-
"",
|
|
1201
|
-
"---",
|
|
1202
|
-
"",
|
|
1203
|
-
result.content || "(empty file)"
|
|
1204
|
-
].join("\n");
|
|
1205
1560
|
return {
|
|
1206
|
-
content: [{ type: "text", text:
|
|
1561
|
+
content: [{ type: "text", text: renderFileRead(result) }]
|
|
1207
1562
|
};
|
|
1208
1563
|
} catch (error) {
|
|
1209
1564
|
return {
|
|
@@ -1215,10 +1570,12 @@ function registerFileTools(server, client) {
|
|
|
1215
1570
|
);
|
|
1216
1571
|
server.tool(
|
|
1217
1572
|
"write_file",
|
|
1218
|
-
|
|
1573
|
+
`Replace the entire content of an existing file (creates a new commit).
|
|
1574
|
+
${SCRIPT_STUDIO_WRITE_WARNING}
|
|
1575
|
+
${AI_READONLY_WARNING}`,
|
|
1219
1576
|
{
|
|
1220
1577
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1221
|
-
path: z4.string().describe(
|
|
1578
|
+
path: z4.string().describe(PATH_PARAM_HINT),
|
|
1222
1579
|
content: z4.string().describe("New content to write"),
|
|
1223
1580
|
commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
|
|
1224
1581
|
},
|
|
@@ -1251,66 +1608,36 @@ Message: ${result.commit.name || result.commit.message}`
|
|
|
1251
1608
|
);
|
|
1252
1609
|
server.tool(
|
|
1253
1610
|
"edit_file",
|
|
1254
|
-
|
|
1611
|
+
`Replace a specific text segment in a file (search and replace). Use this for precise edits instead of rewriting the whole file.
|
|
1612
|
+
|
|
1613
|
+
The replacement is performed by the server inside a lock, so the reported count is what was actually written \u2014 not a local guess. If the text is missing or ambiguous the call fails with a message telling you what to do next; act on it rather than retrying the same arguments.
|
|
1614
|
+
${SCRIPT_STUDIO_WRITE_WARNING}
|
|
1615
|
+
${AI_READONLY_WARNING}`,
|
|
1255
1616
|
{
|
|
1256
1617
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1257
|
-
path: z4.string().describe(
|
|
1618
|
+
path: z4.string().describe(PATH_PARAM_HINT),
|
|
1258
1619
|
old_string: z4.string().describe("The exact text to find and replace (must match exactly, including whitespace)"),
|
|
1259
|
-
new_string: z4.string().describe("The new text to replace it with"),
|
|
1260
|
-
replace_all: z4.boolean().optional().describe("Replace all occurrences (default: false, replaces only first match)"),
|
|
1620
|
+
new_string: z4.string().describe("The new text to replace it with (pass an empty string to delete)"),
|
|
1621
|
+
replace_all: z4.boolean().optional().describe("Replace all occurrences (default: false, replaces only the first match)"),
|
|
1261
1622
|
commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
|
|
1262
1623
|
},
|
|
1263
1624
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1264
1625
|
async ({ book_token, path, old_string, new_string, replace_all, commit_message }) => {
|
|
1265
1626
|
try {
|
|
1266
|
-
const
|
|
1267
|
-
const currentContent = fileData.content || "";
|
|
1268
|
-
if (!currentContent.includes(old_string)) {
|
|
1269
|
-
return {
|
|
1270
|
-
content: [
|
|
1271
|
-
{
|
|
1272
|
-
type: "text",
|
|
1273
|
-
text: `\u274C Edit failed: Could not find the specified text in the file.
|
|
1274
|
-
|
|
1275
|
-
**Looking for:**
|
|
1276
|
-
\`\`\`
|
|
1277
|
-
${old_string.slice(0, 200)}${old_string.length > 200 ? "..." : ""}
|
|
1278
|
-
\`\`\`
|
|
1279
|
-
|
|
1280
|
-
**Tip:** Make sure the text matches exactly, including whitespace and line breaks.`
|
|
1281
|
-
}
|
|
1282
|
-
],
|
|
1283
|
-
isError: true
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
if (!replace_all) {
|
|
1287
|
-
const occurrences = currentContent.split(old_string).length - 1;
|
|
1288
|
-
if (occurrences > 1) {
|
|
1289
|
-
return {
|
|
1290
|
-
content: [
|
|
1291
|
-
{
|
|
1292
|
-
type: "text",
|
|
1293
|
-
text: `\u274C Edit failed: Found ${occurrences} occurrences of the text. Please provide a more specific/longer text segment to ensure a unique match, or set replace_all: true to replace all occurrences.`
|
|
1294
|
-
}
|
|
1295
|
-
],
|
|
1296
|
-
isError: true
|
|
1297
|
-
};
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
const newContent = replace_all ? currentContent.split(old_string).join(new_string) : currentContent.replace(old_string, new_string);
|
|
1301
|
-
const result = await client.updateFile(book_token, {
|
|
1627
|
+
const result = await client.editFile(book_token, {
|
|
1302
1628
|
path,
|
|
1303
|
-
|
|
1304
|
-
|
|
1629
|
+
oldString: old_string,
|
|
1630
|
+
newString: new_string,
|
|
1631
|
+
replaceAll: replace_all,
|
|
1632
|
+
commitMessage: commit_message
|
|
1305
1633
|
});
|
|
1306
|
-
const replacementCount = replace_all ? currentContent.split(old_string).length - 1 : 1;
|
|
1307
1634
|
return {
|
|
1308
1635
|
content: [
|
|
1309
1636
|
{
|
|
1310
1637
|
type: "text",
|
|
1311
|
-
text: `\u2705 File edited
|
|
1638
|
+
text: `\u2705 File edited.
|
|
1312
1639
|
|
|
1313
|
-
**Replacements:** ${
|
|
1640
|
+
**Replacements applied by the server:** ${result.replacements}
|
|
1314
1641
|
**Commit:** ${result.commit.token}
|
|
1315
1642
|
**Message:** ${result.commit.name || result.commit.message}`
|
|
1316
1643
|
}
|
|
@@ -1326,21 +1653,35 @@ ${old_string.slice(0, 200)}${old_string.length > 200 ? "..." : ""}
|
|
|
1326
1653
|
);
|
|
1327
1654
|
server.tool(
|
|
1328
1655
|
"create_file",
|
|
1329
|
-
|
|
1656
|
+
`Create a new file in a book (creates a new commit).
|
|
1657
|
+
|
|
1658
|
+
**Writing a chapter? Pass \`content_type: "manuscript"\`.** Files default to
|
|
1659
|
+
"reference" (not counted in the author's word count, streak, or manuscript export)
|
|
1660
|
+
unless they inherit a manuscript folder or you say otherwise.
|
|
1661
|
+
${SCRIPT_STUDIO_WRITE_WARNING}
|
|
1662
|
+
${AI_READONLY_WARNING}`,
|
|
1330
1663
|
{
|
|
1331
1664
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1332
|
-
path: z4.string().describe(
|
|
1665
|
+
path: z4.string().describe(
|
|
1666
|
+
`${PATH_PARAM_HINT} Parent folders are auto-created (mkdir -p behavior).`
|
|
1667
|
+
),
|
|
1333
1668
|
content: z4.string().optional().describe("Initial content (optional, defaults to empty)"),
|
|
1669
|
+
content_type: z4.enum(["manuscript", "reference", "character", "location", "storyline"]).optional().describe(CONTENT_TYPE_HINT),
|
|
1334
1670
|
commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
|
|
1335
1671
|
},
|
|
1336
1672
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1337
|
-
async ({ book_token, path, content, commit_message }) => {
|
|
1673
|
+
async ({ book_token, path, content, content_type, commit_message }) => {
|
|
1338
1674
|
try {
|
|
1339
1675
|
const result = await client.createFile(book_token, {
|
|
1340
1676
|
path,
|
|
1341
1677
|
content: content || "",
|
|
1678
|
+
contentType: content_type,
|
|
1342
1679
|
commitMessage: commit_message
|
|
1343
1680
|
});
|
|
1681
|
+
const classification = content_type ? `Classified as: ${content_type}${content_type === "manuscript" ? " (counts toward the word count)" : " (not counted in the word count)"}` : "Classified by inheriting the parent folder (root-level defaults to reference, which is NOT counted in the word count).";
|
|
1682
|
+
const notes = (result.notes ?? []).map((n) => `
|
|
1683
|
+
|
|
1684
|
+
\u26A0\uFE0F ${n}`).join("");
|
|
1344
1685
|
return {
|
|
1345
1686
|
content: [
|
|
1346
1687
|
{
|
|
@@ -1349,7 +1690,8 @@ ${old_string.slice(0, 200)}${old_string.length > 200 ? "..." : ""}
|
|
|
1349
1690
|
|
|
1350
1691
|
File Token: ${result.fileToken}
|
|
1351
1692
|
Commit: ${result.commit.token}
|
|
1352
|
-
Message: ${result.commit.name || result.commit.message}
|
|
1693
|
+
Message: ${result.commit.name || result.commit.message}
|
|
1694
|
+
${classification}${notes}`
|
|
1353
1695
|
}
|
|
1354
1696
|
]
|
|
1355
1697
|
};
|
|
@@ -1363,27 +1705,42 @@ Message: ${result.commit.name || result.commit.message}`
|
|
|
1363
1705
|
);
|
|
1364
1706
|
server.tool(
|
|
1365
1707
|
"delete_file",
|
|
1366
|
-
|
|
1708
|
+
`Delete a file or an empty folder from a book (creates a new commit).
|
|
1709
|
+
|
|
1710
|
+
A folder that still has anything inside it is refused unless you pass
|
|
1711
|
+
\`recursive: true\`. That is not bureaucracy: deleting only the folder would
|
|
1712
|
+
leave its contents with no parent, and the app then shows the whole subtree at
|
|
1713
|
+
the book's top level \u2014 the author sees their files scattered, with no error.
|
|
1714
|
+
Say the word and everything inside goes, and you are told how many.
|
|
1715
|
+
${SCRIPT_STUDIO_WRITE_WARNING}
|
|
1716
|
+
${AI_READONLY_WARNING}`,
|
|
1367
1717
|
{
|
|
1368
1718
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1369
|
-
path: z4.string().describe(
|
|
1370
|
-
commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
|
|
1719
|
+
path: z4.string().describe(PATH_PARAM_HINT),
|
|
1720
|
+
commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)"),
|
|
1721
|
+
recursive: z4.boolean().optional().describe("Required to delete a folder that is not empty \u2014 everything inside goes with it")
|
|
1371
1722
|
},
|
|
1372
1723
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1373
|
-
async ({ book_token, path, commit_message }) => {
|
|
1724
|
+
async ({ book_token, path, commit_message, recursive }) => {
|
|
1374
1725
|
try {
|
|
1375
1726
|
const result = await client.deleteFile(book_token, {
|
|
1376
1727
|
path,
|
|
1377
|
-
commitMessage: commit_message
|
|
1728
|
+
commitMessage: commit_message,
|
|
1729
|
+
recursive
|
|
1378
1730
|
});
|
|
1379
1731
|
return {
|
|
1380
1732
|
content: [
|
|
1381
1733
|
{
|
|
1382
1734
|
type: "text",
|
|
1383
|
-
text:
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1735
|
+
text: [
|
|
1736
|
+
"File deleted successfully.",
|
|
1737
|
+
// The model asked to delete one path; several may have gone. It has to
|
|
1738
|
+
// be able to tell the author that.
|
|
1739
|
+
result.deletedDescendants ? `${result.deletedDescendants} item(s) inside it were deleted too \u2014 tell the author.` : null,
|
|
1740
|
+
"",
|
|
1741
|
+
`Commit: ${result.commit.token}`,
|
|
1742
|
+
`Message: ${result.commit.name || result.commit.message}`
|
|
1743
|
+
].filter((line) => line !== null).join("\n")
|
|
1387
1744
|
}
|
|
1388
1745
|
]
|
|
1389
1746
|
};
|
|
@@ -1397,10 +1754,12 @@ Message: ${result.commit.name || result.commit.message}`
|
|
|
1397
1754
|
);
|
|
1398
1755
|
server.tool(
|
|
1399
1756
|
"append_to_file",
|
|
1400
|
-
|
|
1757
|
+
`Append content to the end of an existing file (creates a new commit).
|
|
1758
|
+
${SCRIPT_STUDIO_WRITE_WARNING}
|
|
1759
|
+
${AI_READONLY_WARNING}`,
|
|
1401
1760
|
{
|
|
1402
1761
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1403
|
-
path: z4.string().describe(
|
|
1762
|
+
path: z4.string().describe(PATH_PARAM_HINT),
|
|
1404
1763
|
content: z4.string().describe("Content to append"),
|
|
1405
1764
|
commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
|
|
1406
1765
|
},
|
|
@@ -1431,29 +1790,102 @@ Message: ${result.commit.name || result.commit.message}`
|
|
|
1431
1790
|
}
|
|
1432
1791
|
}
|
|
1433
1792
|
);
|
|
1793
|
+
server.tool(
|
|
1794
|
+
"view_relationship_map",
|
|
1795
|
+
`See a \`.map\` character-relationship graph as a picture plus a readable summary.
|
|
1796
|
+
|
|
1797
|
+
Use this instead of \`read_file\` for \`.map\` files: their raw JSON is node ids and pixel coordinates.
|
|
1798
|
+
The summary lists every character, every relationship ("A \u2014childhood friends\u2192 B") and any faction groups,
|
|
1799
|
+
so it is useful even in clients that do not display images.
|
|
1800
|
+
|
|
1801
|
+
\`.map\` files are read-only to AI \u2014 describe changes you recommend in words; the author edits them in the map editor.`,
|
|
1802
|
+
{
|
|
1803
|
+
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1804
|
+
path: z4.string().describe('Path to a .map file (e.g., "world/relations.map")')
|
|
1805
|
+
},
|
|
1806
|
+
{ readOnlyHint: true, openWorldHint: true },
|
|
1807
|
+
async ({ book_token, path }) => {
|
|
1808
|
+
try {
|
|
1809
|
+
const result = await client.visualizeFile(book_token, path);
|
|
1810
|
+
if (result.contentWithheld) {
|
|
1811
|
+
return {
|
|
1812
|
+
content: [
|
|
1813
|
+
{
|
|
1814
|
+
type: "text",
|
|
1815
|
+
text: `\u{1F512} "${path}" is a confidential team skill. Its contents are withheld from you by your team's settings (reason: ${result.withheldReason ?? "confidential"}). This is intentional, not an error \u2014 do not try to rewrite it.`
|
|
1816
|
+
}
|
|
1817
|
+
]
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
if (result.throttled) {
|
|
1821
|
+
return {
|
|
1822
|
+
content: [
|
|
1823
|
+
{
|
|
1824
|
+
type: "text",
|
|
1825
|
+
text: `\u23F3 Read budget for this team skill is exhausted for today (${result.throttledReason ?? "daily_read_limit"}). Try again later; the file is not empty.`
|
|
1826
|
+
}
|
|
1827
|
+
]
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
const content = [{ type: "text", text: result.summary ?? "(no summary available)" }];
|
|
1831
|
+
if (result.format === "png" && result.image) {
|
|
1832
|
+
content.push({ type: "image", data: result.image, mimeType: "image/png" });
|
|
1833
|
+
} else if (result.imageUnavailable) {
|
|
1834
|
+
content.push({
|
|
1835
|
+
type: "text",
|
|
1836
|
+
text: `_(The image could not be rendered on the server, so only the summary above is available.)_`
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
return { content };
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
return {
|
|
1842
|
+
content: [{ type: "text", text: formatErrorForMcp(error) }],
|
|
1843
|
+
isError: true
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
);
|
|
1434
1848
|
server.tool(
|
|
1435
1849
|
"search_content",
|
|
1436
|
-
|
|
1850
|
+
`Search for content across all files in a book.
|
|
1851
|
+
|
|
1852
|
+
**Script Studio behavior:** By default, structured files (\`*.character\`, \`*.scene\`, \`*.storyline\`, \`*.note\`, \`*.location\`, and \`*.json\` under \`.script_studio/\`) are **excluded** from search results \u2014 their raw JSON is noisy and not useful for full-text search.
|
|
1853
|
+
Pass \`include_structured: true\` to search those files too.
|
|
1854
|
+
Writing Studio books are unaffected by this filter.`,
|
|
1437
1855
|
{
|
|
1438
1856
|
book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
|
|
1439
1857
|
query: z4.string().describe('Search query (e.g., "blue eyes" or "protagonist name")'),
|
|
1440
|
-
file_types: z4.array(z4.string()).optional().describe('Filter by file
|
|
1441
|
-
limit: z4.number().optional().describe("Maximum number of matches to return (default: 20)")
|
|
1858
|
+
file_types: z4.array(z4.string()).optional().describe('Filter by file extension, with or without the dot: ["md", ".txt", "beats"]'),
|
|
1859
|
+
limit: z4.number().optional().describe("Maximum number of matches to return (default: 20)"),
|
|
1860
|
+
include_structured: z4.boolean().optional().describe(
|
|
1861
|
+
"Script Studio only: set to true to include structured files (*.character / *.scene / etc.) in search results. Default: false (excluded)."
|
|
1862
|
+
)
|
|
1442
1863
|
},
|
|
1443
1864
|
{ readOnlyHint: true, openWorldHint: true },
|
|
1444
|
-
async ({ book_token, query, file_types, limit }) => {
|
|
1865
|
+
async ({ book_token, query, file_types, limit, include_structured }) => {
|
|
1445
1866
|
try {
|
|
1867
|
+
let bookType;
|
|
1868
|
+
if (!include_structured) {
|
|
1869
|
+
try {
|
|
1870
|
+
const book = await client.getBook(book_token);
|
|
1871
|
+
bookType = book.bookType;
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1446
1875
|
const result = await client.searchFiles(book_token, {
|
|
1447
1876
|
query,
|
|
1448
1877
|
fileTypes: file_types,
|
|
1449
1878
|
limit
|
|
1450
1879
|
});
|
|
1451
|
-
|
|
1880
|
+
const shouldFilter = !include_structured && bookType === "script";
|
|
1881
|
+
const filteredMatches = shouldFilter ? result.matches.filter((m) => !isStructuredScriptFile(m.file.path)) : result.matches;
|
|
1882
|
+
if (filteredMatches.length === 0) {
|
|
1883
|
+
const suffix = shouldFilter && result.matches.length > 0 ? ` (${result.matches.length} match(es) in structured files were excluded \u2014 pass include_structured: true to see them)` : "";
|
|
1452
1884
|
return {
|
|
1453
1885
|
content: [
|
|
1454
1886
|
{
|
|
1455
1887
|
type: "text",
|
|
1456
|
-
text: `No matches found for "${query}".`
|
|
1888
|
+
text: `No matches found for "${query}"${suffix}.`
|
|
1457
1889
|
}
|
|
1458
1890
|
]
|
|
1459
1891
|
};
|
|
@@ -1461,10 +1893,17 @@ Message: ${result.commit.name || result.commit.message}`
|
|
|
1461
1893
|
const output = [
|
|
1462
1894
|
`# Search Results for "${query}"`,
|
|
1463
1895
|
"",
|
|
1464
|
-
`Found ${
|
|
1896
|
+
`Found ${filteredMatches.length} file(s) with matches:`,
|
|
1465
1897
|
""
|
|
1466
1898
|
];
|
|
1467
|
-
|
|
1899
|
+
if (shouldFilter && filteredMatches.length < result.matches.length) {
|
|
1900
|
+
const excluded = result.matches.length - filteredMatches.length;
|
|
1901
|
+
output.push(
|
|
1902
|
+
`_Note: excluded ${excluded} Script Studio structured file(s). Pass include_structured: true to include them._`
|
|
1903
|
+
);
|
|
1904
|
+
output.push("");
|
|
1905
|
+
}
|
|
1906
|
+
for (const match of filteredMatches) {
|
|
1468
1907
|
output.push(`## ${match.file.path}`);
|
|
1469
1908
|
output.push(`Words: ${match.file.wordCount} | Matches: ${match.matchCount}`);
|
|
1470
1909
|
output.push("");
|
|
@@ -1485,6 +1924,611 @@ Message: ${result.commit.name || result.commit.message}`
|
|
|
1485
1924
|
}
|
|
1486
1925
|
);
|
|
1487
1926
|
}
|
|
1927
|
+
function isStructuredScriptFile(path) {
|
|
1928
|
+
if (!path) return false;
|
|
1929
|
+
const lower = path.toLowerCase();
|
|
1930
|
+
if (lower.startsWith(".script_studio/") && lower.endsWith(".json")) return true;
|
|
1931
|
+
const ext = lower.split(".").pop();
|
|
1932
|
+
if (!ext) return false;
|
|
1933
|
+
return SCRIPT_STRUCTURED_EXTENSIONS.includes(ext);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// src/core/tools/structured.ts
|
|
1937
|
+
import { z as z5 } from "zod";
|
|
1938
|
+
var TYPES = [
|
|
1939
|
+
{
|
|
1940
|
+
tool: "update_beat_board",
|
|
1941
|
+
extension: ".beats",
|
|
1942
|
+
noun: "beat board",
|
|
1943
|
+
ops: "add_act, update_act, remove_act, add_beat, update_beat, remove_beat, move_beat",
|
|
1944
|
+
example: '{"op": "add_act", "name": "Act Two", "op_ref": "A2"}, {"op": "add_beat", "title": "The turn", "act_id": "@A2"}'
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
tool: "update_timeline",
|
|
1948
|
+
extension: ".timeline",
|
|
1949
|
+
noun: "timeline",
|
|
1950
|
+
ops: "add_entry, update_entry, remove_entry, set_config",
|
|
1951
|
+
example: '{"op": "add_entry", "title": "The siege begins", "time_mode": "label", "time_label": "Winter"}'
|
|
1952
|
+
},
|
|
1953
|
+
{
|
|
1954
|
+
tool: "update_script",
|
|
1955
|
+
extension: ".script",
|
|
1956
|
+
noun: "screenplay",
|
|
1957
|
+
ops: "add_scene, update_scene_heading, remove_scene, add_element, update_element, remove_element",
|
|
1958
|
+
example: '{"op": "add_scene", "interior": true, "location": "HARBOUR", "op_ref": "S1"}, {"op": "add_element", "scene_id": "@S1", "type": "action", "text": "The fog lifts."}'
|
|
1959
|
+
},
|
|
1960
|
+
{
|
|
1961
|
+
tool: "update_relationship_map",
|
|
1962
|
+
extension: ".map",
|
|
1963
|
+
noun: "relationship map",
|
|
1964
|
+
ops: "add_node, update_node, move_node, remove_node, add_edge, update_edge, remove_edge, add_group, update_group, remove_group, set_meta",
|
|
1965
|
+
example: '{"op": "add_node", "label": "Lin Shuang", "op_ref": "N1", "position": {"near_id": "<id of Shen Yan>", "direction": "right"}}, {"op": "add_edge", "source_id": "<id of Shen Yan>", "target_id": "@N1", "label": "mentor"}',
|
|
1966
|
+
extra: '**Position is content here, not layout.** `update_node` will not move anything \u2014 that is\n`move_node`, so that "I moved her next to him" is its own reviewable change. Prefer\n`{near_id, direction}` over computing numbers. Existing nodes never move; the final\ncoordinates come back in the notes. Group boxes are computed from `member_ids` \u2014 never\nsend x/y/width/height. Removing a node removes its edges, and you are told so.'
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
tool: "update_world_map",
|
|
1970
|
+
extension: ".geomap",
|
|
1971
|
+
noun: "world map",
|
|
1972
|
+
ops: "add_place, update_place, add_label, update_label, remove_element, add_river, add_border, paint_region, set_meta",
|
|
1973
|
+
example: '{"op": "add_place", "x": 210, "y": 96, "name": "Starhaven", "tier": "port"}, {"op": "add_label", "x": 240, "y": 60, "text": "The Cold Sea", "size": "large"}',
|
|
1974
|
+
extra: "**Coordinates are grid cells, not pixels**: x 0-519, y 0-363. `read_file` gives you the\nmap with its three terrain grids replaced by a note \u2014 they are machine-encoded and you\ndo not need them; `paint_region` works in cells. `remove_element` takes any element id\n(`pl_` place, `lb_` label, `rv_` river, `bd_` border). Terrain has no undo: paint over it\ninstead. Move a place with `update_place`, never by removing and re-adding it. An unknown\nplace `tier` is refused with the full list of real ones \u2014 read the error, do not guess."
|
|
1975
|
+
}
|
|
1976
|
+
];
|
|
1977
|
+
var SHARED_RULES = `
|
|
1978
|
+
**Read the file first.** Every id you send must come from that read \u2014 never
|
|
1979
|
+
invent one, never retype one from memory. An unknown id is refused with the
|
|
1980
|
+
list of real ones, so a mistake costs a round trip, not the author's data.
|
|
1981
|
+
|
|
1982
|
+
**One operation per change.** Each operation takes only its own arguments; a
|
|
1983
|
+
field belonging to a different operation is refused rather than ignored.
|
|
1984
|
+
|
|
1985
|
+
**\`op_ref\` chains within one batch.** Label what an operation creates with
|
|
1986
|
+
\`op_ref: "A2"\`, then a LATER operation can point at it as \`"@A2"\`. Backward
|
|
1987
|
+
references only, and only inside this one batch \u2014 a later call must use the
|
|
1988
|
+
real id, which comes back in \`applied\`.
|
|
1989
|
+
|
|
1990
|
+
**The batch is all-or-nothing.** If one operation fails nothing is written, so
|
|
1991
|
+
you never have to work out how far it got.
|
|
1992
|
+
|
|
1993
|
+
**Unlike the in-app assistant, there is no review panel here.** What you send
|
|
1994
|
+
lands in the author's version history immediately. Say what you changed, not
|
|
1995
|
+
"please confirm it".`;
|
|
1996
|
+
function describe(type) {
|
|
1997
|
+
return `Change a Slima ${type.noun} (\`${type.extension}\`) one field at a time.
|
|
1998
|
+
|
|
1999
|
+
This is the ONLY way to write this file type \u2014 \`write_file\` and \`edit_file\`
|
|
2000
|
+
are refused for it, because a whole-file write would drop the parts of the
|
|
2001
|
+
document you were never shown.
|
|
2002
|
+
|
|
2003
|
+
Operations: ${type.ops}
|
|
2004
|
+
Example \`operations\`: [${type.example}]
|
|
2005
|
+
${SHARED_RULES}${type.extra ? `
|
|
2006
|
+
|
|
2007
|
+
${type.extra}` : ""}`;
|
|
2008
|
+
}
|
|
2009
|
+
function render(type, result) {
|
|
2010
|
+
const lines = [`Applied ${result.applied.length} change(s) to the ${type.noun}.`, ""];
|
|
2011
|
+
const created = result.applied.filter((a) => a.id);
|
|
2012
|
+
if (created.length > 0) {
|
|
2013
|
+
lines.push("**New ids** (use these in later calls \u2014 do not re-read just for them):");
|
|
2014
|
+
for (const a of created) lines.push(`- \`${a.op}\` \u2192 ${a.id}${a.op_ref ? ` (was @${a.op_ref})` : ""}`);
|
|
2015
|
+
lines.push("");
|
|
2016
|
+
}
|
|
2017
|
+
if (result.notes.length > 0) {
|
|
2018
|
+
lines.push("**Also happened, tell the author:**");
|
|
2019
|
+
for (const note of result.notes) lines.push(`- ${note}`);
|
|
2020
|
+
lines.push("");
|
|
2021
|
+
}
|
|
2022
|
+
lines.push(`**Commit:** ${result.commit.token}`);
|
|
2023
|
+
return lines.join("\n");
|
|
2024
|
+
}
|
|
2025
|
+
function registerStructuredTools(server, client) {
|
|
2026
|
+
for (const type of TYPES) {
|
|
2027
|
+
server.tool(
|
|
2028
|
+
type.tool,
|
|
2029
|
+
describe(type),
|
|
2030
|
+
{
|
|
2031
|
+
book_token: z5.string().describe("Book token (e.g., bk_abc123)"),
|
|
2032
|
+
path: z5.string().describe(`Path to the ${type.extension} file (e.g., "plan/board${type.extension}")`),
|
|
2033
|
+
intent: z5.string().describe(
|
|
2034
|
+
`One line in the author's own language saying what this batch does \u2014 it becomes the commit message they read later. Not "batch update".`
|
|
2035
|
+
),
|
|
2036
|
+
operations: z5.array(z5.record(z5.unknown())).describe(
|
|
2037
|
+
"The changes, in order. Each is a flat object: `op` names the operation and the other keys are that operation's arguments. There is no nested `args`."
|
|
2038
|
+
)
|
|
2039
|
+
},
|
|
2040
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2041
|
+
async ({ book_token, path, intent, operations }) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const result = await client.applyStructuredOps(book_token, {
|
|
2044
|
+
path,
|
|
2045
|
+
intent,
|
|
2046
|
+
operations
|
|
2047
|
+
});
|
|
2048
|
+
return { content: [{ type: "text", text: render(type, result) }] };
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// src/core/tools/teaching.ts
|
|
2058
|
+
import { z as z6 } from "zod";
|
|
2059
|
+
var READ_ONLY = {
|
|
2060
|
+
readOnlyHint: true,
|
|
2061
|
+
destructiveHint: false,
|
|
2062
|
+
idempotentHint: true,
|
|
2063
|
+
openWorldHint: true
|
|
2064
|
+
};
|
|
2065
|
+
function text(body) {
|
|
2066
|
+
return { content: [{ type: "text", text: body }] };
|
|
2067
|
+
}
|
|
2068
|
+
function failure(error) {
|
|
2069
|
+
return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
|
|
2070
|
+
}
|
|
2071
|
+
function registerTeachingTools(server, client) {
|
|
2072
|
+
server.tool(
|
|
2073
|
+
"get_capabilities",
|
|
2074
|
+
`What this Slima server supports: which file extensions an AI may write, the content types, the available tools, and the guide index.
|
|
2075
|
+
|
|
2076
|
+
Call this once at the start of a Slima session, before writing anything. Every field is derived from the contract the server actually enforces, so it cannot drift from real behaviour the way a written description can.`,
|
|
2077
|
+
{},
|
|
2078
|
+
READ_ONLY,
|
|
2079
|
+
async () => {
|
|
2080
|
+
try {
|
|
2081
|
+
return text(JSON.stringify(await client.getCapabilities(), null, 2));
|
|
2082
|
+
} catch (error) {
|
|
2083
|
+
return failure(error);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
);
|
|
2087
|
+
server.tool(
|
|
2088
|
+
"list_guides",
|
|
2089
|
+
`List the available Slima guides: titles and one-line summaries of when to load each.
|
|
2090
|
+
|
|
2091
|
+
Guides teach how to operate Slima \u2014 file structure, version control, team knowledge bases, the relationship-map format. Load one with \`load_guide\` when you are about to do the thing it covers.`,
|
|
2092
|
+
{},
|
|
2093
|
+
READ_ONLY,
|
|
2094
|
+
async () => {
|
|
2095
|
+
try {
|
|
2096
|
+
const result = await client.listGuides();
|
|
2097
|
+
const rows = result.guides.map((g) => `- **${g.slug}** \u2014 ${g.title}
|
|
2098
|
+
${g.summary}`);
|
|
2099
|
+
return text(`## Slima guides
|
|
2100
|
+
|
|
2101
|
+
${rows.join("\n")}`);
|
|
2102
|
+
} catch (error) {
|
|
2103
|
+
return failure(error);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
);
|
|
2107
|
+
server.tool(
|
|
2108
|
+
"load_guide",
|
|
2109
|
+
`Load one Slima guide in full.
|
|
2110
|
+
|
|
2111
|
+
Read \`book-structure\` before creating files, \`version-control-basics\` before a large rewrite, \`team-knowledge-base\` when working in a publisher's team, \`beta-reader\` before spending the author's credits on a report. \`list_guides\` has the current set.`,
|
|
2112
|
+
{
|
|
2113
|
+
slug: z6.string().describe('Guide slug, e.g. "book-structure" (from list_guides)')
|
|
2114
|
+
},
|
|
2115
|
+
READ_ONLY,
|
|
2116
|
+
async ({ slug }) => {
|
|
2117
|
+
try {
|
|
2118
|
+
const guide = await client.loadGuide(slug);
|
|
2119
|
+
return text(`# ${guide.title}
|
|
2120
|
+
|
|
2121
|
+
${guide.body}`);
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
return failure(error);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
);
|
|
2127
|
+
server.tool(
|
|
2128
|
+
"list_writing_skills",
|
|
2129
|
+
`List Slima's writing skills \u2014 the craft procedures the in-app coach follows for tasks like developing a character, revising a scene, or diagnosing pacing.
|
|
2130
|
+
|
|
2131
|
+
Use these when the task is about the WRITING. Use \`list_guides\` when the task is about operating Slima.`,
|
|
2132
|
+
{},
|
|
2133
|
+
READ_ONLY,
|
|
2134
|
+
async () => {
|
|
2135
|
+
try {
|
|
2136
|
+
const result = await client.listWritingSkills();
|
|
2137
|
+
const rows = result.skills.map((s) => `- **${s.key}** \u2014 ${s.name}: ${s.description}`);
|
|
2138
|
+
return text(`## Writing skills
|
|
2139
|
+
|
|
2140
|
+
${rows.join("\n")}`);
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
return failure(error);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
);
|
|
2146
|
+
server.tool(
|
|
2147
|
+
"load_writing_skill",
|
|
2148
|
+
`Load one writing skill in full \u2014 a step-by-step procedure plus the questions a coach would ask.
|
|
2149
|
+
|
|
2150
|
+
Follow it rather than summarising it at the author.`,
|
|
2151
|
+
{
|
|
2152
|
+
key: z6.string().describe('Skill key, e.g. "character_develop" (from list_writing_skills)')
|
|
2153
|
+
},
|
|
2154
|
+
READ_ONLY,
|
|
2155
|
+
async ({ key }) => {
|
|
2156
|
+
try {
|
|
2157
|
+
const skill = await client.loadWritingSkill(key);
|
|
2158
|
+
return text(`# ${skill.name}
|
|
2159
|
+
|
|
2160
|
+
${skill.body}`);
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
return failure(error);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// src/core/tools/teams.ts
|
|
2169
|
+
import { z as z7 } from "zod";
|
|
2170
|
+
var READ_ONLY2 = {
|
|
2171
|
+
readOnlyHint: true,
|
|
2172
|
+
destructiveHint: false,
|
|
2173
|
+
idempotentHint: true,
|
|
2174
|
+
openWorldHint: true
|
|
2175
|
+
};
|
|
2176
|
+
function json(body) {
|
|
2177
|
+
return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
|
|
2178
|
+
}
|
|
2179
|
+
function failure2(error) {
|
|
2180
|
+
return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
|
|
2181
|
+
}
|
|
2182
|
+
var TEAM_TOKEN = z7.string().describe("Team token (from list_teams)");
|
|
2183
|
+
function registerTeamTools(server, client) {
|
|
2184
|
+
server.tool(
|
|
2185
|
+
"list_teams",
|
|
2186
|
+
`List the teams this account belongs to \u2014 publishers, studios, writing groups.
|
|
2187
|
+
|
|
2188
|
+
A team book carries house rules and shared knowledge that change how you should write for it. If a book belongs to a team, read \`list_team_rules\` before writing.`,
|
|
2189
|
+
{},
|
|
2190
|
+
READ_ONLY2,
|
|
2191
|
+
async () => {
|
|
2192
|
+
try {
|
|
2193
|
+
return json(await client.listTeams());
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
return failure2(error);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
);
|
|
2199
|
+
server.tool(
|
|
2200
|
+
"get_team",
|
|
2201
|
+
"Get one team: its members and their roles.",
|
|
2202
|
+
{ team_token: TEAM_TOKEN },
|
|
2203
|
+
READ_ONLY2,
|
|
2204
|
+
async ({ team_token }) => {
|
|
2205
|
+
try {
|
|
2206
|
+
return json(await client.getTeam(team_token));
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
return failure2(error);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
);
|
|
2212
|
+
server.tool(
|
|
2213
|
+
"list_team_rules",
|
|
2214
|
+
`The team's coaching rules \u2014 its house style \u2014 each marked mandatory or advisory.
|
|
2215
|
+
|
|
2216
|
+
Read these BEFORE writing for a team book, not after a draft is rejected. Mandatory rules are not suggestions: prose that ignores one gets re-edited by a person, which is worse than not writing it.`,
|
|
2217
|
+
{ team_token: TEAM_TOKEN },
|
|
2218
|
+
READ_ONLY2,
|
|
2219
|
+
async ({ team_token }) => {
|
|
2220
|
+
try {
|
|
2221
|
+
return json(await client.listTeamRules(team_token));
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
return failure2(error);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
);
|
|
2227
|
+
server.tool(
|
|
2228
|
+
"list_team_memories",
|
|
2229
|
+
`What the team has established over time: recurring characters, naming conventions, continuity spanning books, decisions already argued through.
|
|
2230
|
+
|
|
2231
|
+
Treat these as settled. If one contradicts a book's text, say so rather than picking a side.`,
|
|
2232
|
+
{ team_token: TEAM_TOKEN },
|
|
2233
|
+
READ_ONLY2,
|
|
2234
|
+
async ({ team_token }) => {
|
|
2235
|
+
try {
|
|
2236
|
+
return json(await client.listTeamMemories(team_token));
|
|
2237
|
+
} catch (error) {
|
|
2238
|
+
return failure2(error);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
);
|
|
2242
|
+
server.tool(
|
|
2243
|
+
"list_team_skills",
|
|
2244
|
+
`The team's own writing procedures.
|
|
2245
|
+
|
|
2246
|
+
Some are marked \`confidential: true\` and come back WITHOUT a source path; their file contents are withheld from this connection. That is deliberate, and it does not stop the skill working \u2014 the server applies it on the team's behalf. Do not try to reconstruct one, reproduce it verbatim, or reach its contents by another route: those routes are closed and audited. Read the \`team-knowledge-base\` guide before working in a team knowledge base.`,
|
|
2247
|
+
{ team_token: TEAM_TOKEN },
|
|
2248
|
+
READ_ONLY2,
|
|
2249
|
+
async ({ team_token }) => {
|
|
2250
|
+
try {
|
|
2251
|
+
return json(await client.listTeamSkills(team_token));
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
return failure2(error);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// src/core/tools/workspace.ts
|
|
2260
|
+
import { z as z8 } from "zod";
|
|
2261
|
+
var READ_ONLY3 = {
|
|
2262
|
+
readOnlyHint: true,
|
|
2263
|
+
destructiveHint: false,
|
|
2264
|
+
idempotentHint: true,
|
|
2265
|
+
openWorldHint: true
|
|
2266
|
+
};
|
|
2267
|
+
var WRITES = {
|
|
2268
|
+
readOnlyHint: false,
|
|
2269
|
+
destructiveHint: false,
|
|
2270
|
+
idempotentHint: false,
|
|
2271
|
+
openWorldHint: true
|
|
2272
|
+
};
|
|
2273
|
+
var BOOK_TOKEN = z8.string().describe("Book token (bk_...)");
|
|
2274
|
+
function json2(body) {
|
|
2275
|
+
return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
|
|
2276
|
+
}
|
|
2277
|
+
function failure3(error) {
|
|
2278
|
+
return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
|
|
2279
|
+
}
|
|
2280
|
+
function registerWorkspaceTools(server, client) {
|
|
2281
|
+
server.tool(
|
|
2282
|
+
"list_notes",
|
|
2283
|
+
"The author's margin notes on this book \u2014 book-level and chapter-level, including foreshadowing pairs.",
|
|
2284
|
+
{ book_token: BOOK_TOKEN },
|
|
2285
|
+
READ_ONLY3,
|
|
2286
|
+
async ({ book_token }) => {
|
|
2287
|
+
try {
|
|
2288
|
+
return json2(await client.listNotes(book_token));
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
return failure3(error);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
);
|
|
2294
|
+
server.tool(
|
|
2295
|
+
"create_note",
|
|
2296
|
+
`Leave a note beside the manuscript. Notes do not touch the prose or the word count.
|
|
2297
|
+
|
|
2298
|
+
Use \`note_type: "chapter"\` with the file's token in \`file_id\` to anchor it to one chapter; \`"book"\` for the work as a whole. Do not use notes as your own scratchpad \u2014 they appear in the author's interface as their own thinking. See the \`notes-and-foreshadowing\` guide.`,
|
|
2299
|
+
{
|
|
2300
|
+
book_token: BOOK_TOKEN,
|
|
2301
|
+
title: z8.string().describe("Short title (required)"),
|
|
2302
|
+
content: z8.string().optional().describe("Body, up to 50,000 characters"),
|
|
2303
|
+
note_type: z8.enum(["book", "chapter"]).optional().describe('Defaults to "book"'),
|
|
2304
|
+
file_id: z8.string().optional().describe('File token \u2014 required when note_type is "chapter"'),
|
|
2305
|
+
color: z8.enum(["gray", "red", "blue", "green", "yellow", "purple"]).optional()
|
|
2306
|
+
},
|
|
2307
|
+
WRITES,
|
|
2308
|
+
async ({ book_token, title, content, note_type, file_id, color }) => {
|
|
2309
|
+
try {
|
|
2310
|
+
return json2(
|
|
2311
|
+
await client.createNote(book_token, {
|
|
2312
|
+
title,
|
|
2313
|
+
content,
|
|
2314
|
+
note_type: note_type ?? "book",
|
|
2315
|
+
file_id,
|
|
2316
|
+
color
|
|
2317
|
+
})
|
|
2318
|
+
);
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
return failure3(error);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
);
|
|
2324
|
+
server.tool(
|
|
2325
|
+
"semantic_search",
|
|
2326
|
+
`Find passages by MEANING rather than by the words used.
|
|
2327
|
+
|
|
2328
|
+
\`search_content\` finds the word an author typed; this finds the passage they meant \u2014 "where does the mother's resentment surface?" returns the scene even when neither word appears in it. On a team knowledge base it is the difference between browsing and asking.
|
|
2329
|
+
|
|
2330
|
+
Costs a small embedding charge, unlike literal search. Returns \xA7N paragraph anchors so you can quote them back precisely.`,
|
|
2331
|
+
{
|
|
2332
|
+
book_token: BOOK_TOKEN,
|
|
2333
|
+
query: z8.string().describe("What you are looking for, in natural language"),
|
|
2334
|
+
limit: z8.number().optional().describe("Max results (default 10, cap 50)")
|
|
2335
|
+
},
|
|
2336
|
+
{ readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2337
|
+
async ({ book_token, query, limit }) => {
|
|
2338
|
+
try {
|
|
2339
|
+
return json2(await client.semanticSearch(book_token, query, limit));
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
return failure3(error);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
);
|
|
2345
|
+
server.tool(
|
|
2346
|
+
"list_comments",
|
|
2347
|
+
`Editorial comment threads on this book, open and resolved.
|
|
2348
|
+
|
|
2349
|
+
Read these before starting work on a book you have been handed \u2014 they are where an editor has already said what is wrong, and producing an independent list without reading them usually contradicts a decision someone already made.`,
|
|
2350
|
+
{ book_token: BOOK_TOKEN },
|
|
2351
|
+
READ_ONLY3,
|
|
2352
|
+
async ({ book_token }) => {
|
|
2353
|
+
try {
|
|
2354
|
+
return json2(await client.listComments(book_token));
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
return failure3(error);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
);
|
|
2360
|
+
server.tool(
|
|
2361
|
+
"create_comment",
|
|
2362
|
+
`Leave an editorial comment.
|
|
2363
|
+
|
|
2364
|
+
Comment when the change is a judgement call \u2014 pacing, whether a motivation lands, whether a scene earns its length. Edit the file instead when the author asked for the change. When unsure, comment: a comment costs ten seconds to dismiss, an unwanted rewrite costs the author a diff of their own prose.`,
|
|
2365
|
+
{
|
|
2366
|
+
book_token: BOOK_TOKEN,
|
|
2367
|
+
content: z8.string().describe("The comment"),
|
|
2368
|
+
file_token: z8.string().optional().describe("File this comment is about, if any")
|
|
2369
|
+
},
|
|
2370
|
+
WRITES,
|
|
2371
|
+
async ({ book_token, content, file_token }) => {
|
|
2372
|
+
try {
|
|
2373
|
+
return json2(await client.createComment(book_token, { content, file_token }));
|
|
2374
|
+
} catch (error) {
|
|
2375
|
+
return failure3(error);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
server.tool(
|
|
2380
|
+
"resolve_comment",
|
|
2381
|
+
`Mark a comment thread addressed.
|
|
2382
|
+
|
|
2383
|
+
Say what you did when you resolve one. A silent resolve tells the editor nothing about whether you understood the note.`,
|
|
2384
|
+
{
|
|
2385
|
+
book_token: BOOK_TOKEN,
|
|
2386
|
+
comment_token: z8.string().describe("Comment token (from list_comments)")
|
|
2387
|
+
},
|
|
2388
|
+
WRITES,
|
|
2389
|
+
async ({ book_token, comment_token }) => {
|
|
2390
|
+
try {
|
|
2391
|
+
return json2(await client.resolveComment(book_token, comment_token));
|
|
2392
|
+
} catch (error) {
|
|
2393
|
+
return failure3(error);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
);
|
|
2397
|
+
server.tool(
|
|
2398
|
+
"reopen_comment",
|
|
2399
|
+
"Reopen a comment thread that was resolved but should not have been.",
|
|
2400
|
+
{
|
|
2401
|
+
book_token: BOOK_TOKEN,
|
|
2402
|
+
comment_token: z8.string().describe("Comment token (from list_comments)")
|
|
2403
|
+
},
|
|
2404
|
+
WRITES,
|
|
2405
|
+
async ({ book_token, comment_token }) => {
|
|
2406
|
+
try {
|
|
2407
|
+
return json2(await client.reopenComment(book_token, comment_token));
|
|
2408
|
+
} catch (error) {
|
|
2409
|
+
return failure3(error);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// src/core/resources/book_schema.ts
|
|
2416
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2417
|
+
var BOOK_SCHEMA_URI_TEMPLATE = "slima://books/{book_token}/schema";
|
|
2418
|
+
function registerBookSchemaResource(server, client) {
|
|
2419
|
+
server.resource(
|
|
2420
|
+
"slima-book-schema",
|
|
2421
|
+
new ResourceTemplate(BOOK_SCHEMA_URI_TEMPLATE, {
|
|
2422
|
+
// Intentionally no `list` callback — books may number in the hundreds, so
|
|
2423
|
+
// we expose only the URI template via `resources/templates/list` and let
|
|
2424
|
+
// clients call `resources/read` with a concrete token.
|
|
2425
|
+
list: void 0
|
|
2426
|
+
}),
|
|
2427
|
+
{
|
|
2428
|
+
description: "Per-book MCP operation schema: which paths are readable/writable, which are structured (read-only), and the structured file catalog. Read this before planning writes on an unfamiliar book.",
|
|
2429
|
+
mimeType: "application/json"
|
|
2430
|
+
},
|
|
2431
|
+
async (uri, variables) => {
|
|
2432
|
+
const rawToken = variables["book_token"];
|
|
2433
|
+
const bookToken = Array.isArray(rawToken) ? rawToken[0] : rawToken;
|
|
2434
|
+
if (!bookToken) {
|
|
2435
|
+
throw new Error(
|
|
2436
|
+
`Invalid Slima book schema URI: missing book_token in ${uri.toString()}`
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
const book = await client.getBook(bookToken);
|
|
2440
|
+
const schema = buildBookSchema(book);
|
|
2441
|
+
return {
|
|
2442
|
+
contents: [
|
|
2443
|
+
{
|
|
2444
|
+
uri: uri.toString(),
|
|
2445
|
+
mimeType: "application/json",
|
|
2446
|
+
text: JSON.stringify(schema, null, 2)
|
|
2447
|
+
}
|
|
2448
|
+
]
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
function buildBookSchema(book) {
|
|
2454
|
+
const isScript = book.bookType === "script";
|
|
2455
|
+
const isKnowledge = book.bookType === "knowledge";
|
|
2456
|
+
return {
|
|
2457
|
+
book_token: book.token,
|
|
2458
|
+
book_title: book.title,
|
|
2459
|
+
book_type: book.bookType ?? "book",
|
|
2460
|
+
studio_label: isScript ? "Script Studio" : isKnowledge ? "Team Knowledge Base" : "Writing Studio",
|
|
2461
|
+
mcp_capabilities: {
|
|
2462
|
+
// Note: actual reads still go through validate_safe_path!
|
|
2463
|
+
// (blocks absolute paths, traversal, control chars) even though it's
|
|
2464
|
+
// not reflected in this allowlist.
|
|
2465
|
+
readable_paths: "Any path within the book tree (path-traversal and absolute paths still rejected).",
|
|
2466
|
+
writable_paths: isScript ? [".script_studio/planning/**/*"] : ["**/*"],
|
|
2467
|
+
writable_exceptions: isScript ? [".script_studio/planning/.initialized"] : [],
|
|
2468
|
+
readonly_paths: isScript ? [
|
|
2469
|
+
".script_studio/series.json",
|
|
2470
|
+
".script_studio/seasons/**/season.json",
|
|
2471
|
+
".script_studio/seasons/**/episodes/**/episode.json",
|
|
2472
|
+
".script_studio/seasons/**/scenes/*.scene",
|
|
2473
|
+
".script_studio/characters/**/*.character",
|
|
2474
|
+
".script_studio/locations/**/*.location",
|
|
2475
|
+
".script_studio/storylines/**/*.storyline",
|
|
2476
|
+
".script_studio/notes/**/*.note",
|
|
2477
|
+
".script_studio/planning/.initialized"
|
|
2478
|
+
] : [],
|
|
2479
|
+
reserved_sentinels: isScript ? [".script_studio/planning/.initialized"] : [],
|
|
2480
|
+
// Path allowlists are not the whole story on a team book: the server also
|
|
2481
|
+
// checks the caller's ROLE. An editor gets 403 on every write no matter
|
|
2482
|
+
// which path, so "writable_paths" above is an upper bound, not a promise.
|
|
2483
|
+
write_access_note: isKnowledge ? "Team knowledge base: owner / admin / seated writer may write; an editor is read-only (every write returns 403). Files under .slima/skills/ may be enabled as team skills by a manager." : "Personal books: the owner and writer collaborators may write; editor collaborators are read-only."
|
|
2484
|
+
},
|
|
2485
|
+
// AI file-type policy — mirrors slima_vue `fileTypeRegistry.ts` (`aiPolicy`).
|
|
2486
|
+
// Applies to BOTH studio types; the Script Studio path allowlist above is a
|
|
2487
|
+
// separate, additional restriction.
|
|
2488
|
+
ai_file_policy: {
|
|
2489
|
+
writable: [...AI_WRITABLE_EXTENSIONS],
|
|
2490
|
+
// Structured types are not one bucket. Collapsing them into `readonly`
|
|
2491
|
+
// is what made this resource tell the model `.map` could not be written
|
|
2492
|
+
// for weeks after it could, and never mention `.beats` at all.
|
|
2493
|
+
field_level_tools: AI_STRUCTURED_TOOLS,
|
|
2494
|
+
readonly: [...AI_READONLY_EXTENSIONS],
|
|
2495
|
+
readonly_note: "Read-only means the AI must not write these via write_file / edit_file / create_file / append_to_file / delete_file. Slima owns their structure; overwriting one corrupts it for the author. Recommend changes in words and point the author at that file type's editor.",
|
|
2496
|
+
field_level_note: "These take one change at a time through the tool named above. Every generic write is refused (write_file / edit_file / append_to_file), because a whole-document write drops the parts of the file you were never shown \u2014 a beat board loses its chapter cards, a relationship map loses a character, and nothing reports it. create_file works: it writes an empty document of the right shape and ignores the content you send, so you then fill it in with the tool.",
|
|
2497
|
+
// ⚠️ This block ships with the client and can lag a Rails deploy.
|
|
2498
|
+
// `get_capabilities` is derived from the contract Rails enforces; when the
|
|
2499
|
+
// two disagree, that one is right.
|
|
2500
|
+
authority: "get_capabilities",
|
|
2501
|
+
viewers: AI_STRUCTURED_VIEWERS
|
|
2502
|
+
},
|
|
2503
|
+
structured_file_types: isScript ? {
|
|
2504
|
+
"series.json": "Series metadata (title, genre, logline, theme, synopsis, acts)",
|
|
2505
|
+
"season.json": "Season metadata",
|
|
2506
|
+
"episode.json": "Episode metadata",
|
|
2507
|
+
"*.scene": "Scene content with scene-specific format",
|
|
2508
|
+
"*.character": "Character definition with scope (series / season / episode)",
|
|
2509
|
+
"*.location": "Location definition with scope",
|
|
2510
|
+
"*.storyline": "Storyline with tier and ID linkage",
|
|
2511
|
+
"*.note": "Note with scope"
|
|
2512
|
+
} : {},
|
|
2513
|
+
notes: isScript ? "Structured files require schema-aware edits \u2014 use the Script Studio UI. MCP write access is restricted to planning/ (free-form reference tree) to prevent schema corruption." : isKnowledge ? "Team knowledge base. Free-form paths, but two team rules apply: writes follow team role (see write_access_note), and a file marked confidential is returned WITHOUT its content \u2014 read_file reports it as withheld rather than empty. That is intentional: the AI may use such a skill without reading it. Never try to rewrite a withheld file." : "Writing Studio books are free-form. No schema restrictions; any path is writable.",
|
|
2514
|
+
policy_source: "Rails: Mcp::FileOperationService#validate_studio_write_path!"
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// src/core/server-instructions.ts
|
|
2519
|
+
var SERVER_INSTRUCTIONS = `Slima is an AI writing IDE for long-form fiction. You are connected to an author's account.
|
|
2520
|
+
|
|
2521
|
+
**Start by calling \`get_capabilities\`.** It returns what this server supports \u2014 which file types you may write, how content types work, the tools available, and the guide index \u2014 derived from the contract the server enforces, so it is never out of date.
|
|
2522
|
+
|
|
2523
|
+
Then \`load_guide\` what the task needs: \`getting-started\` for the shape of a book, \`book-structure\` before creating files, \`version-control-basics\` before a large rewrite, \`team-knowledge-base\` inside a publisher's team, \`beta-reader\` before spending the author's credits.
|
|
2524
|
+
|
|
2525
|
+
Three things worth knowing before your first call:
|
|
2526
|
+
|
|
2527
|
+
- **Every write is a commit.** There is no save step, and the author sees your commits beside their own. Write real commit messages.
|
|
2528
|
+
- **The author may be editing the same file.** Prefer \`edit_file\` over \`update_file\`: an edit that cannot find its anchor fails loudly, where a whole-file write silently replaces whatever they just typed.
|
|
2529
|
+
- **Not every file type is yours to write.** A refusal naming the file type is information, not an obstacle to route around.
|
|
2530
|
+
|
|
2531
|
+
Slima's AI is a writing coach by default. When a request is vague or the author is thinking out loud, ask the question that unblocks them. When they ask plainly \u2014 "write this scene", "rewrite this paragraph" \u2014 do it, without lecturing.`;
|
|
1488
2532
|
|
|
1489
2533
|
// src/core/utils/logger.ts
|
|
1490
2534
|
var isDebug = process.env.DEBUG === "true" || process.env.DEBUG === "1";
|
|
@@ -1521,14 +2565,24 @@ function createSlimaServer(config) {
|
|
|
1521
2565
|
getToken: async () => config.apiToken,
|
|
1522
2566
|
logger
|
|
1523
2567
|
});
|
|
1524
|
-
const server = new McpServer(
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
2568
|
+
const server = new McpServer(
|
|
2569
|
+
{
|
|
2570
|
+
name: "slima",
|
|
2571
|
+
version: "0.3.0"
|
|
2572
|
+
},
|
|
2573
|
+
{
|
|
2574
|
+
instructions: SERVER_INSTRUCTIONS
|
|
2575
|
+
}
|
|
2576
|
+
);
|
|
2577
|
+
registerTeachingTools(server, client);
|
|
1528
2578
|
registerBookTools(server, client);
|
|
2579
|
+
registerTeamTools(server, client);
|
|
2580
|
+
registerWorkspaceTools(server, client);
|
|
1529
2581
|
registerContentTools(server, client);
|
|
1530
2582
|
registerBetaReaderTools(server, client, logger);
|
|
1531
2583
|
registerFileTools(server, client);
|
|
2584
|
+
registerStructuredTools(server, client);
|
|
2585
|
+
registerBookSchemaResource(server, client);
|
|
1532
2586
|
logger.info("Slima MCP Server initialized");
|
|
1533
2587
|
logger.info(`API endpoint: ${config.baseUrl || "https://api.slima.ai"}`);
|
|
1534
2588
|
return server;
|
|
@@ -1740,7 +2794,7 @@ function getSuccessHtml() {
|
|
|
1740
2794
|
</body>
|
|
1741
2795
|
</html>`;
|
|
1742
2796
|
}
|
|
1743
|
-
function escapeHtml(
|
|
2797
|
+
function escapeHtml(text2) {
|
|
1744
2798
|
const map = {
|
|
1745
2799
|
"&": "&",
|
|
1746
2800
|
"<": "<",
|
|
@@ -1748,7 +2802,7 @@ function escapeHtml(text) {
|
|
|
1748
2802
|
'"': """,
|
|
1749
2803
|
"'": "'"
|
|
1750
2804
|
};
|
|
1751
|
-
return
|
|
2805
|
+
return text2.replace(/[&<>"']/g, (char) => map[char]);
|
|
1752
2806
|
}
|
|
1753
2807
|
function getErrorHtml(message) {
|
|
1754
2808
|
const safeMessage = escapeHtml(message);
|
|
@@ -1975,7 +3029,7 @@ async function runStatus() {
|
|
|
1975
3029
|
}
|
|
1976
3030
|
|
|
1977
3031
|
// src/cli/index.ts
|
|
1978
|
-
var VERSION = "0.
|
|
3032
|
+
var VERSION = "0.3.0";
|
|
1979
3033
|
function showHelp() {
|
|
1980
3034
|
console.log(`
|
|
1981
3035
|
Slima MCP v${VERSION}
|
|
@@ -2052,7 +3106,7 @@ async function startServer() {
|
|
|
2052
3106
|
baseUrl
|
|
2053
3107
|
});
|
|
2054
3108
|
const transport = new StdioServerTransport();
|
|
2055
|
-
await server.connect(transport);
|
|
3109
|
+
await server.connect(withGeminiSchemaCompat(transport));
|
|
2056
3110
|
logger.info("Slima MCP Server running on stdio");
|
|
2057
3111
|
process.on("SIGINT", async () => {
|
|
2058
3112
|
logger.info("Shutting down...");
|