Skip to main content

xcstrings_mcp/
server.rs

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    /// Parse an .xcstrings file and return a summary of its contents
81    /// including locales, key counts, and translation states.
82    #[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    /// Get untranslated strings for a target locale with batching support.
109    #[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    /// Submit translations: validates format specifiers and plural forms,
128    /// merges into the file, and writes back atomically.
129    #[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    /// Get translation coverage statistics per locale.
157    #[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    /// Get stale strings (extractionState=stale) for a target locale.
176    #[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    /// Search keys by substring pattern (case-insensitive).
195    #[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    /// Validate translations in the file for correctness.
214    #[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    /// List all locales with translation statistics.
233    #[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    /// Add a new locale to the file.
252    #[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    /// Remove a locale from the file.
280    #[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    /// Get keys requiring plural/device translation for a locale.
308    #[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    /// Get nearby context keys for a translation key.
327    #[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    /// List all cached .xcstrings files.
346    #[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    /// Compare cached file with current on-disk version.
365    #[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    /// Get glossary entries for a language pair.
384    #[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    /// Update glossary entries for a language pair.
403    #[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    /// Export translations to XLIFF 1.2 format for external tools.
429    #[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    /// Import translations from XLIFF 1.2 file.
456    #[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    /// Import legacy .strings and .stringsdict files into .xcstrings format.
484    #[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    /// Create a new empty .xcstrings file.
512    #[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    /// Add new localization keys with source text.
539    #[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    /// Discover localization files in a directory tree.
567    #[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    /// Update developer comments on localization keys.
586    #[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    /// Delete localization keys and all their translations.
614    #[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    /// Rename a localization key, preserving all translations.
642    #[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    /// Get all translations for a specific key across all locales.
670    #[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    /// Remove translations for specific keys in a locale.
689    #[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}