@scoutello/i18n-magic 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-server.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js"
7
7
  import { z } from "zod"
8
- import { addTranslationKey, getMissingKeys, loadConfig } from "./lib/utils.js"
8
+ import { addTranslationKey, getMissingKeys, loadConfig, loadLocalesFile } from "./lib/utils.js"
9
9
  import type { Configuration } from "./lib/types.js"
10
10
  import path from "path"
11
11
  import fs from "fs"
@@ -67,7 +67,8 @@ function resolveProjectRoot(): string {
67
67
  // Zod schema for the add_translation_key tool parameters
68
68
  const AddTranslationKeySchema = z.object({
69
69
  key: z.string().describe("The translation key to add (e.g., \"welcomeMessage\")"),
70
- value: z.string().describe("The English text value for this translation key"),
70
+ value: z.string().describe("The text value for this translation key"),
71
+ language: z.string().optional().describe("The language code of the provided value (e.g., \"en\", \"de\", \"fr\"). Defaults to \"en\" (English) if not specified."),
71
72
  })
72
73
 
73
74
  // Zod schema for the list_untranslated_keys tool parameters
@@ -80,6 +81,35 @@ const ListUntranslatedKeysSchema = z.object({
80
81
  ),
81
82
  })
82
83
 
84
+ // Zod schema for the get_translation_key tool parameters
85
+ const GetTranslationKeySchema = z.object({
86
+ key: z.string().describe("The translation key to retrieve (e.g., \"welcomeMessage\")"),
87
+ namespace: z
88
+ .string()
89
+ .optional()
90
+ .describe(
91
+ "Optional namespace to search in. If not provided, searches in default namespace first, then all namespaces.",
92
+ ),
93
+ })
94
+
95
+ // Zod schema for the update_translation_key tool parameters
96
+ const UpdateTranslationKeySchema = z.object({
97
+ key: z.string().describe("The translation key to update (e.g., \"welcomeMessage\")"),
98
+ value: z.string().describe("The new text value for this translation key"),
99
+ language: z.string().optional().describe("The language code of the provided value (e.g., \"en\", \"de\", \"fr\"). Defaults to \"en\" (English) if not specified."),
100
+ namespace: z
101
+ .string()
102
+ .optional()
103
+ .describe(
104
+ "Optional namespace to update. If not provided, updates the key in all namespaces where it exists.",
105
+ ),
106
+ })
107
+
108
+ // Zod schema for the search_translations tool parameters
109
+ const SearchTranslationsSchema = z.object({
110
+ query: z.string().describe("Search term to find in translation keys or values (fuzzy search)"),
111
+ })
112
+
83
113
  class I18nMagicServer {
84
114
  private server: Server
85
115
  private config: Configuration | null = null
@@ -154,7 +184,7 @@ class I18nMagicServer {
154
184
  tools: [
155
185
  {
156
186
  name: "add_translation_key",
157
- description: "Add a new translation key with an English value.",
187
+ description: "Add a new translation key with a text value. You can optionally specify the language of the value you're providing (defaults to English).",
158
188
  inputSchema: {
159
189
  type: "object",
160
190
  properties: {
@@ -166,7 +196,12 @@ class I18nMagicServer {
166
196
  value: {
167
197
  type: "string",
168
198
  description:
169
- "The English text value for this translation key",
199
+ "The text value for this translation key",
200
+ },
201
+ language: {
202
+ type: "string",
203
+ description:
204
+ "The language code of the provided value (e.g., \"en\" for English, \"de\" for German, \"fr\" for French). Defaults to \"en\" if not specified.",
170
205
  },
171
206
  },
172
207
  required: ["key", "value"],
@@ -188,6 +223,74 @@ class I18nMagicServer {
188
223
  required: [],
189
224
  },
190
225
  },
226
+ {
227
+ name: "get_translation_key",
228
+ description:
229
+ "Retrieve the English value for a specific translation key. This tool searches for the key in the locale files and always returns the English translation if it exists.",
230
+ inputSchema: {
231
+ type: "object",
232
+ properties: {
233
+ key: {
234
+ type: "string",
235
+ description:
236
+ "The translation key to retrieve (e.g., \"welcomeMessage\")",
237
+ },
238
+ namespace: {
239
+ type: "string",
240
+ description:
241
+ "Optional namespace to search in. If not provided, searches in default namespace first, then all namespaces.",
242
+ },
243
+ },
244
+ required: ["key"],
245
+ },
246
+ },
247
+ {
248
+ name: "update_translation_key",
249
+ description:
250
+ "Update an existing translation key with a new text value. You can optionally specify the language of the value you're providing (defaults to English). This will update the key across all locales (translating automatically to other languages) and all namespaces where the key exists. Use this when you need to fix typos, improve wording, or change the text of an existing translation. If you're not sure if a key exists, use get_translation_key or search_translations first.",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {
254
+ key: {
255
+ type: "string",
256
+ description:
257
+ "The translation key to update (e.g., \"welcomeMessage\")",
258
+ },
259
+ value: {
260
+ type: "string",
261
+ description:
262
+ "The new text value for this translation key",
263
+ },
264
+ language: {
265
+ type: "string",
266
+ description:
267
+ "The language code of the provided value (e.g., \"en\" for English, \"de\" for German, \"fr\" for French). Defaults to \"en\" if not specified.",
268
+ },
269
+ namespace: {
270
+ type: "string",
271
+ description:
272
+ "Optional namespace to update. If not provided, updates the key in all namespaces where it exists.",
273
+ },
274
+ },
275
+ required: ["key", "value"],
276
+ },
277
+ },
278
+ {
279
+ name: "search_translations",
280
+ description:
281
+ "Search for translations by keyword or phrase across ALL namespaces. This tool performs fuzzy search across both translation keys AND their English values, making it perfect for finding existing translations before adding new ones. Use this to: 1) Check if similar text already exists to avoid duplicates, 2) Find the key name when you only remember part of the text, 3) Discover related translations. Always returns the key name AND English value for each result.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ query: {
286
+ type: "string",
287
+ description:
288
+ "Search term to find in translation keys or values (e.g., 'password', 'welcome', 'click'). Fuzzy search - doesn't need to be exact. Searches across all namespaces.",
289
+ },
290
+ },
291
+ required: ["query"],
292
+ },
293
+ },
191
294
  ],
192
295
  }
193
296
  })
@@ -220,6 +323,7 @@ class I18nMagicServer {
220
323
  result = await addTranslationKey({
221
324
  key: params.key,
222
325
  value: params.value,
326
+ language: params.language || "en",
223
327
  config,
224
328
  })
225
329
  } finally {
@@ -234,14 +338,15 @@ class I18nMagicServer {
234
338
  text: JSON.stringify(
235
339
  {
236
340
  success: true,
237
- message: `Successfully added translation key "${result.key}" to affected namespaces: ${result.namespace} (${result.locale})`,
341
+ message: `Successfully added translation key "${result.key}" to affected namespaces: ${result.namespace} (locales: ${result.locale})`,
238
342
  key: result.key,
239
343
  value: result.value,
344
+ providedLanguage: params.language || "en",
240
345
  namespace: result.namespace,
241
- locale: result.locale,
346
+ locales: result.locale,
242
347
  nextStep: result.locale.includes(',')
243
- ? "Run 'i18n-magic sync' to translate this key to other locales"
244
- : "Key was translated to default locale. Run 'i18n-magic sync' to translate to other locales",
348
+ ? "Key was automatically translated to multiple locales"
349
+ : "Run 'i18n-magic sync' to translate this key to other locales",
245
350
  diagnostics: logMessages.join('\n'),
246
351
  },
247
352
  null,
@@ -396,6 +501,433 @@ class I18nMagicServer {
396
501
  }
397
502
  }
398
503
 
504
+ if (request.params.name === "get_translation_key") {
505
+ try {
506
+ // Validate parameters
507
+ const params = GetTranslationKeySchema.parse(
508
+ request.params.arguments,
509
+ )
510
+
511
+ // Ensure config is loaded
512
+ const config = await this.ensureConfig()
513
+
514
+ // Suppress console.log to prevent interference with MCP JSON protocol
515
+ const originalConsoleLog = console.log
516
+ console.log = () => {}
517
+
518
+ let foundValue: string | null = null
519
+ let foundNamespace: string | null = null
520
+
521
+ try {
522
+ // If namespace is specified, only check that namespace
523
+ if (params.namespace) {
524
+ const keys = await loadLocalesFile(config.loadPath, "en", params.namespace)
525
+ if (keys[params.key]) {
526
+ foundValue = keys[params.key]
527
+ foundNamespace = params.namespace
528
+ }
529
+ } else {
530
+ // Try default namespace first
531
+ try {
532
+ const keys = await loadLocalesFile(config.loadPath, "en", config.defaultNamespace)
533
+ if (keys[params.key]) {
534
+ foundValue = keys[params.key]
535
+ foundNamespace = config.defaultNamespace
536
+ }
537
+ } catch (error) {
538
+ // Default namespace file doesn't exist or has issues, continue to search other namespaces
539
+ }
540
+
541
+ // If not found in default namespace, search all other namespaces
542
+ if (!foundValue) {
543
+ for (const namespace of config.namespaces) {
544
+ if (namespace === config.defaultNamespace) continue // Already checked
545
+
546
+ try {
547
+ const keys = await loadLocalesFile(config.loadPath, "en", namespace)
548
+ if (keys[params.key]) {
549
+ foundValue = keys[params.key]
550
+ foundNamespace = namespace
551
+ break
552
+ }
553
+ } catch (error) {
554
+ // Namespace file doesn't exist or has issues, continue
555
+ }
556
+ }
557
+ }
558
+ }
559
+ } finally {
560
+ // Restore console.log
561
+ console.log = originalConsoleLog
562
+ }
563
+
564
+ if (foundValue) {
565
+ return {
566
+ content: [
567
+ {
568
+ type: "text",
569
+ text: JSON.stringify(
570
+ {
571
+ success: true,
572
+ key: params.key,
573
+ value: foundValue,
574
+ namespace: foundNamespace,
575
+ locale: "en",
576
+ },
577
+ null,
578
+ 2,
579
+ ),
580
+ },
581
+ ],
582
+ }
583
+ } else {
584
+ return {
585
+ content: [
586
+ {
587
+ type: "text",
588
+ text: JSON.stringify(
589
+ {
590
+ success: false,
591
+ error: `Translation key "${params.key}" not found in English locale${params.namespace ? ` (namespace: ${params.namespace})` : ""}`,
592
+ key: params.key,
593
+ searchedNamespace: params.namespace || "all namespaces",
594
+ suggestion: "Use list_untranslated_keys to see all missing keys or add_translation_key to add this key",
595
+ },
596
+ null,
597
+ 2,
598
+ ),
599
+ },
600
+ ],
601
+ isError: false,
602
+ }
603
+ }
604
+ } catch (error) {
605
+ const errorMessage =
606
+ error instanceof Error ? error.message : "Unknown error occurred"
607
+
608
+ // Get more detailed error information
609
+ let errorDetails = errorMessage
610
+ if (error instanceof Error) {
611
+ const cause = (error as any).cause
612
+ if (cause instanceof Error) {
613
+ errorDetails = `${errorMessage}\nCause: ${cause.message}\nStack: ${cause.stack}`
614
+ } else if (cause) {
615
+ errorDetails = `${errorMessage}\nCause: ${JSON.stringify(cause)}`
616
+ }
617
+ if (error.stack) {
618
+ errorDetails = `${errorDetails}\nStack: ${error.stack}`
619
+ }
620
+ }
621
+
622
+ // Log detailed error to stderr for debugging
623
+ console.error(`[i18n-magic MCP] Error getting translation key:`)
624
+ console.error(errorDetails)
625
+
626
+ return {
627
+ content: [
628
+ {
629
+ type: "text",
630
+ text: JSON.stringify(
631
+ {
632
+ success: false,
633
+ error: errorMessage,
634
+ details: errorDetails,
635
+ },
636
+ null,
637
+ 2,
638
+ ),
639
+ },
640
+ ],
641
+ isError: true,
642
+ }
643
+ }
644
+ }
645
+
646
+ if (request.params.name === "update_translation_key") {
647
+ try {
648
+ // Validate parameters
649
+ const params = UpdateTranslationKeySchema.parse(
650
+ request.params.arguments,
651
+ )
652
+
653
+ // Ensure config is loaded
654
+ const config = await this.ensureConfig()
655
+
656
+ // Suppress console.log to prevent interference with MCP JSON protocol
657
+ const originalConsoleLog = console.log
658
+ const logMessages: string[] = []
659
+ console.log = (...args: any[]) => {
660
+ const message = args.map(arg =>
661
+ typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
662
+ ).join(' ')
663
+ logMessages.push(message)
664
+ console.error(`[i18n-magic] ${message}`)
665
+ }
666
+
667
+ try {
668
+ // Find which namespaces contain this key
669
+ const targetNamespaces: string[] = []
670
+
671
+ if (params.namespace) {
672
+ // Check if key exists in specified namespace
673
+ const keys = await loadLocalesFile(config.loadPath, "en", params.namespace)
674
+ if (keys[params.key]) {
675
+ targetNamespaces.push(params.namespace)
676
+ } else {
677
+ throw new Error(`Key "${params.key}" does not exist in namespace "${params.namespace}"`)
678
+ }
679
+ } else {
680
+ // Find all namespaces where this key exists
681
+ for (const namespace of config.namespaces) {
682
+ try {
683
+ const keys = await loadLocalesFile(config.loadPath, "en", namespace)
684
+ if (keys[params.key]) {
685
+ targetNamespaces.push(namespace)
686
+ }
687
+ } catch (error) {
688
+ // Namespace file doesn't exist, continue
689
+ }
690
+ }
691
+
692
+ if (targetNamespaces.length === 0) {
693
+ throw new Error(`Key "${params.key}" does not exist in any namespace. Use add_translation_key to create it.`)
694
+ }
695
+ }
696
+
697
+ // Build translation cache with new value
698
+ const inputLanguage = params.language || "en"
699
+ const translationCache: Record<string, string> = {
700
+ [inputLanguage]: params.value,
701
+ }
702
+
703
+ // Translate to all other locales
704
+ const otherLocales = config.locales.filter((l) => l !== inputLanguage)
705
+ if (otherLocales.length > 0 && config.openai) {
706
+ const { translateKey } = await import("./lib/utils.js")
707
+
708
+ await Promise.all(
709
+ otherLocales.map(async (locale) => {
710
+ const translation = await translateKey({
711
+ context: config.context || "",
712
+ inputLanguage: inputLanguage,
713
+ outputLanguage: locale,
714
+ object: {
715
+ [params.key]: params.value,
716
+ },
717
+ openai: config.openai!,
718
+ model: config.model,
719
+ })
720
+ translationCache[locale] = translation[params.key]
721
+ })
722
+ )
723
+ }
724
+
725
+ // Update the key in all relevant namespaces and locales
726
+ await Promise.all(
727
+ targetNamespaces.flatMap((namespace) =>
728
+ config.locales.map(async (locale) => {
729
+ const newValue = translationCache[locale] || params.value
730
+ const existingKeys = await loadLocalesFile(config.loadPath, locale, namespace)
731
+ existingKeys[params.key] = newValue
732
+ const { writeLocalesFile } = await import("./lib/utils.js")
733
+ await writeLocalesFile(config.savePath, locale, namespace, existingKeys)
734
+ })
735
+ )
736
+ )
737
+
738
+ return {
739
+ content: [
740
+ {
741
+ type: "text",
742
+ text: JSON.stringify(
743
+ {
744
+ success: true,
745
+ message: `Successfully updated translation key "${params.key}" in ${targetNamespaces.length} namespace(s) and ${config.locales.length} locale(s)`,
746
+ key: params.key,
747
+ newValue: params.value,
748
+ providedLanguage: params.language || "en",
749
+ namespaces: targetNamespaces,
750
+ locales: config.locales,
751
+ diagnostics: logMessages.join('\n'),
752
+ },
753
+ null,
754
+ 2,
755
+ ),
756
+ },
757
+ ],
758
+ }
759
+ } finally {
760
+ // Restore console.log
761
+ console.log = originalConsoleLog
762
+ }
763
+ } catch (error) {
764
+ const errorMessage =
765
+ error instanceof Error ? error.message : "Unknown error occurred"
766
+
767
+ let errorDetails = errorMessage
768
+ if (error instanceof Error && error.stack) {
769
+ errorDetails = `${errorDetails}\nStack: ${error.stack}`
770
+ }
771
+
772
+ console.error(`[i18n-magic MCP] Error updating translation key:`)
773
+ console.error(errorDetails)
774
+
775
+ return {
776
+ content: [
777
+ {
778
+ type: "text",
779
+ text: JSON.stringify(
780
+ {
781
+ success: false,
782
+ error: errorMessage,
783
+ details: errorDetails,
784
+ },
785
+ null,
786
+ 2,
787
+ ),
788
+ },
789
+ ],
790
+ isError: true,
791
+ }
792
+ }
793
+ }
794
+
795
+ if (request.params.name === "search_translations") {
796
+ try {
797
+ // Validate parameters
798
+ const params = SearchTranslationsSchema.parse(
799
+ request.params.arguments,
800
+ )
801
+
802
+ // Ensure config is loaded
803
+ const config = await this.ensureConfig()
804
+
805
+ // Suppress console.log
806
+ const originalConsoleLog = console.log
807
+ console.log = () => {}
808
+
809
+ try {
810
+ const searchQuery = params.query.toLowerCase()
811
+ const results: Array<{
812
+ key: string
813
+ value: string
814
+ namespace: string
815
+ matchType: "key" | "value" | "both"
816
+ }> = []
817
+
818
+ // Search through all namespaces
819
+ for (const namespace of config.namespaces) {
820
+ try {
821
+ const keys = await loadLocalesFile(config.loadPath, "en", namespace)
822
+
823
+ for (const [key, value] of Object.entries(keys)) {
824
+ const keyLower = key.toLowerCase()
825
+ const valueLower = value.toLowerCase()
826
+
827
+ // Fuzzy matching: check if search query is contained in key or value
828
+ const keyMatch = keyLower.includes(searchQuery)
829
+ const valueMatch = valueLower.includes(searchQuery)
830
+
831
+ if (keyMatch || valueMatch) {
832
+ results.push({
833
+ key,
834
+ value,
835
+ namespace,
836
+ matchType: keyMatch && valueMatch ? "both" : keyMatch ? "key" : "value",
837
+ })
838
+ }
839
+ }
840
+ } catch (error) {
841
+ // Namespace file doesn't exist or has issues, continue
842
+ }
843
+ }
844
+
845
+ // Sort results: exact matches first, then by match type, then alphabetically
846
+ results.sort((a, b) => {
847
+ // Prioritize exact key matches
848
+ const aExactKey = a.key.toLowerCase() === searchQuery
849
+ const bExactKey = b.key.toLowerCase() === searchQuery
850
+ if (aExactKey && !bExactKey) return -1
851
+ if (!aExactKey && bExactKey) return 1
852
+
853
+ // Prioritize exact value matches
854
+ const aExactValue = a.value.toLowerCase() === searchQuery
855
+ const bExactValue = b.value.toLowerCase() === searchQuery
856
+ if (aExactValue && !bExactValue) return -1
857
+ if (!aExactValue && bExactValue) return 1
858
+
859
+ // Then by match type (both > key > value)
860
+ const matchOrder = { both: 0, key: 1, value: 2 }
861
+ const matchCompare = matchOrder[a.matchType] - matchOrder[b.matchType]
862
+ if (matchCompare !== 0) return matchCompare
863
+
864
+ // Finally alphabetically by key
865
+ return a.key.localeCompare(b.key)
866
+ })
867
+
868
+ // Limit results to prevent overwhelming output
869
+ const maxResults = 50
870
+ const limitedResults = results.slice(0, maxResults)
871
+ const hasMore = results.length > maxResults
872
+
873
+ return {
874
+ content: [
875
+ {
876
+ type: "text",
877
+ text: JSON.stringify(
878
+ {
879
+ success: true,
880
+ message: results.length === 0
881
+ ? `No translations found matching "${params.query}"`
882
+ : `Found ${results.length} translation${results.length === 1 ? "" : "s"} matching "${params.query}"${hasMore ? ` (showing first ${maxResults})` : ""}`,
883
+ query: params.query,
884
+ totalResults: results.length,
885
+ results: limitedResults,
886
+ hasMore,
887
+ tip: "Each result shows the translation key, English value, namespace, and what matched (key, value, or both). Use these keys directly in your code or use get_translation_key for more details.",
888
+ },
889
+ null,
890
+ 2,
891
+ ),
892
+ },
893
+ ],
894
+ }
895
+ } finally {
896
+ // Restore console.log
897
+ console.log = originalConsoleLog
898
+ }
899
+ } catch (error) {
900
+ const errorMessage =
901
+ error instanceof Error ? error.message : "Unknown error occurred"
902
+
903
+ let errorDetails = errorMessage
904
+ if (error instanceof Error && error.stack) {
905
+ errorDetails = `${errorDetails}\nStack: ${error.stack}`
906
+ }
907
+
908
+ console.error(`[i18n-magic MCP] Error searching translations:`)
909
+ console.error(errorDetails)
910
+
911
+ return {
912
+ content: [
913
+ {
914
+ type: "text",
915
+ text: JSON.stringify(
916
+ {
917
+ success: false,
918
+ error: errorMessage,
919
+ details: errorDetails,
920
+ },
921
+ null,
922
+ 2,
923
+ ),
924
+ },
925
+ ],
926
+ isError: true,
927
+ }
928
+ }
929
+ }
930
+
399
931
  throw new Error(`Unknown tool: ${request.params.name}`)
400
932
  })
401
933
  }