1use rmcp::{
2 handler::server::wrapper::Parameters,
3 model::{GetPromptResult, PromptMessage, PromptMessageRole},
4 prompt, prompt_router,
5};
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use crate::server::XcStringsMcpServer;
10
11pub(crate) fn build_prompt_router()
12-> rmcp::handler::server::router::prompt::PromptRouter<XcStringsMcpServer> {
13 XcStringsMcpServer::prompt_router()
14}
15
16#[derive(Debug, Deserialize, JsonSchema)]
17pub(crate) struct TranslateBatchParams {
18 locale: String,
20 count: Option<u32>,
22}
23
24#[derive(Debug, Deserialize, JsonSchema)]
25pub(crate) struct ReviewTranslationsParams {
26 locale: String,
28}
29
30#[derive(Debug, Deserialize, JsonSchema)]
31pub(crate) struct LocalizationAuditParams {
32 locale: String,
34}
35
36#[derive(Debug, Deserialize, JsonSchema)]
37pub(crate) struct FixValidationErrorsParams {
38 locale: String,
40}
41
42#[derive(Debug, Deserialize, JsonSchema)]
43pub(crate) struct AddLanguageParams {
44 locale: String,
46 file_path: Option<String>,
48}
49
50#[derive(Debug, Deserialize, JsonSchema)]
51pub(crate) struct FullTranslateParams {
52 locale: String,
54 file_path: String,
56}
57
58#[derive(Debug, Deserialize, JsonSchema)]
59pub(crate) struct CleanupStaleParams {
60 file_path: Option<String>,
62}
63
64#[derive(Debug, Deserialize, JsonSchema)]
65pub(crate) struct ExtractStringsParams {
66 source_language: String,
68 file_path: String,
70}
71
72#[prompt_router]
73impl XcStringsMcpServer {
74 #[prompt(
76 name = "translate_batch",
77 description = "Instructions for translating a batch of strings to a target locale"
78 )]
79 fn translate_batch(
80 &self,
81 Parameters(params): Parameters<TranslateBatchParams>,
82 ) -> Result<GetPromptResult, rmcp::ErrorData> {
83 let count = params.count.unwrap_or(20);
84 let content = format!(
85 "You are translating iOS app strings to {locale}.\n\
86 \n\
87 Instructions:\n\
88 1. Call get_untranslated with locale=\"{locale}\" and batch_size={count}\n\
89 2. For each string, translate naturally \u{2014} not word-for-word\n\
90 3. Preserve all format specifiers (%@, %d, %lld, etc.) exactly as they appear\n\
91 4. For plural forms, use get_plurals to see required CLDR forms for {locale}\n\
92 5. Use get_context to understand nearby related strings\n\
93 6. Submit translations using submit_translations\n\
94 7. If there are more untranslated strings, repeat from step 1\n\
95 \n\
96 Guidelines:\n\
97 - Keep translations concise \u{2014} mobile UI has limited space\n\
98 - Maintain consistent terminology \u{2014} use get_glossary to check existing terms\n\
99 - Don't translate brand names or technical identifiers\n\
100 - Preserve the tone and formality level of the source text\n\
101 - Review rejected translations in response and fix format specifier issues before retranslating",
102 locale = params.locale,
103 count = count,
104 );
105
106 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
107 PromptMessageRole::User,
108 content,
109 )])
110 .with_description(format!(
111 "Translate a batch of {count} strings to {}",
112 params.locale
113 )))
114 }
115
116 #[prompt(
118 name = "review_translations",
119 description = "Instructions for reviewing existing translations for quality"
120 )]
121 fn review_translations(
122 &self,
123 Parameters(params): Parameters<ReviewTranslationsParams>,
124 ) -> Result<GetPromptResult, rmcp::ErrorData> {
125 let content = format!(
126 "You are reviewing existing translations for locale \"{locale}\".\n\
127 \n\
128 Instructions:\n\
129 1. Call validate_translations with locale=\"{locale}\" to find technical issues\n\
130 2. Call get_coverage with locale=\"{locale}\" to see overall progress\n\
131 3. For each validation issue, assess severity:\n\
132 \x20 - Format specifier mismatches: CRITICAL \u{2014} fix immediately\n\
133 \x20 - Missing plural forms: HIGH \u{2014} will cause runtime issues\n\
134 \x20 - Empty translations: MEDIUM \u{2014} incomplete but not broken\n\
135 4. Review a sample of translated strings for quality:\n\
136 \x20 - Natural language flow (not word-for-word translation)\n\
137 \x20 - Consistent terminology\n\
138 \x20 - Appropriate length for mobile UI\n\
139 \x20 - Correct gender/number agreement\n\
140 5. Report findings with specific key names and suggested fixes",
141 locale = params.locale,
142 );
143
144 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
145 PromptMessageRole::User,
146 content,
147 )])
148 .with_description(format!(
149 "Review translations for locale \"{}\"",
150 params.locale
151 )))
152 }
153
154 #[prompt(
156 name = "full_translate",
157 description = "Complete workflow for translating an entire file"
158 )]
159 fn full_translate(
160 &self,
161 Parameters(params): Parameters<FullTranslateParams>,
162 ) -> Result<GetPromptResult, rmcp::ErrorData> {
163 let content = format!(
164 "Complete translation workflow for {file_path} \u{2192} {locale}.\n\
165 \n\
166 Step 1: Parse the file\n\
167 \x20 Call parse_xcstrings with file_path=\"{file_path}\"\n\
168 \n\
169 Step 2: Check current state\n\
170 \x20 Call get_coverage to see existing translation progress for {locale}\n\
171 \x20 Call list_locales to verify {locale} exists (add_locale if needed)\n\
172 \x20 Call get_glossary for existing terminology guidance\n\
173 \n\
174 Step 3: Translate simple strings\n\
175 \x20 Call get_untranslated with locale=\"{locale}\"\n\
176 \x20 Translate each batch and submit with submit_translations\n\
177 \x20 Repeat until no untranslated strings remain\n\
178 \n\
179 Step 4: Translate plural forms\n\
180 \x20 Call get_plurals with locale=\"{locale}\"\n\
181 \x20 For each plural key, provide all required CLDR forms\n\
182 \x20 Submit using submit_translations with plural_forms\n\
183 \n\
184 Step 5: Validate\n\
185 \x20 Call validate_translations to check for issues\n\
186 \x20 Fix any problems found\n\
187 \n\
188 Step 6: Final check\n\
189 \x20 Call get_coverage to confirm 100% for {locale}\n\
190 \x20 Call get_diff to see all changes made",
191 file_path = params.file_path,
192 locale = params.locale,
193 );
194
195 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
196 PromptMessageRole::User,
197 content,
198 )])
199 .with_description(format!(
200 "Full translation workflow for {} to {}",
201 params.file_path, params.locale
202 )))
203 }
204
205 #[prompt(
207 name = "localization_audit",
208 description = "Run a complete localization audit: coverage, validation, stale keys, glossary consistency"
209 )]
210 fn localization_audit(
211 &self,
212 Parameters(params): Parameters<LocalizationAuditParams>,
213 ) -> Result<GetPromptResult, rmcp::ErrorData> {
214 let content = format!(
215 "Complete localization audit for locale \"{locale}\".\n\
216 \n\
217 Step 1: Check coverage\n\
218 \x20 Call get_coverage to see translation progress for {locale}\n\
219 \n\
220 Step 2: Validate existing translations\n\
221 \x20 Call validate_translations to find technical issues for {locale}\n\
222 \x20 Categorize by severity:\n\
223 \x20 CRITICAL: format specifier mismatches \u{2014} will crash at runtime\n\
224 \x20 HIGH: missing plural forms \u{2014} will show wrong text\n\
225 \x20 MEDIUM: empty translations \u{2014} incomplete but not broken\n\
226 \n\
227 Step 3: Check for stale keys\n\
228 \x20 Call get_stale with locale=\"{locale}\" to find removed strings\n\
229 \x20 These can be safely ignored or cleaned up\n\
230 \n\
231 Step 4: Check glossary consistency\n\
232 \x20 Call get_glossary for the source/target locale pair\n\
233 \x20 Use search_keys to spot-check that key terms match glossary\n\
234 \n\
235 Step 5: Summary report\n\
236 \x20 Report: coverage %, validation errors by severity,\n\
237 \x20 stale key count, and any glossary inconsistencies found",
238 locale = params.locale,
239 );
240
241 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
242 PromptMessageRole::User,
243 content,
244 )])
245 .with_description(format!("Localization audit for \"{}\"", params.locale)))
246 }
247
248 #[prompt(
250 name = "fix_validation_errors",
251 description = "Guided workflow to find and fix all validation errors for a locale"
252 )]
253 fn fix_validation_errors(
254 &self,
255 Parameters(params): Parameters<FixValidationErrorsParams>,
256 ) -> Result<GetPromptResult, rmcp::ErrorData> {
257 let content = format!(
258 "Fix validation errors for locale \"{locale}\".\n\
259 \n\
260 Step 1: Get all validation issues\n\
261 \x20 Call validate_translations with locale=\"{locale}\"\n\
262 \n\
263 Step 2: Fix CRITICAL issues first (format specifier mismatches)\n\
264 \x20 For each specifier mismatch:\n\
265 \x20 - Call get_context to understand the string's purpose\n\
266 \x20 - Fix the translation to include the correct specifiers\n\
267 \x20 - Submit with submit_translations (dry_run=true first to verify)\n\
268 \n\
269 Step 3: Fix HIGH issues (missing plural forms)\n\
270 \x20 For each missing plural form:\n\
271 \x20 - Call get_plurals to see required CLDR forms for {locale}\n\
272 \x20 - Provide all required forms (one, few, many, other etc.)\n\
273 \x20 - Submit with submit_translations using plural_forms\n\
274 \n\
275 Step 4: Fix MEDIUM issues (empty translations)\n\
276 \x20 These are untranslated strings \u{2014} use the translate_batch workflow\n\
277 \x20 Call get_untranslated and translate in batches\n\
278 \n\
279 Step 5: Verify\n\
280 \x20 Call validate_translations again to confirm zero issues remain",
281 locale = params.locale,
282 );
283
284 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
285 PromptMessageRole::User,
286 content,
287 )])
288 .with_description(format!("Fix validation errors for \"{}\"", params.locale)))
289 }
290
291 #[prompt(
293 name = "extract_strings",
294 description = "Guided workflow to extract hardcoded strings from Swift source code into an .xcstrings file"
295 )]
296 fn extract_strings(
297 &self,
298 Parameters(params): Parameters<ExtractStringsParams>,
299 ) -> Result<GetPromptResult, rmcp::ErrorData> {
300 let content = format!(
301 "Extract hardcoded strings from Swift source code into {file_path}.\n\
302 \n\
303 Step 1: Create or parse the .xcstrings file\n\
304 \x20 If {file_path} does not exist:\n\
305 \x20 Call create_xcstrings with file_path=\"{file_path}\" and \
306 source_language=\"{source_language}\"\n\
307 \x20 If it already exists:\n\
308 \x20 Call parse_xcstrings with file_path=\"{file_path}\"\n\
309 \n\
310 Step 2: Scan Swift files for hardcoded strings\n\
311 \x20 Look for patterns like:\n\
312 \x20 - Text(\"...\") and Label(\"...\")\n\
313 \x20 - String literals in .alert(), .navigationTitle(), etc.\n\
314 \x20 - NSLocalizedString(\"...\", comment: \"...\")\n\
315 \x20 - Any user-visible string literal\n\
316 \x20 Skip: debug logs, print(), assert messages, identifiers\n\
317 \n\
318 Step 3: Generate key names\n\
319 \x20 Use dot.separated.convention based on context:\n\
320 \x20 - screen.element.description (e.g., settings.title, login.button.submit)\n\
321 \x20 - Keep keys short but descriptive\n\
322 \x20 - Group related keys with shared prefixes\n\
323 \n\
324 Step 4: Add keys to the .xcstrings file\n\
325 \x20 Call add_keys with the generated keys and source text\n\
326 \x20 Include developer comments describing the context\n\
327 \n\
328 Step 5: Replace hardcoded strings in Swift code\n\
329 \x20 Replace each hardcoded string with String(localized: \"key.name\")\n\
330 \x20 For strings with format specifiers, use appropriate interpolation\n\
331 \n\
332 Step 6: Validate\n\
333 \x20 Call parse_xcstrings to verify the file is valid\n\
334 \x20 Ensure all replaced strings have corresponding keys",
335 file_path = params.file_path,
336 source_language = params.source_language,
337 );
338
339 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
340 PromptMessageRole::User,
341 content,
342 )])
343 .with_description(format!(
344 "Extract strings from Swift code into {}",
345 params.file_path
346 )))
347 }
348
349 #[prompt(
351 name = "cleanup_stale",
352 description = "Find and remove stale/unused localization keys"
353 )]
354 fn cleanup_stale(
355 &self,
356 Parameters(params): Parameters<CleanupStaleParams>,
357 ) -> Result<GetPromptResult, rmcp::ErrorData> {
358 let file_instruction = params
359 .file_path
360 .as_ref()
361 .map(|fp| format!("\n Call parse_xcstrings with file_path=\"{fp}\""))
362 .unwrap_or_else(|| {
363 "\n Ensure a file is already parsed (call parse_xcstrings if needed)".to_string()
364 });
365
366 let content = format!(
367 "Find and remove stale/unused localization keys.\n\
368 \n\
369 1. Parse the file{file_instruction}\n\
370 2. Call get_stale(batch_size=100) to find keys removed from source code\n\
371 3. Review each stale key with get_key and get_context to confirm it is unused\n\
372 4. Call delete_keys with confirmed stale keys\n\
373 5. Call get_coverage and get_stale to verify cleanup",
374 );
375
376 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
377 PromptMessageRole::User,
378 content,
379 )])
380 .with_description("Find and remove stale/unused localization keys"))
381 }
382
383 #[prompt(
385 name = "add_language",
386 description = "Guided workflow to add a new locale and translate all strings"
387 )]
388 fn add_language(
389 &self,
390 Parameters(params): Parameters<AddLanguageParams>,
391 ) -> Result<GetPromptResult, rmcp::ErrorData> {
392 let file_instruction = params
393 .file_path
394 .as_ref()
395 .map(|fp| format!("\n Call parse_xcstrings with file_path=\"{fp}\""))
396 .unwrap_or_else(|| {
397 "\n Ensure a file is already parsed (call parse_xcstrings if needed)".to_string()
398 });
399
400 let content = format!(
401 "Add and translate a new language: {locale}.\n\
402 \n\
403 Step 1: Parse the file{file_instruction}\n\
404 \n\
405 Step 2: Add the locale\n\
406 \x20 Call add_locale with locale=\"{locale}\"\n\
407 \n\
408 Step 3: Check scope\n\
409 \x20 Call get_coverage to see how many strings need translation\n\
410 \x20 Call get_untranslated with locale=\"{locale}\" to preview the first batch\n\
411 \n\
412 Step 4: Check glossary\n\
413 \x20 Call get_glossary to see existing terminology guidance\n\
414 \x20 Use consistent terminology throughout\n\
415 \n\
416 Step 5: Translate simple strings\n\
417 \x20 Call get_untranslated in batches (batch_size=20)\n\
418 \x20 Translate each batch naturally, preserving format specifiers\n\
419 \x20 Submit with submit_translations\n\
420 \x20 Repeat until no untranslated strings remain\n\
421 \n\
422 Step 6: Translate plural forms\n\
423 \x20 Call get_plurals with locale=\"{locale}\"\n\
424 \x20 For each plural key, provide all required CLDR forms\n\
425 \x20 Submit using submit_translations with plural_forms\n\
426 \n\
427 Step 7: Validate and finalize\n\
428 \x20 Call validate_translations to check for issues\n\
429 \x20 Call get_coverage to confirm 100% for {locale}",
430 locale = params.locale,
431 file_instruction = file_instruction,
432 );
433
434 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
435 PromptMessageRole::User,
436 content,
437 )])
438 .with_description(format!("Add language \"{}\" and translate", params.locale)))
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use rmcp::model::PromptMessageContent;
446 use std::path::PathBuf;
447 use std::sync::Arc;
448
449 fn make_server() -> XcStringsMcpServer {
450 let store = Arc::new(crate::tools::test_helpers::MemoryStore::new());
451 XcStringsMcpServer::new(store, PathBuf::from("/tmp/g.json"))
452 }
453
454 #[test]
455 fn translate_batch_returns_content() {
456 let server = make_server();
457 let result = server
458 .translate_batch(Parameters(TranslateBatchParams {
459 locale: "uk".into(),
460 count: Some(10),
461 }))
462 .unwrap();
463 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
464 panic!("expected text")
465 };
466 assert!(text.contains("uk"));
467 assert!(text.contains("10"));
468 assert!(text.contains("get_untranslated"));
469 }
470
471 #[test]
472 fn translate_batch_default_count() {
473 let server = make_server();
474 let result = server
475 .translate_batch(Parameters(TranslateBatchParams {
476 locale: "de".into(),
477 count: None,
478 }))
479 .unwrap();
480 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
481 panic!("expected text")
482 };
483 assert!(text.contains("20"));
484 }
485
486 #[test]
487 fn review_translations_returns_content() {
488 let server = make_server();
489 let result = server
490 .review_translations(Parameters(ReviewTranslationsParams {
491 locale: "fr".into(),
492 }))
493 .unwrap();
494 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
495 panic!("expected text")
496 };
497 assert!(text.contains("fr"));
498 assert!(text.contains("validate_translations"));
499 }
500
501 #[test]
502 fn full_translate_returns_content() {
503 let server = make_server();
504 let result = server
505 .full_translate(Parameters(FullTranslateParams {
506 locale: "ja".into(),
507 file_path: "/App/L.xcstrings".into(),
508 }))
509 .unwrap();
510 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
511 panic!("expected text")
512 };
513 assert!(text.contains("ja"));
514 assert!(text.contains("/App/L.xcstrings"));
515 }
516
517 #[test]
518 fn localization_audit_returns_content() {
519 let server = make_server();
520 let result = server
521 .localization_audit(Parameters(LocalizationAuditParams {
522 locale: "uk".into(),
523 }))
524 .unwrap();
525 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
526 panic!("expected text")
527 };
528 assert!(text.contains("uk"));
529 assert!(text.contains("get_coverage"));
530 assert!(text.contains("get_stale"));
531 }
532
533 #[test]
534 fn fix_validation_errors_returns_content() {
535 let server = make_server();
536 let result = server
537 .fix_validation_errors(Parameters(FixValidationErrorsParams {
538 locale: "de".into(),
539 }))
540 .unwrap();
541 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
542 panic!("expected text")
543 };
544 assert!(text.contains("de"));
545 assert!(text.contains("CRITICAL"));
546 }
547
548 #[test]
549 fn extract_strings_returns_content() {
550 let server = make_server();
551 let result = server
552 .extract_strings(Parameters(ExtractStringsParams {
553 source_language: "en".into(),
554 file_path: "/App/L.xcstrings".into(),
555 }))
556 .unwrap();
557 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
558 panic!("expected text")
559 };
560 assert!(text.contains("create_xcstrings"));
561 assert!(text.contains("add_keys"));
562 assert!(text.contains("String(localized"));
563 }
564
565 #[test]
566 fn add_language_with_file_path() {
567 let server = make_server();
568 let result = server
569 .add_language(Parameters(AddLanguageParams {
570 locale: "ko".into(),
571 file_path: Some("/App/L.xcstrings".into()),
572 }))
573 .unwrap();
574 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
575 panic!("expected text")
576 };
577 assert!(text.contains("ko"));
578 assert!(text.contains("/App/L.xcstrings"));
579 }
580
581 #[test]
582 fn cleanup_stale_returns_content() {
583 let server = make_server();
584 let result = server
585 .cleanup_stale(Parameters(CleanupStaleParams {
586 file_path: Some("/App/L.xcstrings".into()),
587 }))
588 .unwrap();
589 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
590 panic!("expected text")
591 };
592 assert!(text.contains("get_stale"));
593 assert!(text.contains("delete_keys"));
594 assert!(text.contains("/App/L.xcstrings"));
595 }
596
597 #[test]
598 fn cleanup_stale_without_file_path() {
599 let server = make_server();
600 let result = server
601 .cleanup_stale(Parameters(CleanupStaleParams { file_path: None }))
602 .unwrap();
603 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
604 panic!("expected text")
605 };
606 assert!(text.contains("Ensure a file is already parsed"));
607 }
608
609 #[test]
610 fn add_language_without_file_path() {
611 let server = make_server();
612 let result = server
613 .add_language(Parameters(AddLanguageParams {
614 locale: "zh".into(),
615 file_path: None,
616 }))
617 .unwrap();
618 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
619 panic!("expected text")
620 };
621 assert!(text.contains("zh"));
622 assert!(text.contains("Ensure a file is already parsed"));
623 }
624}