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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}