Skip to main content

xcstrings_mcp/
server.rs

1use std::sync::Arc;
2
3use rmcp::{
4    ServerHandler,
5    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
6    model::{ProtocolVersion, ServerCapabilities, ServerInfo},
7    tool, tool_handler, tool_router,
8};
9use tokio::sync::Mutex;
10use tracing::error;
11
12use crate::io::FileStore;
13use crate::tools::{
14    coverage::{GetCoverageParams, ValidateFileParams, handle_get_coverage, handle_validate_file},
15    extract::{GetStaleParams, GetUntranslatedParams, handle_get_stale, handle_get_untranslated},
16    manage::{AddLocaleParams, ListLocalesParams, handle_add_locale, handle_list_locales},
17    parse::{CachedFile, ParseParams, handle_parse},
18    plural::{GetContextParams, GetPluralsParams, handle_get_context, handle_get_plurals},
19    translate::{SubmitTranslationsParams, handle_submit_translations},
20};
21
22#[derive(Clone)]
23pub struct XcStringsMcpServer {
24    store: Arc<dyn FileStore>,
25    cache: Arc<Mutex<Option<CachedFile>>>,
26    write_lock: Arc<Mutex<()>>,
27    tool_router: ToolRouter<Self>,
28}
29
30impl XcStringsMcpServer {
31    pub fn new(store: Arc<dyn FileStore>) -> Self {
32        Self {
33            store,
34            cache: Arc::new(Mutex::new(None)),
35            write_lock: Arc::new(Mutex::new(())),
36            tool_router: Self::tool_router(),
37        }
38    }
39}
40
41#[tool_router]
42impl XcStringsMcpServer {
43    /// Parse an .xcstrings file and return a summary of its contents
44    /// including locales, key counts, and translation states.
45    #[tool(
46        name = "parse_xcstrings",
47        description = "Parse an .xcstrings file and return a summary (locales, key counts, states). Must be called before other tools if no file_path is passed."
48    )]
49    async fn parse_xcstrings(
50        &self,
51        Parameters(params): Parameters<ParseParams>,
52    ) -> Result<String, String> {
53        match handle_parse(self.store.as_ref(), &self.cache, params).await {
54            Ok(value) => serde_json::to_string_pretty(&value)
55                .map_err(|e| format!("serialization error: {e}")),
56            Err(e) => {
57                error!(error = %e, "parse_xcstrings failed");
58                Err(e.to_string())
59            }
60        }
61    }
62
63    /// Get untranslated strings for a target locale with batching support.
64    #[tool(
65        name = "get_untranslated",
66        description = "Get untranslated strings for a target locale. Returns batched results with pagination. Call parse_xcstrings first or pass file_path."
67    )]
68    async fn get_untranslated(
69        &self,
70        Parameters(params): Parameters<GetUntranslatedParams>,
71    ) -> Result<String, String> {
72        match handle_get_untranslated(self.store.as_ref(), &self.cache, params).await {
73            Ok(value) => serde_json::to_string_pretty(&value)
74                .map_err(|e| format!("serialization error: {e}")),
75            Err(e) => {
76                error!(error = %e, "get_untranslated failed");
77                Err(e.to_string())
78            }
79        }
80    }
81
82    /// Submit translations: validates format specifiers and plural forms,
83    /// merges into the file, and writes back atomically.
84    #[tool(
85        name = "submit_translations",
86        description = "Submit translations for review and writing. Validates specifiers/plurals, merges, and writes atomically. Use dry_run=true to validate without writing."
87    )]
88    async fn submit_translations(
89        &self,
90        Parameters(params): Parameters<SubmitTranslationsParams>,
91    ) -> Result<String, String> {
92        match handle_submit_translations(self.store.as_ref(), &self.cache, &self.write_lock, params)
93            .await
94        {
95            Ok(value) => serde_json::to_string_pretty(&value)
96                .map_err(|e| format!("serialization error: {e}")),
97            Err(e) => {
98                error!(error = %e, "submit_translations failed");
99                Err(e.to_string())
100            }
101        }
102    }
103
104    /// Get translation coverage statistics per locale.
105    #[tool(
106        name = "get_coverage",
107        description = "Get translation coverage statistics per locale. Shows translated/total counts and percentages for each locale."
108    )]
109    async fn get_coverage(
110        &self,
111        Parameters(params): Parameters<GetCoverageParams>,
112    ) -> Result<String, String> {
113        match handle_get_coverage(self.store.as_ref(), &self.cache, params).await {
114            Ok(value) => serde_json::to_string_pretty(&value)
115                .map_err(|e| format!("serialization error: {e}")),
116            Err(e) => {
117                error!(error = %e, "get_coverage failed");
118                Err(e.to_string())
119            }
120        }
121    }
122
123    /// Get stale strings (extractionState=stale) for a target locale.
124    #[tool(
125        name = "get_stale",
126        description = "Get strings marked as stale (removed from source code). Returns batched results with pagination."
127    )]
128    async fn get_stale(
129        &self,
130        Parameters(params): Parameters<GetStaleParams>,
131    ) -> Result<String, String> {
132        match handle_get_stale(self.store.as_ref(), &self.cache, params).await {
133            Ok(value) => serde_json::to_string_pretty(&value)
134                .map_err(|e| format!("serialization error: {e}")),
135            Err(e) => {
136                error!(error = %e, "get_stale failed");
137                Err(e.to_string())
138            }
139        }
140    }
141
142    /// Validate translations in the file for correctness.
143    #[tool(
144        name = "validate_translations",
145        description = "Validate all translations for format specifier mismatches, missing plural forms, empty values, and other issues. Optionally filter by locale."
146    )]
147    async fn validate_translations_file(
148        &self,
149        Parameters(params): Parameters<ValidateFileParams>,
150    ) -> Result<String, String> {
151        match handle_validate_file(self.store.as_ref(), &self.cache, params).await {
152            Ok(value) => serde_json::to_string_pretty(&value)
153                .map_err(|e| format!("serialization error: {e}")),
154            Err(e) => {
155                error!(error = %e, "validate_translations failed");
156                Err(e.to_string())
157            }
158        }
159    }
160
161    /// List all locales with translation statistics.
162    #[tool(
163        name = "list_locales",
164        description = "List all locales in the file with translation counts and percentages."
165    )]
166    async fn list_locales(
167        &self,
168        Parameters(params): Parameters<ListLocalesParams>,
169    ) -> Result<String, String> {
170        match handle_list_locales(self.store.as_ref(), &self.cache, params).await {
171            Ok(value) => serde_json::to_string_pretty(&value)
172                .map_err(|e| format!("serialization error: {e}")),
173            Err(e) => {
174                error!(error = %e, "list_locales failed");
175                Err(e.to_string())
176            }
177        }
178    }
179
180    /// Add a new locale to the file.
181    #[tool(
182        name = "add_locale",
183        description = "Add a new locale to the file. Initializes all translatable keys with empty translations (state=new). Writes the file atomically."
184    )]
185    async fn add_locale(
186        &self,
187        Parameters(params): Parameters<AddLocaleParams>,
188    ) -> Result<String, String> {
189        match handle_add_locale(self.store.as_ref(), &self.cache, &self.write_lock, params).await {
190            Ok(value) => serde_json::to_string_pretty(&value)
191                .map_err(|e| format!("serialization error: {e}")),
192            Err(e) => {
193                error!(error = %e, "add_locale failed");
194                Err(e.to_string())
195            }
196        }
197    }
198
199    /// Get keys requiring plural/device translation for a locale.
200    #[tool(
201        name = "get_plurals",
202        description = "Get keys needing plural or device-variant translation. Returns plural forms needed, existing translations, and required CLDR forms. Use for translating plurals/substitutions/device variants."
203    )]
204    async fn get_plurals(
205        &self,
206        Parameters(params): Parameters<GetPluralsParams>,
207    ) -> Result<String, String> {
208        match handle_get_plurals(self.store.as_ref(), &self.cache, params).await {
209            Ok(value) => serde_json::to_string_pretty(&value)
210                .map_err(|e| format!("serialization error: {e}")),
211            Err(e) => {
212                error!(error = %e, "get_plurals failed");
213                Err(e.to_string())
214            }
215        }
216    }
217
218    /// Get nearby context keys for a translation key.
219    #[tool(
220        name = "get_context",
221        description = "Get nearby keys sharing a common prefix with the given key. Helps translators understand context by seeing related strings and their translations."
222    )]
223    async fn get_context(
224        &self,
225        Parameters(params): Parameters<GetContextParams>,
226    ) -> Result<String, String> {
227        match handle_get_context(self.store.as_ref(), &self.cache, params).await {
228            Ok(value) => serde_json::to_string_pretty(&value)
229                .map_err(|e| format!("serialization error: {e}")),
230            Err(e) => {
231                error!(error = %e, "get_context failed");
232                Err(e.to_string())
233            }
234        }
235    }
236}
237
238#[tool_handler]
239impl ServerHandler for XcStringsMcpServer {
240    fn get_info(&self) -> ServerInfo {
241        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
242            .with_protocol_version(ProtocolVersion::V_2025_06_18)
243            .with_instructions(
244                "MCP server for iOS/macOS .xcstrings (String Catalog) localization files. \
245                     Use parse_xcstrings to load a file, get_untranslated to find strings needing \
246                     translation, get_plurals for plural/device variant keys, get_context for nearby \
247                     related keys, submit_translations to write translations back, get_coverage for \
248                     per-locale statistics, get_stale to find removed strings, validate_translations \
249                     to check correctness, list_locales to see all locales, and add_locale to add a \
250                     new locale.",
251            )
252    }
253}