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/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 response = await fetch(url, {
94
- method,
95
- headers: {
96
- Authorization: `Bearer ${token}`,
97
- "Content-Type": "application/json",
98
- "User-Agent": "slima-mcp-server/0.1.0"
99
- },
100
- body: body ? JSON.stringify(body) : void 0
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 json = await response.json();
106
- return json.data;
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 all books
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
- return this.request("GET", "/api/v1/books?status=active");
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
- "Create a new book in your Slima library",
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 all books in your Slima library",
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: `Found ${books.length} book(s):
738
+ text: `${header}
739
+
740
+ ${formatted}
479
741
 
480
- ${formatted}`
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 text = `# ${book.title}
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 client.listCommits(book_token, 1);
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
- "Get writing statistics for a book",
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 [book, commits] = await Promise.all([
575
- client.getBook(book_token),
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
- - **Recent Progress**: ${progressText}
592
- - **Commits Analyzed**: ${commits.length}
593
- - **Language**: ${book.language || "N/A"}`;
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 the content of a chapter or file from a book",
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 chapter-01)")
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 commits = await client.listCommits(book_token, 1);
621
- if (commits.length === 0) {
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
- const file = findFileInSnapshot(commits[0].filesSnapshot, file_path);
632
- if (!file) {
633
- const availableFiles = listAllFiles(commits[0].filesSnapshot);
634
- const fileList = availableFiles.slice(0, 10).join("\n- ");
635
- const moreText = availableFiles.length > 10 ? `
636
- ... and ${availableFiles.length - 10} more files` : "";
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
- - ${fileList}${moreText}
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: "Could not retrieve file content. The blob may be missing."
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 content = blobs[0].content;
686
- const wordInfo = file.wordCount ? ` (${file.wordCount} words)` : "";
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 findFileInSnapshot(files, path) {
707
- const normalizedPath = path.replace(/^\//, "").toLowerCase();
708
- const pathParts = normalizedPath.split("/");
709
- function search(items, remainingParts) {
710
- for (const item of items) {
711
- const itemNameLower = item.name.toLowerCase();
712
- const itemTokenLower = item.token.toLowerCase();
713
- if (remainingParts.length === 1) {
714
- const target = remainingParts[0];
715
- if (itemNameLower === target || itemTokenLower === target || itemNameLower.replace(/\.[^.]+$/, "") === target.replace(/\.[^.]+$/, "")) {
716
- return item;
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 searchByName(files, name) {
736
- const nameLower = name.toLowerCase();
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 listAllFiles(files, prefix = "") {
751
- const result = [];
752
- for (const item of files) {
753
- const path = prefix ? `${prefix}/${item.name}` : item.name;
754
- if (item.kind === "file") {
755
- result.push(path);
756
- } else if (item.children) {
757
- result.push(...listAllFiles(item.children, path));
758
- }
759
- }
760
- return result;
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
- "Get AI Beta Reader feedback on a chapter from a specific reader persona. This may take a moment to complete.",
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("Book token (e.g., bk_abc123)"),
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 = findFileInSnapshot2(commits[0].filesSnapshot, file_path);
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: `Analysis is still in progress. You can check the status later.
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
- Test ID: ${test.token}`
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
- async function pollForCompletion(checkFn, timeoutMs, logger2) {
937
- const startTime = Date.now();
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
- ## Individual Reader Feedback
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 findFileInSnapshot2(files, path) {
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 = searchByName2(files, pathParts[pathParts.length - 1]);
1483
+ result = searchByName(files, pathParts[pathParts.length - 1]);
1162
1484
  return result;
1163
1485
  }
1164
- function searchByName2(files, name) {
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 = searchByName2(item.children, name);
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: fileInfo }]
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
- "Replace the entire content of an existing file (creates a new commit)",
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('File path (e.g., "characters/protagonist.md")'),
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
- "Edit a file by replacing a specific text segment (search and replace). Use this for precise edits instead of rewriting the entire file.",
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('File path (e.g., "characters/protagonist.md")'),
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 fileData = await client.readFile(book_token, path);
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
- content: newContent,
1304
- commitMessage: commit_message || `Edit ${path}`
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 successfully.
1638
+ text: `\u2705 File edited.
1312
1639
 
1313
- **Replacements:** ${replacementCount}
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
- "Create a new file in a book (creates a new commit)",
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('File path including parent folders (e.g., "characters/new-character.md")'),
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
- "Delete a file from a book (creates a new commit)",
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('File path to delete (e.g., "old-notes.md")'),
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: `File deleted successfully.
1384
-
1385
- Commit: ${result.commit.token}
1386
- Message: ${result.commit.name || result.commit.message}`
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
- "Append content to the end of an existing file (creates a new commit)",
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('File path (e.g., "notes.md")'),
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
- "Search for content across all files in a book",
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 types (e.g., ["markdown", "txt"])'),
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
- if (result.matches.length === 0) {
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 ${result.matches.length} file(s) with matches:`,
1896
+ `Found ${filteredMatches.length} file(s) with matches:`,
1465
1897
  ""
1466
1898
  ];
1467
- for (const match of result.matches) {
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
- name: "slima",
1526
- version: "0.1.15"
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(text) {
2797
+ function escapeHtml(text2) {
1744
2798
  const map = {
1745
2799
  "&": "&amp;",
1746
2800
  "<": "&lt;",
@@ -1748,7 +2802,7 @@ function escapeHtml(text) {
1748
2802
  '"': "&quot;",
1749
2803
  "'": "&#039;"
1750
2804
  };
1751
- return text.replace(/[&<>"']/g, (char) => map[char]);
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.1.15";
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...");