@syntholoc/mcp 0.1.2 → 0.3.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/README.md CHANGED
@@ -1,10 +1,55 @@
1
1
  # @syntholoc/mcp
2
2
 
3
3
  The [Syntholoc](https://syntholoc.com) MCP server — manage AI localization and
4
- translation straight from [Claude Code](https://claude.com/claude-code) (or any
5
- MCP client), without leaving your editor.
4
+ translation straight from Codex, Claude Code, or any MCP client without leaving
5
+ your editor.
6
6
 
7
- ## Add to Claude Code
7
+ ## Hosted OAuth
8
+
9
+ Use the hosted Streamable HTTP endpoint when your MCP client supports OAuth.
10
+
11
+ ### Codex
12
+
13
+ Add this to `~/.codex/config.toml` (or project `.codex/config.toml` in a trusted
14
+ project):
15
+
16
+ ```toml
17
+ [mcp_servers.syntholoc]
18
+ url = "https://api.syntholoc.com/mcp/v1"
19
+ ```
20
+
21
+ Then authorize:
22
+
23
+ ```bash
24
+ codex mcp login syntholoc
25
+ ```
26
+
27
+ ### Claude Code
28
+
29
+ ```bash
30
+ claude mcp add --transport http syntholoc https://api.syntholoc.com/mcp/v1
31
+ ```
32
+
33
+ ## Local stdio
34
+
35
+ Use local stdio when you want to run the MCP server as a local process with a
36
+ machine API key.
37
+
38
+ ### Codex
39
+
40
+ Add this to `~/.codex/config.toml` (or project `.codex/config.toml`):
41
+
42
+ ```toml
43
+ [mcp_servers.syntholoc]
44
+ command = "bunx"
45
+ args = ["@syntholoc/mcp"]
46
+
47
+ [mcp_servers.syntholoc.env]
48
+ SYNTHOLOC_API_KEY = "<your-api-key>"
49
+ SYNTHOLOC_API_URL = "https://api.syntholoc.com/graphql"
50
+ ```
51
+
52
+ ### Claude Code
8
53
 
9
54
  ```bash
10
55
  claude mcp add --transport stdio syntholoc \
@@ -18,13 +63,13 @@ never passed as a tool argument.
18
63
 
19
64
  ### Environment variables
20
65
 
21
- | Variable | Default | Description |
22
- |---|---|---|
23
- | `SYNTHOLOC_API_KEY` | – | Machine API key (`x-api-key`). Required for most tools. |
24
- | `SYNTHOLOC_API_URL` | `https://api.syntholoc.com/graphql` | GraphQL endpoint. Override to target a self-hosted or local instance. |
25
- | `SYNTHOLOC_TOOL_SETS` | *(all)* | Comma-separated tool sets to load (`projects,keys,translations,qa,glossary,styleguides,tm,git,providers,jobs,analytics,locales,config`). Fewer tools = a more focused agent. |
26
- | `SYNTHOLOC_READ_ONLY` | `false` | Set `true` to expose only read-only tools (list/get) — safe exploration and onboarding. |
27
- | `SYNTHOLOC_TOOL_FILTER` | *(none)* | Comma-separated wildcard patterns matched against tool names (e.g. `list_*,get_*`). |
66
+ | Variable | Default | Description |
67
+ | ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | `SYNTHOLOC_API_KEY` | – | Machine API key (`x-api-key`). Required for most tools. |
69
+ | `SYNTHOLOC_API_URL` | `https://api.syntholoc.com/graphql` | GraphQL endpoint. Override to target a self-hosted or local instance. |
70
+ | `SYNTHOLOC_TOOL_SETS` | _(all)_ | Comma-separated tool sets to load (`projects,keys,translations,qa,glossary,styleguides,tm,git,providers,jobs,analytics,locales,config`). Fewer tools = a more focused agent. |
71
+ | `SYNTHOLOC_READ_ONLY` | `false` | Set `true` to expose only read-only tools (list/get) — safe exploration and onboarding. |
72
+ | `SYNTHOLOC_TOOL_FILTER` | _(none)_ | Comma-separated wildcard patterns matched against tool names (e.g. `list_*,get_*`). |
28
73
 
29
74
  > The remote (HTTP) transport accepts the same controls as request headers:
30
75
  > `syntholoc-tool-sets`, `syntholoc-read-only`, `syntholoc-tool-filter`. The hosted
@@ -59,10 +104,10 @@ and analytics — plus MCP resources and prompts for common workflows.
59
104
 
60
105
  Typical asks, one sentence each:
61
106
 
62
- - *"Translate the untranslated German strings and wait for it to finish."*
63
- - *"Show project progress and the QA score."*
64
- - *"Add these glossary terms and re-run QA on French."*
65
- - *"Export the Spanish locale."*
107
+ - _"Translate the untranslated German strings and wait for it to finish."_
108
+ - _"Show project progress and the QA score."_
109
+ - _"Add these glossary terms and re-run QA on French."_
110
+ - _"Export the Spanish locale."_
66
111
 
67
112
  Translation runs asynchronously and returns a job id; a `wait_for_job` tool
68
113
  blocks until completion, so you can chain "translate → wait → export" in a
@@ -2,7 +2,7 @@ import {
2
2
  TOOL_SET_NAMES,
3
3
  buildMcpServer,
4
4
  parseList
5
- } from "./chunk-QOVGANRN.js";
5
+ } from "./chunk-GNXMB53B.js";
6
6
  export {
7
7
  TOOL_SET_NAMES,
8
8
  buildMcpServer,
@@ -49,10 +49,15 @@ function registerPrompts(server) {
49
49
  "1. Read `.syntholoc/config.json` for the source file(s) and project.",
50
50
  projectId ? ` (Use project ${projectId} if config is missing.)` : "",
51
51
  "2. From `git diff` on the source file(s), collect added/changed keys and `upsert_keys` (dryRun first to preview).",
52
+ " - MULTI-FILE / namespaced project (a file per namespace, e.g. messages/en/home.json): import each",
53
+ " changed file with `import_source_strings` passing `keyPrefix` = the namespace (the filename",
54
+ ' without extension, e.g. "home"). This keeps overlapping keys from different files distinct.',
52
55
  "3. Call `get_config` for `maxBulkSize`. For EACH target locale check `locale_progress` \u2192 `untranslated`.",
53
56
  " - If untranslated >= maxBulkSize: `translate_locale` (scope UNTRANSLATED) and `wait_for_job`.",
54
57
  " - If below the threshold: tell me the counts and ASK whether to translate now or accumulate.",
55
58
  "4. `export_locale` for each translated locale and write the result back into the mapped local files.",
59
+ " - Namespaced project: call `export_locale` once per namespace with the same `keyPrefix` (the prefix",
60
+ " is stripped on export) and write each result to its file, e.g. messages/{locale}/home.json.",
56
61
  "5. Summarize keys added, locales translated, and the cost from the job usage."
57
62
  ].filter(Boolean).join("\n")
58
63
  )
@@ -83,12 +88,47 @@ function registerPrompts(server) {
83
88
  },
84
89
  () => userMessage(
85
90
  [
86
- "Set up Syntholoc for this repository:",
87
- "1. Find the i18n source files (e.g. messages/en.json, locales/en/*.json) and infer the source locale.",
88
- "2. `create_project`, then `seed_starter_locales` (or `add_locale`) for the target languages.",
89
- "3. `import_source_strings` from the source file(s).",
90
- "4. Write `.syntholoc/config.json` mapping source \u2192 translation file patterns and the project id.",
91
- "5. Offer to run the localize_changed flow next."
91
+ "Set up Syntholoc for this repository. PREFER the Git-sync path \u2014 it pulls every",
92
+ "source string (and any existing translations) in one server-side sync and keeps",
93
+ "the repo in sync going forward. Only fall back to manual imports if the user",
94
+ "declines Git or the code lives outside a supported host.",
95
+ "",
96
+ "1. Find the i18n source files and infer the source locale. Note the LAYOUT:",
97
+ " - Single file per locale (e.g. messages/en.json) \u2192 one mapping, no namespace.",
98
+ " - A directory per locale with many namespace files (e.g. messages/en/home.json, common.json) \u2192",
99
+ " each file is a NAMESPACE. Syntholoc stores a flat key space, so each file is imported under a",
100
+ " `keyPrefix` = its namespace to avoid collisions between files.",
101
+ "2. `create_project`, then `seed_starter_locales` (or `add_locale`) for the target languages. Use valid",
102
+ ' BCP-47 codes (e.g. "zh-CN", not "cn"); `add_locale` accepts `code` or `locale`. Region-ambiguous',
103
+ ' languages MUST be qualified \u2014 "pt" \u2192 "pt-BR" or "pt-PT", "zh" \u2192 "zh-CN" or "zh-TW". The tools reject',
104
+ ' ambiguous codes and return the candidates; if a source filename is just "pt", ask which region (or',
105
+ " default to the likeliest) and record the file\u2192locale mapping in the config `translation` pattern.",
106
+ " (Target locales must exist before a Git pull so their translation files import.)",
107
+ "3. RECOMMENDED \u2014 connect the repo and sync, instead of pushing strings one by one:",
108
+ " a. `list_repositories`. If one is already connected, skip to (d).",
109
+ " b. Otherwise connect it yourself with `connect_repository`. Ask the user which method they prefer:",
110
+ " - GitHub App (no token, recommended): `get_github_app_install_url` \u2192 give the URL to the user",
111
+ " to open and authorize (you cannot click it for them; you may offer to open it via the shell).",
112
+ " Once they confirm, `list_github_installations` \u2192 `list_github_installation_repos` to pick the",
113
+ " repo, then `connect_repository` with that `installationId`.",
114
+ " - Token (any host): ask the user to paste a personal access token (repo read + PR scope), then",
115
+ " `connect_repository` with `token`. Treat the token as a secret \u2014 do not echo it back.",
116
+ " c. Pass `files` = Crowdin-style mappings matching the layout from step 1. NOTE the placeholder",
117
+ " syntax differs from the config file: here use %two_letters_code% / %locale% / %original_file_name%",
118
+ ' (e.g. source "messages/en/home.json", translation "messages/%two_letters_code%/%original_file_name%").',
119
+ " d. `sync_repository` \u2014 imports ALL source strings from the repo at once and opens the translation PR.",
120
+ " Then `pull_translations` to import any translation files already in the repo (choose `conflict`",
121
+ " KEEP_APPROVED to preserve in-app edits).",
122
+ " e. `wait_for_job` on the returned job id, then report keys imported and the PR url.",
123
+ "4. FALLBACK \u2014 file-based import when the user does NOT want Git integration:",
124
+ " - Single file \u2192 `import_source_strings` once.",
125
+ " - Namespaced \u2192 `import_source_strings` once PER FILE with `keyPrefix` set to the namespace",
126
+ " (filename without extension). Keys become e.g. `home.title`, `common.title`.",
127
+ "5. Write `.syntholoc/config.json` (schema https://syntholoc.com/schemas/config.json): the `projectId` and",
128
+ " a Crowdin-style `files` array of `{ source, translation }` patterns with a `{locale}` placeholder,",
129
+ ' e.g. { "source": "messages/en/home.json", "translation": "messages/{locale}/home.json", "namespace": "home" }.',
130
+ ' Do NOT invent fields like `i18nDir`, `format: "nested-dirs"`, or `localeMapping` \u2014 they are not read.',
131
+ "6. Offer to run the localize_changed flow next."
92
132
  ].join("\n")
93
133
  )
94
134
  );
@@ -132,7 +172,10 @@ function registerResources(server, gql) {
132
172
  description: "Organizations you belong to (id, name, slug, your role). Pin one for a request with the `syntholoc-org-id` header; otherwise the earliest org is used.",
133
173
  mimeType: "application/json"
134
174
  },
135
- async (uri) => json(uri, (await gql.request(MY_ORGANIZATIONS)).myProjects.map((g) => ({ ...g.organization, viewerRole: g.viewerRole })))
175
+ async (uri) => json(
176
+ uri,
177
+ (await gql.request(MY_ORGANIZATIONS)).myProjects.map((g) => ({ ...g.organization, viewerRole: g.viewerRole }))
178
+ )
136
179
  );
137
180
  server.registerResource(
138
181
  "projects",
@@ -318,11 +361,23 @@ function registerConfigTools(server, gql) {
318
361
  // src/tools/git.ts
319
362
  import { z as z3 } from "zod";
320
363
  import {
364
+ CONNECT_REPOSITORY,
365
+ DISCONNECT_REPOSITORY,
366
+ GITHUB_APP,
367
+ GITHUB_INSTALLATIONS,
368
+ GITHUB_INSTALLATION_REPOS,
321
369
  LIST_REPOSITORIES,
322
370
  PULL_TRANSLATIONS,
323
371
  PUSH_TRANSLATIONS,
324
- SYNC_REPOSITORY
372
+ SYNC_REPOSITORY,
373
+ UPDATE_REPOSITORY
325
374
  } from "@syntholoc/sdk";
375
+ var fileMapping = z3.object({
376
+ source: z3.string().describe('Source file path or glob, e.g. "messages/en.json" or "locales/en/*.json".'),
377
+ translation: z3.string().describe(
378
+ 'Translation pattern with Crowdin placeholders, e.g. "messages/%two_letters_code%/%original_file_name%" or "locales/%locale%/%original_file_name%".'
379
+ )
380
+ });
326
381
  function registerGitTools(server, gql) {
327
382
  server.registerTool(
328
383
  "list_repositories",
@@ -334,6 +389,92 @@ function registerGitTools(server, gql) {
334
389
  },
335
390
  async ({ projectId }) => jsonResult((await gql.request(LIST_REPOSITORIES, { projectId })).repositories)
336
391
  );
392
+ server.registerTool(
393
+ "get_github_app_install_url",
394
+ {
395
+ title: "GitHub App install URL",
396
+ description: "Returns whether the Syntholoc GitHub App is available and a signed install URL for this project. GitHub App connect flow, step 1: give this URL to the user to open in a browser and authorize Syntholoc on the repositories they want (this authorize click cannot be automated). After they finish, call `list_github_installations` to see the new installation. If `enabled` is false, the server has no GitHub App configured \u2014 fall back to the token (PAT) path via `connect_repository`.",
397
+ inputSchema: { projectId: z3.string() },
398
+ annotations: { readOnlyHint: true, openWorldHint: true }
399
+ },
400
+ async ({ projectId }) => jsonResult((await gql.request(GITHUB_APP, { projectId })).githubApp)
401
+ );
402
+ server.registerTool(
403
+ "list_github_installations",
404
+ {
405
+ title: "List GitHub installations",
406
+ description: "Lists GitHub App installations available to the active organization. GitHub App connect flow, step 2: run this after the user authorized the App (see `get_github_app_install_url`) to get the `installationId` to pass to `list_github_installation_repos` and `connect_repository`.",
407
+ inputSchema: { projectId: z3.string() },
408
+ annotations: { readOnlyHint: true, openWorldHint: true }
409
+ },
410
+ async ({ projectId }) => jsonResult((await gql.request(GITHUB_INSTALLATIONS, { projectId })).githubInstallations)
411
+ );
412
+ server.registerTool(
413
+ "list_github_installation_repos",
414
+ {
415
+ title: "List installation repositories",
416
+ description: "Lists the repositories Syntholoc can access through a GitHub App installation. GitHub App connect flow, step 3: pick the target repo (its `fullName` and `defaultBranch`) before calling `connect_repository` with the same `installationId`.",
417
+ inputSchema: {
418
+ projectId: z3.string(),
419
+ installationId: z3.number().int().describe("From `list_github_installations`.")
420
+ },
421
+ annotations: { readOnlyHint: true, openWorldHint: true }
422
+ },
423
+ async ({ projectId, installationId }) => jsonResult((await gql.request(GITHUB_INSTALLATION_REPOS, { projectId, installationId })).githubInstallationRepos)
424
+ );
425
+ server.registerTool(
426
+ "connect_repository",
427
+ {
428
+ title: "Connect repository",
429
+ description: "Connects a git repository to the project so syncs can pull source strings and push translation PRs. Provide EITHER `installationId` (GitHub App mode \u2014 after the browser authorize flow: `get_github_app_install_url` \u2192 `list_github_installations` \u2192 `list_github_installation_repos`) OR `token` (PAT mode \u2014 a personal access token with repo read + pull-request scope; works for GitHub, GitLab and Bitbucket, no browser needed). Set `files` to Crowdin-style mappings that match the project layout. After connecting, call `sync_repository` to import all source strings at once.",
430
+ inputSchema: {
431
+ projectId: z3.string(),
432
+ provider: z3.enum(["GITHUB", "GITLAB", "BITBUCKET"]).default("GITHUB"),
433
+ fullName: z3.string().describe('owner/repo, e.g. "acme/webapp".'),
434
+ branch: z3.string().optional().describe('Source branch (default "main").'),
435
+ files: z3.array(fileMapping).optional().describe("Crowdin-style source\u2192translation mappings."),
436
+ paths: z3.array(z3.string()).optional().describe("Simple mode: source file paths/globs to track (use instead of `files`)."),
437
+ installationId: z3.number().int().optional().describe("GitHub App installation id. Mutually exclusive with `token`."),
438
+ token: z3.string().optional().describe("Access token (PAT mode), stored encrypted. Mutually exclusive with `installationId`."),
439
+ translationBranch: z3.string().optional().describe("Branch translations are committed to (default l10n_<branch>)."),
440
+ prTargetBranch: z3.string().optional().describe("Branch translation PRs target (default = branch)."),
441
+ autoSyncEnabled: z3.boolean().optional().describe("Enable scheduled auto-sync.")
442
+ },
443
+ annotations: { openWorldHint: true }
444
+ },
445
+ async ({ projectId, ...input }) => jsonResult((await gql.request(CONNECT_REPOSITORY, { projectId, input })).connectRepository)
446
+ );
447
+ server.registerTool(
448
+ "update_repository",
449
+ {
450
+ title: "Update repository",
451
+ description: "Updates the configuration of an already-connected repository in place \u2014 no disconnect/reconnect, stored credentials and webhooks are preserved. Use this to fix or change file mappings (`files` or `paths`), the source `branch`, the translation/PR branches, or auto-sync settings. Only the fields you pass are changed. After changing mappings, call `sync_repository` to re-import source strings.",
452
+ inputSchema: {
453
+ projectId: z3.string(),
454
+ repositoryId: z3.string(),
455
+ branch: z3.string().optional().describe("Source branch."),
456
+ files: z3.array(fileMapping).optional().describe("Crowdin-style source\u2192translation mappings (replaces existing)."),
457
+ paths: z3.array(z3.string()).optional().describe("Simple mode: source file paths/globs to track (replaces existing; use instead of `files`)."),
458
+ translationBranch: z3.string().optional().describe("Branch translations are committed to."),
459
+ prTargetBranch: z3.string().optional().describe("Branch translation PRs target."),
460
+ pushSources: z3.boolean().optional().describe("Rewrite the source file(s) in the repo from in-app keys."),
461
+ autoSyncEnabled: z3.boolean().optional().describe("Enable scheduled auto-sync."),
462
+ syncIntervalHours: z3.number().int().optional().describe("Auto-sync interval in hours.")
463
+ },
464
+ annotations: { openWorldHint: true }
465
+ },
466
+ async ({ projectId, repositoryId, ...input }) => jsonResult((await gql.request(UPDATE_REPOSITORY, { projectId, id: repositoryId, input })).updateRepository)
467
+ );
468
+ server.registerTool(
469
+ "disconnect_repository",
470
+ {
471
+ title: "Disconnect repository",
472
+ description: "Removes the link to a connected repository and its stored credentials. The repository itself is not affected. Destructive \u2014 confirm with the user first.",
473
+ inputSchema: { projectId: z3.string(), repositoryId: z3.string() },
474
+ annotations: { destructiveHint: true, openWorldHint: true }
475
+ },
476
+ async ({ projectId, repositoryId }) => jsonResult((await gql.request(DISCONNECT_REPOSITORY, { projectId, id: repositoryId })).disconnectRepository)
477
+ );
337
478
  server.registerTool(
338
479
  "sync_repository",
339
480
  {
@@ -505,7 +646,7 @@ import {
505
646
  UPDATE_KEY,
506
647
  UPSERT_KEYS
507
648
  } from "@syntholoc/sdk";
508
- var FORMATS = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS"];
649
+ var FORMATS = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS", "NJK"];
509
650
  function registerKeyTools(server, gql) {
510
651
  server.registerTool(
511
652
  "list_keys",
@@ -575,17 +716,24 @@ function registerKeyTools(server, gql) {
575
716
  "import_source_strings",
576
717
  {
577
718
  title: "Import source strings",
578
- description: "Imports source strings from a file (paste the raw content) and returns the diff summary. Use `dryRun` to preview. For a structured delta prefer upsert_keys.",
719
+ description: 'Imports source strings from ONE file (paste the raw content) and returns the diff summary. For initial setup of a repo-hosted project, PREFER connecting the repo and running `sync_repository` \u2014 it imports every source file server-side in one pass; use this tool only for file-based projects with no Git integration, or for one-off single-file imports. For a multi-file / namespaced project (a file per namespace, e.g. messages/en/home.json), pass `keyPrefix` (the namespace, e.g. "home") so keys become `home.title` and files with overlapping top-level keys do not collide \u2014 call once per file. Use `dryRun` to preview.',
579
720
  inputSchema: {
580
721
  projectId: z6.string(),
581
722
  content: z6.string(),
582
723
  format: z6.enum(FORMATS),
724
+ keyPrefix: z6.string().optional().describe('Namespace prefix, e.g. "home" (per-file, multi-file projects).'),
725
+ sourceFile: z6.string().optional().describe(
726
+ 'Real source file path, e.g. "messages/en/home.json" \u2014 stored for the file tree + reliable export.'
727
+ ),
583
728
  dryRun: z6.boolean().default(false)
584
729
  },
585
730
  annotations: { idempotentHint: true, openWorldHint: true }
586
731
  },
587
- async ({ projectId, content, format, dryRun }) => jsonResult(
588
- (await gql.request(IMPORT_SOURCE_STRINGS, { projectId, input: { content, format, dryRun } })).importSourceStrings
732
+ async ({ projectId, content, format, keyPrefix, sourceFile, dryRun }) => jsonResult(
733
+ (await gql.request(IMPORT_SOURCE_STRINGS, {
734
+ projectId,
735
+ input: { content, format, keyPrefix, sourceFile, dryRun }
736
+ })).importSourceStrings
589
737
  )
590
738
  );
591
739
  }
@@ -593,6 +741,158 @@ function registerKeyTools(server, gql) {
593
741
  // src/tools/locales.ts
594
742
  import { z as z7 } from "zod";
595
743
  import { ADD_LOCALE, LIST_LOCALES as LIST_LOCALES2, REMOVE_LOCALE, SEED_STARTER_LOCALES } from "@syntholoc/sdk";
744
+
745
+ // ../../packages/shared/src/locales.ts
746
+ var STARTER_LOCALES = [
747
+ { code: "ar", name: "Arabic", nativeName: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629", rtl: true },
748
+ { code: "az", name: "Azerbaijani", nativeName: "Az\u0259rbaycan" },
749
+ { code: "be", name: "Belarusian", nativeName: "\u0411\u0435\u043B\u0430\u0440\u0443\u0441\u043A\u0430\u044F" },
750
+ { code: "bn", name: "Bengali", nativeName: "\u09AC\u09BE\u0982\u09B2\u09BE" },
751
+ { code: "cs", name: "Czech", nativeName: "\u010Ce\u0161tina" },
752
+ { code: "de", name: "German", nativeName: "Deutsch" },
753
+ { code: "el", name: "Greek", nativeName: "\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC" },
754
+ { code: "en", name: "English", nativeName: "English" },
755
+ { code: "es", name: "Spanish", nativeName: "Espa\xF1ol" },
756
+ { code: "es-419", name: "Spanish (Latin America)", nativeName: "Espa\xF1ol (Latinoam\xE9rica)" },
757
+ { code: "fa", name: "Persian", nativeName: "\u0641\u0627\u0631\u0633\u06CC", rtl: true },
758
+ { code: "fr", name: "French", nativeName: "Fran\xE7ais" },
759
+ { code: "hi", name: "Hindi", nativeName: "\u0939\u093F\u0928\u094D\u0926\u0940" },
760
+ { code: "hy", name: "Armenian", nativeName: "\u0540\u0561\u0575\u0565\u0580\u0565\u0576" },
761
+ { code: "id", name: "Indonesian", nativeName: "Bahasa Indonesia" },
762
+ { code: "it", name: "Italian", nativeName: "Italiano" },
763
+ { code: "ja", name: "Japanese", nativeName: "\u65E5\u672C\u8A9E" },
764
+ { code: "ka", name: "Georgian", nativeName: "\u10E5\u10D0\u10E0\u10D7\u10E3\u10DA\u10D8" },
765
+ { code: "kk", name: "Kazakh", nativeName: "\u049A\u0430\u0437\u0430\u049B \u0442\u0456\u043B\u0456" },
766
+ { code: "ko", name: "Korean", nativeName: "\uD55C\uAD6D\uC5B4" },
767
+ { code: "lv", name: "Latvian", nativeName: "Latvie\u0161u" },
768
+ { code: "ms", name: "Malay", nativeName: "Bahasa Melayu" },
769
+ { code: "nl", name: "Dutch", nativeName: "Nederlands" },
770
+ { code: "pl", name: "Polish", nativeName: "Polski" },
771
+ { code: "pt-BR", name: "Portuguese (Brazil)", nativeName: "Portugu\xEAs (Brasil)" },
772
+ { code: "pt-PT", name: "Portuguese (Portugal)", nativeName: "Portugu\xEAs (Portugal)" },
773
+ { code: "ro", name: "Romanian", nativeName: "Rom\xE2n\u0103" },
774
+ { code: "ru", name: "Russian", nativeName: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439" },
775
+ { code: "sk", name: "Slovak", nativeName: "Sloven\u010Dina" },
776
+ { code: "sl", name: "Slovenian", nativeName: "Sloven\u0161\u010Dina" },
777
+ { code: "sw", name: "Swahili", nativeName: "Kiswahili" },
778
+ { code: "th", name: "Thai", nativeName: "\u0E44\u0E17\u0E22" },
779
+ { code: "tl", name: "Filipino", nativeName: "Filipino" },
780
+ { code: "tr", name: "Turkish", nativeName: "T\xFCrk\xE7e" },
781
+ { code: "uk", name: "Ukrainian", nativeName: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
782
+ { code: "ur", name: "Urdu", nativeName: "\u0627\u0631\u062F\u0648", rtl: true },
783
+ { code: "uz", name: "Uzbek", nativeName: "O\u02BBzbek" },
784
+ { code: "vi", name: "Vietnamese", nativeName: "Ti\u1EBFng Vi\u1EC7t" },
785
+ { code: "zh-CN", name: "Chinese (Simplified)", nativeName: "\u7B80\u4F53\u4E2D\u6587" },
786
+ { code: "zh-TW", name: "Chinese (Traditional)", nativeName: "\u7E41\u9AD4\u4E2D\u6587" }
787
+ ];
788
+ var STARTER_LOCALE_CODES = STARTER_LOCALES.map((l) => l.code);
789
+ var STARTER_BY_CODE = new Map(STARTER_LOCALES.map((l) => [l.code, l]));
790
+ function findStarterLocale(code) {
791
+ return STARTER_BY_CODE.get(code);
792
+ }
793
+
794
+ // ../../packages/shared/src/locale-resolve.ts
795
+ var ALIASES = {
796
+ cn: "zh-CN",
797
+ "zh-hans": "zh-CN",
798
+ "zh-hant": "zh-TW",
799
+ jp: "ja",
800
+ kr: "ko",
801
+ ua: "uk",
802
+ cz: "cs",
803
+ gr: "el",
804
+ iw: "he",
805
+ // legacy Hebrew
806
+ in: "id"
807
+ // legacy Indonesian
808
+ };
809
+ var VARIANTS_BY_LANG = (() => {
810
+ const map = /* @__PURE__ */ new Map();
811
+ for (const { code } of STARTER_LOCALES) {
812
+ const lang = code.split("-")[0].toLowerCase();
813
+ (map.get(lang) ?? map.set(lang, []).get(lang)).push(code);
814
+ }
815
+ return map;
816
+ })();
817
+ function canonicalize(input) {
818
+ try {
819
+ return Intl.getCanonicalLocales(input)[0] ?? null;
820
+ } catch {
821
+ return null;
822
+ }
823
+ }
824
+ function isRealLanguage(code) {
825
+ try {
826
+ const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code);
827
+ return !!name && name.toLowerCase() !== code.toLowerCase();
828
+ } catch {
829
+ return false;
830
+ }
831
+ }
832
+ function resolveLocaleCode(raw) {
833
+ const input = raw.trim();
834
+ if (!input) {
835
+ return { status: "unknown", input: raw, message: "Empty locale code." };
836
+ }
837
+ const alias = ALIASES[input.toLowerCase()];
838
+ if (alias) {
839
+ return { status: "normalized", code: alias, from: input, reason: `mapped "${input}" to catalog code "${alias}"` };
840
+ }
841
+ const canonical = canonicalize(input);
842
+ if (!canonical) {
843
+ return { status: "unknown", input, message: `"${input}" is not a valid BCP-47 locale code.` };
844
+ }
845
+ if (findStarterLocale(canonical)) {
846
+ return canonical === input ? { status: "ok", code: canonical } : { status: "normalized", code: canonical, from: input, reason: `casing normalized to "${canonical}"` };
847
+ }
848
+ const lang = canonical.split("-")[0].toLowerCase();
849
+ const hasRegionOrScript = canonical.includes("-");
850
+ const variants = VARIANTS_BY_LANG.get(lang) ?? [];
851
+ if (!hasRegionOrScript) {
852
+ if (variants.length > 1) {
853
+ return {
854
+ status: "ambiguous",
855
+ input,
856
+ candidates: variants,
857
+ message: `"${input}" is ambiguous \u2014 the catalog has ${variants.join(", ")}. Pick one.`
858
+ };
859
+ }
860
+ if (variants.length === 1) {
861
+ return {
862
+ status: "normalized",
863
+ code: variants[0],
864
+ from: input,
865
+ reason: `expanded "${input}" to "${variants[0]}"`
866
+ };
867
+ }
868
+ return isRealLanguage(canonical) ? { status: "ok", code: canonical } : { status: "unknown", input, message: `"${input}" is not a recognized language.` };
869
+ }
870
+ if (!isRealLanguage(canonical)) {
871
+ return { status: "unknown", input, message: `"${input}" is not a valid locale.` };
872
+ }
873
+ return canonical === input ? { status: "ok", code: canonical } : { status: "normalized", code: canonical, from: input, reason: `canonicalized to "${canonical}"` };
874
+ }
875
+ function resolveLocaleCodes(raw) {
876
+ const out = { codes: [], normalized: [], rejected: [] };
877
+ const seen = /* @__PURE__ */ new Set();
878
+ for (const code of raw) {
879
+ const res = resolveLocaleCode(code);
880
+ if (res.status === "ambiguous") {
881
+ out.rejected.push({ input: res.input, message: res.message, candidates: res.candidates });
882
+ } else if (res.status === "unknown") {
883
+ out.rejected.push({ input: res.input, message: res.message });
884
+ } else {
885
+ if (res.status === "normalized") out.normalized.push({ from: res.from, code: res.code, reason: res.reason });
886
+ if (!seen.has(res.code)) {
887
+ seen.add(res.code);
888
+ out.codes.push(res.code);
889
+ }
890
+ }
891
+ }
892
+ return out;
893
+ }
894
+
895
+ // src/tools/locales.ts
596
896
  function registerLocaleTools(server, gql) {
597
897
  server.registerTool(
598
898
  "list_locales",
@@ -608,10 +908,13 @@ function registerLocaleTools(server, gql) {
608
908
  "add_locale",
609
909
  {
610
910
  title: "Add locale",
611
- description: "Adds a target locale (BCP-47 code) to a project.",
911
+ description: 'Adds ONE target locale to a project. To add several at once, prefer seed_starter_locales (one call for the whole list). Codes are validated: aliases/casing are normalized (e.g. "cn" \u2192 "zh-CN"), and ambiguous macro-languages (e.g. "pt", "zh") are rejected with the concrete variants to choose from (pt-BR / pt-PT, zh-CN / zh-TW).',
612
912
  inputSchema: {
613
913
  projectId: z7.string(),
614
- code: z7.string().describe('BCP-47 code, e.g. "de-DE"'),
914
+ // `code` and `locale` are aliases — accept either so the natural
915
+ // `locale: "de-DE"` form works as well as `code`.
916
+ code: z7.string().optional().describe('BCP-47 code, e.g. "de-DE" or "ru". Alias: `locale`.'),
917
+ locale: z7.string().optional().describe("Alias for `code`."),
615
918
  name: z7.string().optional(),
616
919
  formality: z7.string().optional(),
617
920
  gender: z7.string().optional(),
@@ -619,17 +922,55 @@ function registerLocaleTools(server, gql) {
619
922
  },
620
923
  annotations: { openWorldHint: true }
621
924
  },
622
- async ({ projectId, ...input }) => jsonResult((await gql.request(ADD_LOCALE, { projectId, input })).addLocale)
925
+ async ({ projectId, code, locale, name, formality, gender, variant }) => {
926
+ const resolvedCode = code ?? locale;
927
+ if (!resolvedCode) {
928
+ return jsonResult({
929
+ error: 'Missing locale code. Pass `code` (BCP-47, e.g. "de-DE" or "ru"). To add several locales at once, use seed_starter_locales with a `codes` array.'
930
+ });
931
+ }
932
+ const res = resolveLocaleCode(resolvedCode);
933
+ if (res.status === "ambiguous") {
934
+ return jsonResult({
935
+ error: res.message,
936
+ hint: `Retry add_locale with one of: ${res.candidates.join(", ")}.`,
937
+ candidates: res.candidates
938
+ });
939
+ }
940
+ if (res.status === "unknown") {
941
+ return jsonResult({ error: res.message, hint: 'Use a valid BCP-47 code, e.g. "de-DE" or "zh-CN".' });
942
+ }
943
+ const input = { code: res.code, name, formality, gender, variant };
944
+ const added = (await gql.request(ADD_LOCALE, { projectId, input })).addLocale;
945
+ return res.status === "normalized" ? jsonResult({ ...added, note: `Interpreted "${res.from}" as "${res.code}" (${res.reason}).` }) : jsonResult(added);
946
+ }
623
947
  );
624
948
  server.registerTool(
625
949
  "seed_starter_locales",
626
950
  {
627
951
  title: "Seed starter locales",
628
- description: "Adds a set of common starter locales to a project (omit codes for the defaults).",
629
- inputSchema: { projectId: z7.string(), codes: z7.array(z7.string()).optional() },
952
+ description: 'Adds MULTIPLE target locales in one call \u2014 pass `codes` (BCP-47, e.g. ["ru","de-DE","fr"]), or omit for a common starter set. Prefer this over repeated add_locale calls. Codes are validated as a batch: if any are ambiguous (e.g. "pt", "zh") or invalid, NONE are added and the response lists what to fix; aliases like "cn" are normalized to "zh-CN".',
953
+ inputSchema: {
954
+ projectId: z7.string(),
955
+ codes: z7.array(z7.string()).optional().describe('BCP-47 codes to add, e.g. ["ru","de-DE","fr"].')
956
+ },
630
957
  annotations: { openWorldHint: true }
631
958
  },
632
- async ({ projectId, codes }) => jsonResult((await gql.request(SEED_STARTER_LOCALES, { projectId, codes })).seedStarterLocales)
959
+ async ({ projectId, codes }) => {
960
+ if (codes && codes.length > 0) {
961
+ const resolved = resolveLocaleCodes(codes);
962
+ if (resolved.rejected.length > 0) {
963
+ return jsonResult({
964
+ error: "Some locale codes are ambiguous or invalid; nothing was added.",
965
+ hint: "Fix the codes below and retry. For ambiguous ones, pick a listed candidate.",
966
+ rejected: resolved.rejected
967
+ });
968
+ }
969
+ const seeded = (await gql.request(SEED_STARTER_LOCALES, { projectId, codes: resolved.codes })).seedStarterLocales;
970
+ return resolved.normalized.length > 0 ? jsonResult({ locales: seeded, normalized: resolved.normalized }) : jsonResult(seeded);
971
+ }
972
+ return jsonResult((await gql.request(SEED_STARTER_LOCALES, { projectId, codes })).seedStarterLocales);
973
+ }
633
974
  );
634
975
  server.registerTool(
635
976
  "remove_locale",
@@ -906,7 +1247,7 @@ import {
906
1247
  TRANSLATE_KEY,
907
1248
  TRANSLATE_LOCALE
908
1249
  } from "@syntholoc/sdk";
909
- var FORMATS2 = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS"];
1250
+ var FORMATS2 = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS", "NJK"];
910
1251
  function registerTranslationTools(server, gql) {
911
1252
  server.registerTool(
912
1253
  "translate_locale",
@@ -989,12 +1330,14 @@ function registerTranslationTools(server, gql) {
989
1330
  "export_locale",
990
1331
  {
991
1332
  title: "Export locale",
992
- description: "Exports a locale to a file string in the chosen format \u2014 the basis for writing translations back into local files. Set `onlyApproved` to exclude drafts.",
1333
+ description: 'Exports a locale to a file string in the chosen format \u2014 the basis for writing translations back into local files. Set `onlyApproved` to exclude drafts. For a multi-file project prefer `sourceFile` (the real path, e.g. "messages/en/home.json") to export exactly that file with file-local key names \u2014 reliable. `keyPrefix` is the legacy name-prefix heuristic.',
993
1334
  inputSchema: {
994
1335
  projectId: z13.string(),
995
1336
  localeId: z13.string(),
996
1337
  format: z13.enum(FORMATS2).default("JSON"),
997
- onlyApproved: z13.boolean().optional()
1338
+ onlyApproved: z13.boolean().optional(),
1339
+ sourceFile: z13.string().optional().describe('Real source file path to export, e.g. "messages/en/home.json" (reliable, preferred).'),
1340
+ keyPrefix: z13.string().optional().describe('Legacy namespace prefix, e.g. "home".')
998
1341
  },
999
1342
  annotations: { readOnlyHint: true, openWorldHint: true }
1000
1343
  },
@@ -1004,23 +1347,24 @@ function registerTranslationTools(server, gql) {
1004
1347
  "import_translations",
1005
1348
  {
1006
1349
  title: "Import translations",
1007
- description: "Imports ready-made translations for a locale (paste file content). `autoApprove` lands them as APPROVED; `conflict` is OVERWRITE | SKIP_EXISTING | KEEP_APPROVED; `dryRun` previews.",
1350
+ description: "Imports ready-made translations for a locale (paste file content). `autoApprove` lands them as APPROVED; `conflict` is OVERWRITE | SKIP_EXISTING | KEEP_APPROVED; `dryRun` previews. For a multi-file / namespaced project pass `keyPrefix` (the namespace) to match prefixed source keys.",
1008
1351
  inputSchema: {
1009
1352
  projectId: z13.string(),
1010
1353
  localeId: z13.string(),
1011
1354
  content: z13.string(),
1012
1355
  format: z13.enum(FORMATS2),
1356
+ keyPrefix: z13.string().optional().describe('Namespace prefix, e.g. "home" (per-file, multi-file projects).'),
1013
1357
  autoApprove: z13.boolean().default(false),
1014
1358
  conflict: z13.enum(["OVERWRITE", "SKIP_EXISTING", "KEEP_APPROVED"]).default("OVERWRITE"),
1015
1359
  dryRun: z13.boolean().default(false)
1016
1360
  },
1017
1361
  annotations: { idempotentHint: true, openWorldHint: true }
1018
1362
  },
1019
- async ({ projectId, localeId, content, format, autoApprove, conflict, dryRun }) => jsonResult(
1363
+ async ({ projectId, localeId, content, format, keyPrefix, autoApprove, conflict, dryRun }) => jsonResult(
1020
1364
  (await gql.request(IMPORT_TRANSLATIONS, {
1021
1365
  projectId,
1022
1366
  localeId,
1023
- input: { content, format, autoApprove, conflict, dryRun }
1367
+ input: { content, format, keyPrefix, autoApprove, conflict, dryRun }
1024
1368
  })).importTranslations
1025
1369
  )
1026
1370
  );
@@ -1054,9 +1398,12 @@ function registerAllTools(server, gql, opts = {}) {
1054
1398
  }
1055
1399
  }
1056
1400
 
1401
+ // package.json
1402
+ var version = "0.3.0";
1403
+
1057
1404
  // src/build-server.ts
1058
1405
  function buildMcpServer(gql, opts = {}) {
1059
- const server = new McpServer({ name: "syntholoc", version: "0.1.0" });
1406
+ const server = new McpServer({ name: "syntholoc", version });
1060
1407
  registerAllTools(server, gql, opts);
1061
1408
  registerResources(server, gql);
1062
1409
  registerPrompts(server);
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  buildMcpServer,
4
4
  parseList
5
- } from "./chunk-QOVGANRN.js";
5
+ } from "./chunk-GNXMB53B.js";
6
6
 
7
7
  // src/index.ts
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@syntholoc/mcp",
3
- "version": "0.1.2",
4
- "description": "Syntholoc MCP server — manage AI localization and translation from Claude Code.",
3
+ "version": "0.3.0",
4
+ "description": "Syntholoc MCP server — manage AI localization and translation from Codex, Claude Code, and other MCP clients.",
5
5
  "homepage": "https://syntholoc.com",
6
6
  "keywords": [
7
7
  "syntholoc",
@@ -10,6 +10,7 @@
10
10
  "translation",
11
11
  "mcp",
12
12
  "model-context-protocol",
13
+ "codex",
13
14
  "claude-code"
14
15
  ],
15
16
  "type": "module",
@@ -46,12 +47,14 @@
46
47
  },
47
48
  "dependencies": {
48
49
  "@modelcontextprotocol/sdk": "1.22.0",
49
- "@syntholoc/sdk": "0.1.1",
50
+ "@syntholoc/sdk": "0.2.0",
50
51
  "zod": "3.25.76"
51
52
  },
52
53
  "devDependencies": {
54
+ "@syntholoc/shared": "0.1.0",
53
55
  "@types/bun": "1.3.14",
54
56
  "@types/node": "22.19.21",
57
+ "graphql": "16.14.2",
55
58
  "tsup": "8.5.1",
56
59
  "typescript": "6.0.3"
57
60
  }