Skip to main content

xcstrings_mcp/
prompts.rs

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    /// The target locale code (e.g. "uk", "fr", "de")
19    locale: String,
20    /// Number of strings to translate per batch (default: 20)
21    count: Option<u32>,
22}
23
24#[derive(Debug, Deserialize, JsonSchema)]
25pub(crate) struct ReviewTranslationsParams {
26    /// The locale code to review translations for
27    locale: String,
28}
29
30#[derive(Debug, Deserialize, JsonSchema)]
31pub(crate) struct LocalizationAuditParams {
32    /// The locale code to audit
33    locale: String,
34}
35
36#[derive(Debug, Deserialize, JsonSchema)]
37pub(crate) struct FixValidationErrorsParams {
38    /// The locale code to fix validation errors for
39    locale: String,
40}
41
42#[derive(Debug, Deserialize, JsonSchema)]
43pub(crate) struct AddLanguageParams {
44    /// The target locale code to add
45    locale: String,
46    /// Path to the .xcstrings file (optional if already parsed)
47    file_path: Option<String>,
48}
49
50#[derive(Debug, Deserialize, JsonSchema)]
51pub(crate) struct FullTranslateParams {
52    /// The target locale code
53    locale: String,
54    /// Path to the .xcstrings file
55    file_path: String,
56}
57
58#[derive(Debug, Deserialize, JsonSchema)]
59pub(crate) struct CleanupStaleParams {
60    /// Path to the .xcstrings file (optional if already parsed)
61    file_path: Option<String>,
62}
63
64#[derive(Debug, Deserialize, JsonSchema)]
65pub(crate) struct ExtractStringsParams {
66    /// Source language code (e.g. "en")
67    source_language: String,
68    /// Path to the .xcstrings file to create or update
69    file_path: String,
70}
71
72#[prompt_router]
73impl XcStringsMcpServer {
74    /// Instructions for translating a batch of strings to a target locale
75    #[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    /// Instructions for reviewing existing translations for quality
117    #[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    /// Complete workflow for translating an entire file
155    #[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    /// Complete localization audit for a locale
206    #[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    /// Fix all validation errors for a locale
249    #[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    /// Extract hardcoded strings from Swift source code into .xcstrings
292    #[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    /// Find and remove stale/unused localization keys
350    #[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    /// Add a new language and begin translating
384    #[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}