@syntholoc/mcp 0.2.0 → 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-DZKWHV2O.js";
5
+ } from "./chunk-GNXMB53B.js";
6
6
  export {
7
7
  TOOL_SET_NAMES,
8
8
  buildMcpServer,
@@ -88,23 +88,47 @@ function registerPrompts(server) {
88
88
  },
89
89
  () => userMessage(
90
90
  [
91
- "Set up Syntholoc for this repository:",
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
+ "",
92
96
  "1. Find the i18n source files and infer the source locale. Note the LAYOUT:",
93
97
  " - Single file per locale (e.g. messages/en.json) \u2192 one mapping, no namespace.",
94
98
  " - A directory per locale with many namespace files (e.g. messages/en/home.json, common.json) \u2192",
95
- " each file is a NAMESPACE. Syntholoc stores a flat key space, so import each file under a",
99
+ " each file is a NAMESPACE. Syntholoc stores a flat key space, so each file is imported under a",
96
100
  " `keyPrefix` = its namespace to avoid collisions between files.",
97
101
  "2. `create_project`, then `seed_starter_locales` (or `add_locale`) for the target languages. Use valid",
98
- ' BCP-47 codes (e.g. "zh-CN", not "cn"); `add_locale` accepts `code` or `locale`.',
99
- "3. Import the source strings:",
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:",
100
124
  " - Single file \u2192 `import_source_strings` once.",
101
125
  " - Namespaced \u2192 `import_source_strings` once PER FILE with `keyPrefix` set to the namespace",
102
126
  " (filename without extension). Keys become e.g. `home.title`, `common.title`.",
103
- "4. Write `.syntholoc/config.json` (schema https://syntholoc.com/schemas/config.json): the `projectId` and",
127
+ "5. Write `.syntholoc/config.json` (schema https://syntholoc.com/schemas/config.json): the `projectId` and",
104
128
  " a Crowdin-style `files` array of `{ source, translation }` patterns with a `{locale}` placeholder,",
105
129
  ' e.g. { "source": "messages/en/home.json", "translation": "messages/{locale}/home.json", "namespace": "home" }.',
106
130
  ' Do NOT invent fields like `i18nDir`, `format: "nested-dirs"`, or `localeMapping` \u2014 they are not read.',
107
- "5. Offer to run the localize_changed flow next."
131
+ "6. Offer to run the localize_changed flow next."
108
132
  ].join("\n")
109
133
  )
110
134
  );
@@ -148,7 +172,10 @@ function registerResources(server, gql) {
148
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.",
149
173
  mimeType: "application/json"
150
174
  },
151
- 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
+ )
152
179
  );
153
180
  server.registerResource(
154
181
  "projects",
@@ -334,11 +361,23 @@ function registerConfigTools(server, gql) {
334
361
  // src/tools/git.ts
335
362
  import { z as z3 } from "zod";
336
363
  import {
364
+ CONNECT_REPOSITORY,
365
+ DISCONNECT_REPOSITORY,
366
+ GITHUB_APP,
367
+ GITHUB_INSTALLATIONS,
368
+ GITHUB_INSTALLATION_REPOS,
337
369
  LIST_REPOSITORIES,
338
370
  PULL_TRANSLATIONS,
339
371
  PUSH_TRANSLATIONS,
340
- SYNC_REPOSITORY
372
+ SYNC_REPOSITORY,
373
+ UPDATE_REPOSITORY
341
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
+ });
342
381
  function registerGitTools(server, gql) {
343
382
  server.registerTool(
344
383
  "list_repositories",
@@ -350,6 +389,92 @@ function registerGitTools(server, gql) {
350
389
  },
351
390
  async ({ projectId }) => jsonResult((await gql.request(LIST_REPOSITORIES, { projectId })).repositories)
352
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
+ );
353
478
  server.registerTool(
354
479
  "sync_repository",
355
480
  {
@@ -521,7 +646,7 @@ import {
521
646
  UPDATE_KEY,
522
647
  UPSERT_KEYS
523
648
  } from "@syntholoc/sdk";
524
- var FORMATS = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS"];
649
+ var FORMATS = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS", "NJK"];
525
650
  function registerKeyTools(server, gql) {
526
651
  server.registerTool(
527
652
  "list_keys",
@@ -591,13 +716,15 @@ function registerKeyTools(server, gql) {
591
716
  "import_source_strings",
592
717
  {
593
718
  title: "Import source strings",
594
- description: 'Imports source strings from ONE file (paste the raw content) and returns the diff summary. 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.',
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.',
595
720
  inputSchema: {
596
721
  projectId: z6.string(),
597
722
  content: z6.string(),
598
723
  format: z6.enum(FORMATS),
599
724
  keyPrefix: z6.string().optional().describe('Namespace prefix, e.g. "home" (per-file, multi-file projects).'),
600
- sourceFile: z6.string().optional().describe('Real source file path, e.g. "messages/en/home.json" \u2014 stored for the file tree + reliable export.'),
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
+ ),
601
728
  dryRun: z6.boolean().default(false)
602
729
  },
603
730
  annotations: { idempotentHint: true, openWorldHint: true }
@@ -614,6 +741,158 @@ function registerKeyTools(server, gql) {
614
741
  // src/tools/locales.ts
615
742
  import { z as z7 } from "zod";
616
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
617
896
  function registerLocaleTools(server, gql) {
618
897
  server.registerTool(
619
898
  "list_locales",
@@ -629,7 +908,7 @@ function registerLocaleTools(server, gql) {
629
908
  "add_locale",
630
909
  {
631
910
  title: "Add locale",
632
- description: "Adds ONE target locale to a project. To add several at once, prefer seed_starter_locales (one call for the whole list).",
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).',
633
912
  inputSchema: {
634
913
  projectId: z7.string(),
635
914
  // `code` and `locale` are aliases — accept either so the natural
@@ -650,22 +929,48 @@ function registerLocaleTools(server, gql) {
650
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.'
651
930
  });
652
931
  }
653
- const input = { code: resolvedCode, name, formality, gender, variant };
654
- return jsonResult((await gql.request(ADD_LOCALE, { projectId, input })).addLocale);
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);
655
946
  }
656
947
  );
657
948
  server.registerTool(
658
949
  "seed_starter_locales",
659
950
  {
660
951
  title: "Seed starter locales",
661
- 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.',
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".',
662
953
  inputSchema: {
663
954
  projectId: z7.string(),
664
955
  codes: z7.array(z7.string()).optional().describe('BCP-47 codes to add, e.g. ["ru","de-DE","fr"].')
665
956
  },
666
957
  annotations: { openWorldHint: true }
667
958
  },
668
- 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
+ }
669
974
  );
670
975
  server.registerTool(
671
976
  "remove_locale",
@@ -942,7 +1247,7 @@ import {
942
1247
  TRANSLATE_KEY,
943
1248
  TRANSLATE_LOCALE
944
1249
  } from "@syntholoc/sdk";
945
- var FORMATS2 = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS"];
1250
+ var FORMATS2 = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS", "NJK"];
946
1251
  function registerTranslationTools(server, gql) {
947
1252
  server.registerTool(
948
1253
  "translate_locale",
@@ -1093,9 +1398,12 @@ function registerAllTools(server, gql, opts = {}) {
1093
1398
  }
1094
1399
  }
1095
1400
 
1401
+ // package.json
1402
+ var version = "0.3.0";
1403
+
1096
1404
  // src/build-server.ts
1097
1405
  function buildMcpServer(gql, opts = {}) {
1098
- const server = new McpServer({ name: "syntholoc", version: "0.1.0" });
1406
+ const server = new McpServer({ name: "syntholoc", version });
1099
1407
  registerAllTools(server, gql, opts);
1100
1408
  registerResources(server, gql);
1101
1409
  registerPrompts(server);
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  buildMcpServer,
4
4
  parseList
5
- } from "./chunk-DZKWHV2O.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.2.0",
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
  }