1use std::path::PathBuf;
2use std::sync::Arc;
3
4use rmcp::{
5 RoleServer, ServerHandler,
6 handler::server::{
7 router::{prompt::PromptRouter, tool::ToolRouter},
8 wrapper::Parameters,
9 },
10 model::{
11 GetPromptRequestParams, GetPromptResult, ListPromptsResult, PaginatedRequestParams,
12 ProtocolVersion, ServerCapabilities, ServerInfo,
13 },
14 prompt_handler,
15 service::RequestContext,
16 tool, tool_handler, tool_router,
17};
18use tokio::sync::Mutex;
19use tracing::error;
20
21use crate::io::FileStore;
22use crate::tools::{
23 FileCache,
24 coverage::{GetCoverageParams, ValidateFileParams, handle_get_coverage, handle_validate_file},
25 create::{
26 AddKeysParams, CreateXcStringsParams, UpdateCommentsParams, handle_add_keys,
27 handle_create_xcstrings, handle_update_comments,
28 },
29 diff::{GetDiffParams, handle_get_diff},
30 extract::{
31 GetKeyParams, GetStaleParams, GetUntranslatedParams, SearchKeysParams, handle_get_key,
32 handle_get_stale, handle_get_untranslated, handle_search_keys,
33 },
34 files::{DiscoverFilesParams, ListFilesParams, handle_discover_files, handle_list_files},
35 glossary::{
36 GetGlossaryParams, UpdateGlossaryParams, handle_get_glossary, handle_update_glossary,
37 },
38 keys::{
39 DeleteKeysParams, DeleteTranslationsParams, RenameKeyParams, handle_delete_keys,
40 handle_delete_translations, handle_rename_key,
41 },
42 manage::{
43 AddLocaleParams, ListLocalesParams, RemoveLocaleParams, handle_add_locale,
44 handle_list_locales, handle_remove_locale,
45 },
46 parse::{ParseParams, handle_parse},
47 plural::{GetContextParams, GetPluralsParams, handle_get_context, handle_get_plurals},
48 strings::{ImportStringsParams, handle_import_strings},
49 translate::{SubmitTranslationsParams, handle_submit_translations},
50 xliff::{ExportXliffParams, ImportXliffParams, handle_export_xliff, handle_import_xliff},
51};
52
53#[derive(Clone)]
54pub struct XcStringsMcpServer {
55 store: Arc<dyn FileStore>,
56 cache: Arc<Mutex<FileCache>>,
57 write_lock: Arc<Mutex<()>>,
58 glossary_path: PathBuf,
59 glossary_write_lock: Arc<Mutex<()>>,
60 tool_router: ToolRouter<Self>,
61 prompt_router: PromptRouter<Self>,
62}
63
64impl XcStringsMcpServer {
65 pub fn new(store: Arc<dyn FileStore>, glossary_path: PathBuf) -> Self {
66 Self {
67 store,
68 cache: Arc::new(Mutex::new(FileCache::new())),
69 write_lock: Arc::new(Mutex::new(())),
70 glossary_path,
71 glossary_write_lock: Arc::new(Mutex::new(())),
72 tool_router: Self::tool_router(),
73 prompt_router: crate::prompts::build_prompt_router(),
74 }
75 }
76}
77
78#[tool_router]
79impl XcStringsMcpServer {
80 #[tool(
83 name = "parse_xcstrings",
84 description = "Parse an .xcstrings file and cache it. Returns summary: locales, key counts, extraction states. The parsed file becomes the active file — other tools use it automatically when file_path is omitted."
85 )]
86 async fn parse_xcstrings(
87 &self,
88 Parameters(params): Parameters<ParseParams>,
89 context: RequestContext<RoleServer>,
90 ) -> Result<String, String> {
91 match handle_parse(
92 self.store.as_ref(),
93 &self.cache,
94 params,
95 Some(&context.peer),
96 )
97 .await
98 {
99 Ok(value) => serde_json::to_string_pretty(&value)
100 .map_err(|e| format!("serialization error: {e}")),
101 Err(e) => {
102 error!(error = %e, "parse_xcstrings failed");
103 Err(e.to_string())
104 }
105 }
106 }
107
108 #[tool(
110 name = "get_untranslated",
111 description = "Get untranslated strings for one or more target locales. Returns batched results — repeat with offset += batch_size while has_more is true. For plural keys (has_plurals=true), follow up with get_plurals."
112 )]
113 async fn get_untranslated(
114 &self,
115 Parameters(params): Parameters<GetUntranslatedParams>,
116 ) -> Result<String, String> {
117 match handle_get_untranslated(self.store.as_ref(), &self.cache, params).await {
118 Ok(value) => serde_json::to_string_pretty(&value)
119 .map_err(|e| format!("serialization error: {e}")),
120 Err(e) => {
121 error!(error = %e, "get_untranslated failed");
122 Err(e.to_string())
123 }
124 }
125 }
126
127 #[tool(
130 name = "submit_translations",
131 description = "Submit translations for validation and atomic writing. Validates that format specifiers match source text. Use dry_run=true first to preview. Check rejected[] in response for failures with reasons. Set continue_on_error=false to reject entire batch on any failure."
132 )]
133 async fn submit_translations(
134 &self,
135 Parameters(params): Parameters<SubmitTranslationsParams>,
136 context: RequestContext<RoleServer>,
137 ) -> Result<String, String> {
138 match handle_submit_translations(
139 self.store.as_ref(),
140 &self.cache,
141 &self.write_lock,
142 params,
143 Some(&context.peer),
144 )
145 .await
146 {
147 Ok(value) => serde_json::to_string_pretty(&value)
148 .map_err(|e| format!("serialization error: {e}")),
149 Err(e) => {
150 error!(error = %e, "submit_translations failed");
151 Err(e.to_string())
152 }
153 }
154 }
155
156 #[tool(
158 name = "get_coverage",
159 description = "Get translation coverage statistics per locale. Shows translated/total counts and percentages for each locale."
160 )]
161 async fn get_coverage(
162 &self,
163 Parameters(params): Parameters<GetCoverageParams>,
164 ) -> Result<String, String> {
165 match handle_get_coverage(self.store.as_ref(), &self.cache, params).await {
166 Ok(value) => serde_json::to_string_pretty(&value)
167 .map_err(|e| format!("serialization error: {e}")),
168 Err(e) => {
169 error!(error = %e, "get_coverage failed");
170 Err(e.to_string())
171 }
172 }
173 }
174
175 #[tool(
177 name = "get_stale",
178 description = "Get strings marked as stale (removed from source code but still in the file). Returns batched results with pagination. Use delete_keys to remove confirmed stale keys."
179 )]
180 async fn get_stale(
181 &self,
182 Parameters(params): Parameters<GetStaleParams>,
183 ) -> Result<String, String> {
184 match handle_get_stale(self.store.as_ref(), &self.cache, params).await {
185 Ok(value) => serde_json::to_string_pretty(&value)
186 .map_err(|e| format!("serialization error: {e}")),
187 Err(e) => {
188 error!(error = %e, "get_stale failed");
189 Err(e.to_string())
190 }
191 }
192 }
193
194 #[tool(
196 name = "search_keys",
197 description = "Search keys by substring pattern (case-insensitive). Matches both key names and source text. Returns translation units with pagination. Empty pattern returns all translatable keys."
198 )]
199 async fn search_keys(
200 &self,
201 Parameters(params): Parameters<SearchKeysParams>,
202 ) -> Result<String, String> {
203 match handle_search_keys(self.store.as_ref(), &self.cache, params).await {
204 Ok(value) => serde_json::to_string_pretty(&value)
205 .map_err(|e| format!("serialization error: {e}")),
206 Err(e) => {
207 error!(error = %e, "search_keys failed");
208 Err(e.to_string())
209 }
210 }
211 }
212
213 #[tool(
215 name = "validate_translations",
216 description = "Validate translations for errors: format specifier mismatches (CRITICAL — runtime crash), missing plural forms (HIGH — wrong text shown), empty values (MEDIUM). Optionally filter by locale. Returns errors and warnings grouped by severity."
217 )]
218 async fn validate_translations_file(
219 &self,
220 Parameters(params): Parameters<ValidateFileParams>,
221 ) -> Result<String, String> {
222 match handle_validate_file(self.store.as_ref(), &self.cache, params).await {
223 Ok(value) => serde_json::to_string_pretty(&value)
224 .map_err(|e| format!("serialization error: {e}")),
225 Err(e) => {
226 error!(error = %e, "validate_translations failed");
227 Err(e.to_string())
228 }
229 }
230 }
231
232 #[tool(
234 name = "list_locales",
235 description = "List all locales in the file with translation counts and percentages."
236 )]
237 async fn list_locales(
238 &self,
239 Parameters(params): Parameters<ListLocalesParams>,
240 ) -> Result<String, String> {
241 match handle_list_locales(self.store.as_ref(), &self.cache, params).await {
242 Ok(value) => serde_json::to_string_pretty(&value)
243 .map_err(|e| format!("serialization error: {e}")),
244 Err(e) => {
245 error!(error = %e, "list_locales failed");
246 Err(e.to_string())
247 }
248 }
249 }
250
251 #[tool(
253 name = "add_locale",
254 description = "Add a new locale to the file. Initializes all translatable keys with empty translations (state=new). Writes the file atomically."
255 )]
256 async fn add_locale(
257 &self,
258 Parameters(params): Parameters<AddLocaleParams>,
259 context: RequestContext<RoleServer>,
260 ) -> Result<String, String> {
261 match handle_add_locale(
262 self.store.as_ref(),
263 &self.cache,
264 &self.write_lock,
265 params,
266 Some(&context.peer),
267 )
268 .await
269 {
270 Ok(value) => serde_json::to_string_pretty(&value)
271 .map_err(|e| format!("serialization error: {e}")),
272 Err(e) => {
273 error!(error = %e, "add_locale failed");
274 Err(e.to_string())
275 }
276 }
277 }
278
279 #[tool(
281 name = "remove_locale",
282 description = "Remove a locale from the file. Deletes all translations for that locale from every entry. Cannot remove the source locale. Writes the file atomically."
283 )]
284 async fn remove_locale(
285 &self,
286 Parameters(params): Parameters<RemoveLocaleParams>,
287 context: RequestContext<RoleServer>,
288 ) -> Result<String, String> {
289 match handle_remove_locale(
290 self.store.as_ref(),
291 &self.cache,
292 &self.write_lock,
293 params,
294 Some(&context.peer),
295 )
296 .await
297 {
298 Ok(value) => serde_json::to_string_pretty(&value)
299 .map_err(|e| format!("serialization error: {e}")),
300 Err(e) => {
301 error!(error = %e, "remove_locale failed");
302 Err(e.to_string())
303 }
304 }
305 }
306
307 #[tool(
309 name = "get_plurals",
310 description = "Get keys needing plural or device-variant translation. Returns required CLDR forms per locale (e.g., one/few/many/other for Ukrainian), existing partial translations, and substitution info. Submit via submit_translations with plural_forms field."
311 )]
312 async fn get_plurals(
313 &self,
314 Parameters(params): Parameters<GetPluralsParams>,
315 ) -> Result<String, String> {
316 match handle_get_plurals(self.store.as_ref(), &self.cache, params).await {
317 Ok(value) => serde_json::to_string_pretty(&value)
318 .map_err(|e| format!("serialization error: {e}")),
319 Err(e) => {
320 error!(error = %e, "get_plurals failed");
321 Err(e.to_string())
322 }
323 }
324 }
325
326 #[tool(
328 name = "get_context",
329 description = "Get nearby keys sharing a common prefix with the given key. Helps translators understand context by seeing related strings and their translations."
330 )]
331 async fn get_context(
332 &self,
333 Parameters(params): Parameters<GetContextParams>,
334 ) -> Result<String, String> {
335 match handle_get_context(self.store.as_ref(), &self.cache, params).await {
336 Ok(value) => serde_json::to_string_pretty(&value)
337 .map_err(|e| format!("serialization error: {e}")),
338 Err(e) => {
339 error!(error = %e, "get_context failed");
340 Err(e.to_string())
341 }
342 }
343 }
344
345 #[tool(
347 name = "list_files",
348 description = "List all previously parsed .xcstrings files in memory. Shows source language, key count, and which file is active (used when file_path is omitted from other tool calls)."
349 )]
350 async fn list_files(
351 &self,
352 Parameters(_params): Parameters<ListFilesParams>,
353 ) -> Result<String, String> {
354 match handle_list_files(&self.cache).await {
355 Ok(value) => serde_json::to_string_pretty(&value)
356 .map_err(|e| format!("serialization error: {e}")),
357 Err(e) => {
358 error!(error = %e, "list_files failed");
359 Err(e.to_string())
360 }
361 }
362 }
363
364 #[tool(
366 name = "get_diff",
367 description = "Compare cached file with current on-disk version. Shows added keys, removed keys, and keys whose source language text changed. Does not track translation changes in non-source locales."
368 )]
369 async fn get_diff(
370 &self,
371 Parameters(params): Parameters<GetDiffParams>,
372 ) -> Result<String, String> {
373 match handle_get_diff(self.store.as_ref(), &self.cache, params).await {
374 Ok(value) => serde_json::to_string_pretty(&value)
375 .map_err(|e| format!("serialization error: {e}")),
376 Err(e) => {
377 error!(error = %e, "get_diff failed");
378 Err(e.to_string())
379 }
380 }
381 }
382
383 #[tool(
385 name = "get_glossary",
386 description = "Get glossary entries for a source/target locale pair. The glossary persists across sessions and stores preferred translations for terms. Supports optional substring filter."
387 )]
388 async fn get_glossary(
389 &self,
390 Parameters(params): Parameters<GetGlossaryParams>,
391 ) -> Result<String, String> {
392 match handle_get_glossary(self.store.as_ref(), &self.glossary_path, params).await {
393 Ok(value) => serde_json::to_string_pretty(&value)
394 .map_err(|e| format!("serialization error: {e}")),
395 Err(e) => {
396 error!(error = %e, "get_glossary failed");
397 Err(e.to_string())
398 }
399 }
400 }
401
402 #[tool(
404 name = "update_glossary",
405 description = "Add or update glossary entries for a source/target locale pair. The glossary persists across sessions. Upserts entries — existing terms are overwritten, new terms are added."
406 )]
407 async fn update_glossary(
408 &self,
409 Parameters(params): Parameters<UpdateGlossaryParams>,
410 ) -> Result<String, String> {
411 match handle_update_glossary(
412 self.store.as_ref(),
413 &self.glossary_path,
414 &self.glossary_write_lock,
415 params,
416 )
417 .await
418 {
419 Ok(value) => serde_json::to_string_pretty(&value)
420 .map_err(|e| format!("serialization error: {e}")),
421 Err(e) => {
422 error!(error = %e, "update_glossary failed");
423 Err(e.to_string())
424 }
425 }
426 }
427
428 #[tool(
430 name = "export_xliff",
431 description = "Export translations to XLIFF 1.2 format for external tools. Only exports simple strings; plural forms not included. By default exports untranslated strings only."
432 )]
433 async fn export_xliff(
434 &self,
435 Parameters(params): Parameters<ExportXliffParams>,
436 context: RequestContext<RoleServer>,
437 ) -> Result<String, String> {
438 match handle_export_xliff(
439 self.store.as_ref(),
440 &self.cache,
441 params,
442 Some(&context.peer),
443 )
444 .await
445 {
446 Ok(value) => serde_json::to_string_pretty(&value)
447 .map_err(|e| format!("serialization error: {e}")),
448 Err(e) => {
449 error!(error = %e, "export_xliff failed");
450 Err(e.to_string())
451 }
452 }
453 }
454
455 #[tool(
457 name = "import_xliff",
458 description = "Import translations from XLIFF 1.2 file. Only simple strings imported; use submit_translations for plurals. Validates specifiers, merges accepted. Use dry_run=true to preview."
459 )]
460 async fn import_xliff(
461 &self,
462 Parameters(params): Parameters<ImportXliffParams>,
463 context: RequestContext<RoleServer>,
464 ) -> Result<String, String> {
465 match handle_import_xliff(
466 self.store.as_ref(),
467 &self.cache,
468 &self.write_lock,
469 params,
470 Some(&context.peer),
471 )
472 .await
473 {
474 Ok(value) => serde_json::to_string_pretty(&value)
475 .map_err(|e| format!("serialization error: {e}")),
476 Err(e) => {
477 error!(error = %e, "import_xliff failed");
478 Err(e.to_string())
479 }
480 }
481 }
482
483 #[tool(
485 name = "import_strings",
486 description = "Import legacy .strings and .stringsdict files into .xcstrings format. Provide file paths directly or a directory to scan for .lproj folders. Handles UTF-8 and UTF-16 encodings, plural rules, and comments. Creates new .xcstrings or merges into existing. Use dry_run=true to preview."
487 )]
488 async fn import_strings(
489 &self,
490 Parameters(params): Parameters<ImportStringsParams>,
491 context: RequestContext<RoleServer>,
492 ) -> Result<String, String> {
493 match handle_import_strings(
494 self.store.as_ref(),
495 &self.cache,
496 &self.write_lock,
497 params,
498 Some(&context.peer),
499 )
500 .await
501 {
502 Ok(value) => serde_json::to_string_pretty(&value)
503 .map_err(|e| format!("serialization error: {e}")),
504 Err(e) => {
505 error!(error = %e, "import_strings failed");
506 Err(e.to_string())
507 }
508 }
509 }
510
511 #[tool(
513 name = "create_xcstrings",
514 description = "Create a new empty .xcstrings file with the given source language. Fails if the file already exists."
515 )]
516 async fn create_xcstrings(
517 &self,
518 Parameters(params): Parameters<CreateXcStringsParams>,
519 context: RequestContext<RoleServer>,
520 ) -> Result<String, String> {
521 match handle_create_xcstrings(
522 self.store.as_ref(),
523 &self.cache,
524 params,
525 Some(&context.peer),
526 )
527 .await
528 {
529 Ok(value) => serde_json::to_string_pretty(&value)
530 .map_err(|e| format!("serialization error: {e}")),
531 Err(e) => {
532 error!(error = %e, "create_xcstrings failed");
533 Err(e.to_string())
534 }
535 }
536 }
537
538 #[tool(
540 name = "add_keys",
541 description = "Add new localization keys with source text to the .xcstrings file. Skips keys that already exist. Writes atomically."
542 )]
543 async fn add_keys(
544 &self,
545 Parameters(params): Parameters<AddKeysParams>,
546 context: RequestContext<RoleServer>,
547 ) -> Result<String, String> {
548 match handle_add_keys(
549 self.store.as_ref(),
550 &self.cache,
551 &self.write_lock,
552 params,
553 Some(&context.peer),
554 )
555 .await
556 {
557 Ok(value) => serde_json::to_string_pretty(&value)
558 .map_err(|e| format!("serialization error: {e}")),
559 Err(e) => {
560 error!(error = %e, "add_keys failed");
561 Err(e.to_string())
562 }
563 }
564 }
565
566 #[tool(
568 name = "discover_files",
569 description = "Recursively search a directory for localization files. Returns .xcstrings files and legacy .strings/.stringsdict files found in .lproj directories. Use legacy_files to identify projects that can be migrated with import_strings."
570 )]
571 async fn discover_files(
572 &self,
573 Parameters(params): Parameters<DiscoverFilesParams>,
574 ) -> Result<String, String> {
575 match handle_discover_files(params).await {
576 Ok(value) => serde_json::to_string_pretty(&value)
577 .map_err(|e| format!("serialization error: {e}")),
578 Err(e) => {
579 error!(error = %e, "discover_files failed");
580 Err(e.to_string())
581 }
582 }
583 }
584
585 #[tool(
587 name = "update_comments",
588 description = "Update developer comments on existing localization keys. Silently skips non-existent keys. Writes atomically."
589 )]
590 async fn update_comments(
591 &self,
592 Parameters(params): Parameters<UpdateCommentsParams>,
593 context: RequestContext<RoleServer>,
594 ) -> Result<String, String> {
595 match handle_update_comments(
596 self.store.as_ref(),
597 &self.cache,
598 &self.write_lock,
599 params,
600 Some(&context.peer),
601 )
602 .await
603 {
604 Ok(value) => serde_json::to_string_pretty(&value)
605 .map_err(|e| format!("serialization error: {e}")),
606 Err(e) => {
607 error!(error = %e, "update_comments failed");
608 Err(e.to_string())
609 }
610 }
611 }
612
613 #[tool(
615 name = "delete_keys",
616 description = "Delete localization keys and all their translations. Use after get_stale to remove unused keys."
617 )]
618 async fn delete_keys(
619 &self,
620 Parameters(params): Parameters<DeleteKeysParams>,
621 context: RequestContext<RoleServer>,
622 ) -> Result<String, String> {
623 match handle_delete_keys(
624 self.store.as_ref(),
625 &self.cache,
626 &self.write_lock,
627 params,
628 Some(&context.peer),
629 )
630 .await
631 {
632 Ok(value) => serde_json::to_string_pretty(&value)
633 .map_err(|e| format!("serialization error: {e}")),
634 Err(e) => {
635 error!(error = %e, "delete_keys failed");
636 Err(e.to_string())
637 }
638 }
639 }
640
641 #[tool(
643 name = "rename_key",
644 description = "Rename a localization key, preserving all existing translations across all locales."
645 )]
646 async fn rename_key(
647 &self,
648 Parameters(params): Parameters<RenameKeyParams>,
649 context: RequestContext<RoleServer>,
650 ) -> Result<String, String> {
651 match handle_rename_key(
652 self.store.as_ref(),
653 &self.cache,
654 &self.write_lock,
655 params,
656 Some(&context.peer),
657 )
658 .await
659 {
660 Ok(value) => serde_json::to_string_pretty(&value)
661 .map_err(|e| format!("serialization error: {e}")),
662 Err(e) => {
663 error!(error = %e, "rename_key failed");
664 Err(e.to_string())
665 }
666 }
667 }
668
669 #[tool(
671 name = "get_key",
672 description = "Get all translations for a specific key across every locale. Returns source text, developer comment, translation state, and plural/device variant info. Use to inspect a single key in detail."
673 )]
674 async fn get_key(
675 &self,
676 Parameters(params): Parameters<GetKeyParams>,
677 ) -> Result<String, String> {
678 match handle_get_key(self.store.as_ref(), &self.cache, params).await {
679 Ok(value) => serde_json::to_string_pretty(&value)
680 .map_err(|e| format!("serialization error: {e}")),
681 Err(e) => {
682 error!(error = %e, "get_key failed");
683 Err(e.to_string())
684 }
685 }
686 }
687
688 #[tool(
690 name = "delete_translations",
691 description = "Remove translations for specific keys in a locale, resetting them to untranslated state. Cannot delete source language translations."
692 )]
693 async fn delete_translations(
694 &self,
695 Parameters(params): Parameters<DeleteTranslationsParams>,
696 context: RequestContext<RoleServer>,
697 ) -> Result<String, String> {
698 match handle_delete_translations(
699 self.store.as_ref(),
700 &self.cache,
701 &self.write_lock,
702 params,
703 Some(&context.peer),
704 )
705 .await
706 {
707 Ok(value) => serde_json::to_string_pretty(&value)
708 .map_err(|e| format!("serialization error: {e}")),
709 Err(e) => {
710 error!(error = %e, "delete_translations failed");
711 Err(e.to_string())
712 }
713 }
714 }
715}
716
717#[tool_handler]
718#[prompt_handler]
719impl ServerHandler for XcStringsMcpServer {
720 fn get_info(&self) -> ServerInfo {
721 ServerInfo::new(
722 ServerCapabilities::builder()
723 .enable_tools()
724 .enable_prompts()
725 .build(),
726 )
727 .with_protocol_version(ProtocolVersion::V_2025_06_18)
728 .with_instructions(
729 "MCP server for iOS/macOS .xcstrings String Catalog localization.\n\
730 \n\
731 SETUP: discover_files to find files, parse_xcstrings to load \
732 (required before other tools unless file_path is passed). \
733 create_xcstrings for new files.\n\
734 \n\
735 TRANSLATE: get_untranslated → translate → submit_translations \
736 (use dry_run=true first). For plurals: get_plurals → submit with plural_forms. \
737 Use get_context for nearby keys, get_glossary for term consistency.\n\
738 \n\
739 REVIEW: get_coverage for statistics, validate_translations for errors \
740 (specifier mismatches, missing plurals), get_stale for removed keys, \
741 get_diff for changes since last parse.\n\
742 \n\
743 MANAGE: list_locales, add_locale/remove_locale, \
744 add_keys/delete_keys/rename_key/get_key, search_keys, \
745 update_comments, delete_translations, list_files.\n\
746 \n\
747 MIGRATE: import_strings for legacy .strings/.stringsdict → .xcstrings. \
748 export_xliff/import_xliff for external translator tools (simple strings only).\n\
749 \n\
750 GLOSSARY: get_glossary/update_glossary — persists across sessions for \
751 term consistency.\n\
752 \n\
753 Pagination: batched tools return has_more/offset/total — repeat with \
754 offset += batch_size while has_more is true. \
755 Source locale translations cannot be submitted or deleted.",
756 )
757 }
758}