@syntholoc/mcp 0.2.0 → 0.3.1
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 +59 -14
- package/dist/build-server.js +1 -1
- package/dist/{chunk-DZKWHV2O.js → chunk-MUY33R6O.js} +336 -20
- package/dist/index.js +1 -1
- package/package.json +6 -3
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
|
|
5
|
-
|
|
4
|
+
translation straight from Codex, Claude Code, or any MCP client without leaving
|
|
5
|
+
your editor.
|
|
6
6
|
|
|
7
|
-
##
|
|
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
|
|
22
|
-
|
|
23
|
-
| `SYNTHOLOC_API_KEY`
|
|
24
|
-
| `SYNTHOLOC_API_URL`
|
|
25
|
-
| `SYNTHOLOC_TOOL_SETS`
|
|
26
|
-
| `SYNTHOLOC_READ_ONLY`
|
|
27
|
-
| `SYNTHOLOC_TOOL_FILTER` |
|
|
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
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
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
|
package/dist/build-server.js
CHANGED
|
@@ -88,22 +88,51 @@ 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
|
|
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
|
-
"
|
|
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 (e).",
|
|
109
|
+
" b. Write `.syntholoc/config.json` FIRST and commit it to the source branch \u2014 the server reads it",
|
|
110
|
+
" from the repo as the SOURCE OF TRUTH for mappings on every sync/pull/write-back (Crowdin-style),",
|
|
111
|
+
" so it must land in the branch before the first sync. Schema (https://syntholoc.com/schemas/config.json):",
|
|
112
|
+
" the `projectId`, `sourceLocale`, and a Crowdin-style `files` array of `{ source, translation }`",
|
|
113
|
+
' patterns, e.g. { "source": "messages/en/*.json", "translation": "messages/{locale}/%original_file_name%" }.',
|
|
114
|
+
" - Placeholders: `{locale}` (or %locale% / %two_letters_code% / %original_file_name% / %original_path%).",
|
|
115
|
+
` - If a locale's ON-DISK code differs from its BCP-47 code (e.g. "zh-CN" stored as "cn", "pt-BR" as "pt"),`,
|
|
116
|
+
' add `languagesMapping`, e.g. { "two_letters_code": { "zh-CN": "cn", "pt-BR": "pt" } }.',
|
|
117
|
+
' - Do NOT invent fields like `i18nDir`, `format: "nested-dirs"`, or `localeMapping` \u2014 they are not read.',
|
|
118
|
+
" c. Connect it with `connect_repository`. Ask the user which method they prefer:",
|
|
119
|
+
" - GitHub App (no token, recommended): `get_github_app_install_url` \u2192 give the URL to the user",
|
|
120
|
+
" to open and authorize (you cannot click it for them; you may offer to open it via the shell).",
|
|
121
|
+
" Once they confirm, `list_github_installations` \u2192 `list_github_installation_repos` to pick the",
|
|
122
|
+
" repo, then `connect_repository` with that `installationId`.",
|
|
123
|
+
" - Token (any host): ask the user to paste a personal access token (repo read + PR scope), then",
|
|
124
|
+
" `connect_repository` with `token`. Treat the token as a secret \u2014 do not echo it back.",
|
|
125
|
+
" d. `files`/`paths` on `connect_repository` are an OPTIONAL fallback used only when the repo has no",
|
|
126
|
+
" committed `.syntholoc/config.json`. If you committed the config in (b) you may pass the same `files`",
|
|
127
|
+
" for redundancy, but the in-repo config wins. Same Crowdin placeholders as the config file.",
|
|
128
|
+
" e. `sync_repository` \u2014 imports ALL source strings from the repo at once and opens the translation PR.",
|
|
129
|
+
" Then `pull_translations` to import any translation files already in the repo (choose `conflict`",
|
|
130
|
+
" KEEP_APPROVED to preserve in-app edits).",
|
|
131
|
+
" f. `wait_for_job` on the returned job id, then report keys imported and the PR url.",
|
|
132
|
+
"4. FALLBACK \u2014 file-based import when the user does NOT want Git integration:",
|
|
100
133
|
" - Single file \u2192 `import_source_strings` once.",
|
|
101
134
|
" - Namespaced \u2192 `import_source_strings` once PER FILE with `keyPrefix` set to the namespace",
|
|
102
135
|
" (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",
|
|
104
|
-
" a Crowdin-style `files` array of `{ source, translation }` patterns with a `{locale}` placeholder,",
|
|
105
|
-
' e.g. { "source": "messages/en/home.json", "translation": "messages/{locale}/home.json", "namespace": "home" }.',
|
|
106
|
-
' Do NOT invent fields like `i18nDir`, `format: "nested-dirs"`, or `localeMapping` \u2014 they are not read.',
|
|
107
136
|
"5. Offer to run the localize_changed flow next."
|
|
108
137
|
].join("\n")
|
|
109
138
|
)
|
|
@@ -148,7 +177,10 @@ function registerResources(server, gql) {
|
|
|
148
177
|
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
178
|
mimeType: "application/json"
|
|
150
179
|
},
|
|
151
|
-
async (uri) => json(
|
|
180
|
+
async (uri) => json(
|
|
181
|
+
uri,
|
|
182
|
+
(await gql.request(MY_ORGANIZATIONS)).myProjects.map((g) => ({ ...g.organization, viewerRole: g.viewerRole }))
|
|
183
|
+
)
|
|
152
184
|
);
|
|
153
185
|
server.registerResource(
|
|
154
186
|
"projects",
|
|
@@ -334,11 +366,26 @@ function registerConfigTools(server, gql) {
|
|
|
334
366
|
// src/tools/git.ts
|
|
335
367
|
import { z as z3 } from "zod";
|
|
336
368
|
import {
|
|
369
|
+
CONNECT_REPOSITORY,
|
|
370
|
+
DISCONNECT_REPOSITORY,
|
|
371
|
+
GITHUB_APP,
|
|
372
|
+
GITHUB_INSTALLATIONS,
|
|
373
|
+
GITHUB_INSTALLATION_REPOS,
|
|
337
374
|
LIST_REPOSITORIES,
|
|
338
375
|
PULL_TRANSLATIONS,
|
|
339
376
|
PUSH_TRANSLATIONS,
|
|
340
|
-
SYNC_REPOSITORY
|
|
377
|
+
SYNC_REPOSITORY,
|
|
378
|
+
UPDATE_REPOSITORY
|
|
341
379
|
} from "@syntholoc/sdk";
|
|
380
|
+
var fileMapping = z3.object({
|
|
381
|
+
source: z3.string().describe('Source file path or glob, e.g. "messages/en.json" or "locales/en/*.json".'),
|
|
382
|
+
translation: z3.string().describe(
|
|
383
|
+
'Translation pattern with Crowdin placeholders, e.g. "messages/%two_letters_code%/%original_file_name%" or "locales/%locale%/%original_file_name%".'
|
|
384
|
+
),
|
|
385
|
+
languagesMapping: z3.record(z3.string(), z3.record(z3.string(), z3.string())).optional().describe(
|
|
386
|
+
'Crowdin `languages_mapping`: per-placeholder, per-locale path overrides. Keyed by placeholder name without % (e.g. "two_letters_code", "locale"), then by locale code, to the on-disk replacement. E.g. { "two_letters_code": { "zh-CN": "cn" } } writes Chinese under "cn" instead of "zh".'
|
|
387
|
+
)
|
|
388
|
+
});
|
|
342
389
|
function registerGitTools(server, gql) {
|
|
343
390
|
server.registerTool(
|
|
344
391
|
"list_repositories",
|
|
@@ -350,6 +397,92 @@ function registerGitTools(server, gql) {
|
|
|
350
397
|
},
|
|
351
398
|
async ({ projectId }) => jsonResult((await gql.request(LIST_REPOSITORIES, { projectId })).repositories)
|
|
352
399
|
);
|
|
400
|
+
server.registerTool(
|
|
401
|
+
"get_github_app_install_url",
|
|
402
|
+
{
|
|
403
|
+
title: "GitHub App install URL",
|
|
404
|
+
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`.",
|
|
405
|
+
inputSchema: { projectId: z3.string() },
|
|
406
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
407
|
+
},
|
|
408
|
+
async ({ projectId }) => jsonResult((await gql.request(GITHUB_APP, { projectId })).githubApp)
|
|
409
|
+
);
|
|
410
|
+
server.registerTool(
|
|
411
|
+
"list_github_installations",
|
|
412
|
+
{
|
|
413
|
+
title: "List GitHub installations",
|
|
414
|
+
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`.",
|
|
415
|
+
inputSchema: { projectId: z3.string() },
|
|
416
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
417
|
+
},
|
|
418
|
+
async ({ projectId }) => jsonResult((await gql.request(GITHUB_INSTALLATIONS, { projectId })).githubInstallations)
|
|
419
|
+
);
|
|
420
|
+
server.registerTool(
|
|
421
|
+
"list_github_installation_repos",
|
|
422
|
+
{
|
|
423
|
+
title: "List installation repositories",
|
|
424
|
+
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`.",
|
|
425
|
+
inputSchema: {
|
|
426
|
+
projectId: z3.string(),
|
|
427
|
+
installationId: z3.number().int().describe("From `list_github_installations`.")
|
|
428
|
+
},
|
|
429
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
430
|
+
},
|
|
431
|
+
async ({ projectId, installationId }) => jsonResult((await gql.request(GITHUB_INSTALLATION_REPOS, { projectId, installationId })).githubInstallationRepos)
|
|
432
|
+
);
|
|
433
|
+
server.registerTool(
|
|
434
|
+
"connect_repository",
|
|
435
|
+
{
|
|
436
|
+
title: "Connect repository",
|
|
437
|
+
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). Mappings come from the repo's `.syntholoc/config.json` when present (the source of truth); `files`/`paths` here are an optional fallback used only when that file is absent. After connecting, call `sync_repository` to import all source strings at once.",
|
|
438
|
+
inputSchema: {
|
|
439
|
+
projectId: z3.string(),
|
|
440
|
+
provider: z3.enum(["GITHUB", "GITLAB", "BITBUCKET"]).default("GITHUB"),
|
|
441
|
+
fullName: z3.string().describe('owner/repo, e.g. "acme/webapp".'),
|
|
442
|
+
branch: z3.string().optional().describe('Source branch (default "main").'),
|
|
443
|
+
files: z3.array(fileMapping).optional().describe("Crowdin-style source\u2192translation mappings. Fallback only \u2014 an in-repo `.syntholoc/config.json` wins."),
|
|
444
|
+
paths: z3.array(z3.string()).optional().describe("Simple mode: source file paths/globs to track (use instead of `files`). Fallback for in-repo config."),
|
|
445
|
+
installationId: z3.number().int().optional().describe("GitHub App installation id. Mutually exclusive with `token`."),
|
|
446
|
+
token: z3.string().optional().describe("Access token (PAT mode), stored encrypted. Mutually exclusive with `installationId`."),
|
|
447
|
+
translationBranch: z3.string().optional().describe("Branch translations are committed to (default l10n_<branch>)."),
|
|
448
|
+
prTargetBranch: z3.string().optional().describe("Branch translation PRs target (default = branch)."),
|
|
449
|
+
autoSyncEnabled: z3.boolean().optional().describe("Enable scheduled auto-sync.")
|
|
450
|
+
},
|
|
451
|
+
annotations: { openWorldHint: true }
|
|
452
|
+
},
|
|
453
|
+
async ({ projectId, ...input }) => jsonResult((await gql.request(CONNECT_REPOSITORY, { projectId, input })).connectRepository)
|
|
454
|
+
);
|
|
455
|
+
server.registerTool(
|
|
456
|
+
"update_repository",
|
|
457
|
+
{
|
|
458
|
+
title: "Update repository",
|
|
459
|
+
description: "Updates an already-connected repository in place \u2014 no disconnect/reconnect, stored credentials and webhooks are preserved. Use it for integration settings (source `branch`, translation/PR branches, `pushSources`, auto-sync), and for `files`/`paths` only as the DB fallback. IMPORTANT: to change file MAPPINGS, prefer editing the repo's `.syntholoc/config.json` and opening a PR \u2014 that file is the source of truth and, when present, overrides `files`/`paths` here (they are ignored). So do NOT use this tool to change mappings of a config-managed repo; edit the file, open a PR, and after it merges call `sync_repository`. Only the fields you pass are changed.",
|
|
460
|
+
inputSchema: {
|
|
461
|
+
projectId: z3.string(),
|
|
462
|
+
repositoryId: z3.string(),
|
|
463
|
+
branch: z3.string().optional().describe("Source branch."),
|
|
464
|
+
files: z3.array(fileMapping).optional().describe("Crowdin-style source\u2192translation mappings (replaces existing)."),
|
|
465
|
+
paths: z3.array(z3.string()).optional().describe("Simple mode: source file paths/globs to track (replaces existing; use instead of `files`)."),
|
|
466
|
+
translationBranch: z3.string().optional().describe("Branch translations are committed to."),
|
|
467
|
+
prTargetBranch: z3.string().optional().describe("Branch translation PRs target."),
|
|
468
|
+
pushSources: z3.boolean().optional().describe("Rewrite the source file(s) in the repo from in-app keys."),
|
|
469
|
+
autoSyncEnabled: z3.boolean().optional().describe("Enable scheduled auto-sync."),
|
|
470
|
+
syncIntervalHours: z3.number().int().optional().describe("Auto-sync interval in hours.")
|
|
471
|
+
},
|
|
472
|
+
annotations: { openWorldHint: true }
|
|
473
|
+
},
|
|
474
|
+
async ({ projectId, repositoryId, ...input }) => jsonResult((await gql.request(UPDATE_REPOSITORY, { projectId, id: repositoryId, input })).updateRepository)
|
|
475
|
+
);
|
|
476
|
+
server.registerTool(
|
|
477
|
+
"disconnect_repository",
|
|
478
|
+
{
|
|
479
|
+
title: "Disconnect repository",
|
|
480
|
+
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.",
|
|
481
|
+
inputSchema: { projectId: z3.string(), repositoryId: z3.string() },
|
|
482
|
+
annotations: { destructiveHint: true, openWorldHint: true }
|
|
483
|
+
},
|
|
484
|
+
async ({ projectId, repositoryId }) => jsonResult((await gql.request(DISCONNECT_REPOSITORY, { projectId, id: repositoryId })).disconnectRepository)
|
|
485
|
+
);
|
|
353
486
|
server.registerTool(
|
|
354
487
|
"sync_repository",
|
|
355
488
|
{
|
|
@@ -521,7 +654,7 @@ import {
|
|
|
521
654
|
UPDATE_KEY,
|
|
522
655
|
UPSERT_KEYS
|
|
523
656
|
} from "@syntholoc/sdk";
|
|
524
|
-
var FORMATS = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS"];
|
|
657
|
+
var FORMATS = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS", "NJK"];
|
|
525
658
|
function registerKeyTools(server, gql) {
|
|
526
659
|
server.registerTool(
|
|
527
660
|
"list_keys",
|
|
@@ -591,13 +724,15 @@ function registerKeyTools(server, gql) {
|
|
|
591
724
|
"import_source_strings",
|
|
592
725
|
{
|
|
593
726
|
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.',
|
|
727
|
+
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
728
|
inputSchema: {
|
|
596
729
|
projectId: z6.string(),
|
|
597
730
|
content: z6.string(),
|
|
598
731
|
format: z6.enum(FORMATS),
|
|
599
732
|
keyPrefix: z6.string().optional().describe('Namespace prefix, e.g. "home" (per-file, multi-file projects).'),
|
|
600
|
-
sourceFile: z6.string().optional().describe(
|
|
733
|
+
sourceFile: z6.string().optional().describe(
|
|
734
|
+
'Real source file path, e.g. "messages/en/home.json" \u2014 stored for the file tree + reliable export.'
|
|
735
|
+
),
|
|
601
736
|
dryRun: z6.boolean().default(false)
|
|
602
737
|
},
|
|
603
738
|
annotations: { idempotentHint: true, openWorldHint: true }
|
|
@@ -614,6 +749,158 @@ function registerKeyTools(server, gql) {
|
|
|
614
749
|
// src/tools/locales.ts
|
|
615
750
|
import { z as z7 } from "zod";
|
|
616
751
|
import { ADD_LOCALE, LIST_LOCALES as LIST_LOCALES2, REMOVE_LOCALE, SEED_STARTER_LOCALES } from "@syntholoc/sdk";
|
|
752
|
+
|
|
753
|
+
// ../../packages/shared/src/locales.ts
|
|
754
|
+
var STARTER_LOCALES = [
|
|
755
|
+
{ code: "ar", name: "Arabic", nativeName: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629", rtl: true },
|
|
756
|
+
{ code: "az", name: "Azerbaijani", nativeName: "Az\u0259rbaycan" },
|
|
757
|
+
{ code: "be", name: "Belarusian", nativeName: "\u0411\u0435\u043B\u0430\u0440\u0443\u0441\u043A\u0430\u044F" },
|
|
758
|
+
{ code: "bn", name: "Bengali", nativeName: "\u09AC\u09BE\u0982\u09B2\u09BE" },
|
|
759
|
+
{ code: "cs", name: "Czech", nativeName: "\u010Ce\u0161tina" },
|
|
760
|
+
{ code: "de", name: "German", nativeName: "Deutsch" },
|
|
761
|
+
{ code: "el", name: "Greek", nativeName: "\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC" },
|
|
762
|
+
{ code: "en", name: "English", nativeName: "English" },
|
|
763
|
+
{ code: "es", name: "Spanish", nativeName: "Espa\xF1ol" },
|
|
764
|
+
{ code: "es-419", name: "Spanish (Latin America)", nativeName: "Espa\xF1ol (Latinoam\xE9rica)" },
|
|
765
|
+
{ code: "fa", name: "Persian", nativeName: "\u0641\u0627\u0631\u0633\u06CC", rtl: true },
|
|
766
|
+
{ code: "fr", name: "French", nativeName: "Fran\xE7ais" },
|
|
767
|
+
{ code: "hi", name: "Hindi", nativeName: "\u0939\u093F\u0928\u094D\u0926\u0940" },
|
|
768
|
+
{ code: "hy", name: "Armenian", nativeName: "\u0540\u0561\u0575\u0565\u0580\u0565\u0576" },
|
|
769
|
+
{ code: "id", name: "Indonesian", nativeName: "Bahasa Indonesia" },
|
|
770
|
+
{ code: "it", name: "Italian", nativeName: "Italiano" },
|
|
771
|
+
{ code: "ja", name: "Japanese", nativeName: "\u65E5\u672C\u8A9E" },
|
|
772
|
+
{ code: "ka", name: "Georgian", nativeName: "\u10E5\u10D0\u10E0\u10D7\u10E3\u10DA\u10D8" },
|
|
773
|
+
{ code: "kk", name: "Kazakh", nativeName: "\u049A\u0430\u0437\u0430\u049B \u0442\u0456\u043B\u0456" },
|
|
774
|
+
{ code: "ko", name: "Korean", nativeName: "\uD55C\uAD6D\uC5B4" },
|
|
775
|
+
{ code: "lv", name: "Latvian", nativeName: "Latvie\u0161u" },
|
|
776
|
+
{ code: "ms", name: "Malay", nativeName: "Bahasa Melayu" },
|
|
777
|
+
{ code: "nl", name: "Dutch", nativeName: "Nederlands" },
|
|
778
|
+
{ code: "pl", name: "Polish", nativeName: "Polski" },
|
|
779
|
+
{ code: "pt-BR", name: "Portuguese (Brazil)", nativeName: "Portugu\xEAs (Brasil)" },
|
|
780
|
+
{ code: "pt-PT", name: "Portuguese (Portugal)", nativeName: "Portugu\xEAs (Portugal)" },
|
|
781
|
+
{ code: "ro", name: "Romanian", nativeName: "Rom\xE2n\u0103" },
|
|
782
|
+
{ code: "ru", name: "Russian", nativeName: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439" },
|
|
783
|
+
{ code: "sk", name: "Slovak", nativeName: "Sloven\u010Dina" },
|
|
784
|
+
{ code: "sl", name: "Slovenian", nativeName: "Sloven\u0161\u010Dina" },
|
|
785
|
+
{ code: "sw", name: "Swahili", nativeName: "Kiswahili" },
|
|
786
|
+
{ code: "th", name: "Thai", nativeName: "\u0E44\u0E17\u0E22" },
|
|
787
|
+
{ code: "tl", name: "Filipino", nativeName: "Filipino" },
|
|
788
|
+
{ code: "tr", name: "Turkish", nativeName: "T\xFCrk\xE7e" },
|
|
789
|
+
{ code: "uk", name: "Ukrainian", nativeName: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
|
|
790
|
+
{ code: "ur", name: "Urdu", nativeName: "\u0627\u0631\u062F\u0648", rtl: true },
|
|
791
|
+
{ code: "uz", name: "Uzbek", nativeName: "O\u02BBzbek" },
|
|
792
|
+
{ code: "vi", name: "Vietnamese", nativeName: "Ti\u1EBFng Vi\u1EC7t" },
|
|
793
|
+
{ code: "zh-CN", name: "Chinese (Simplified)", nativeName: "\u7B80\u4F53\u4E2D\u6587" },
|
|
794
|
+
{ code: "zh-TW", name: "Chinese (Traditional)", nativeName: "\u7E41\u9AD4\u4E2D\u6587" }
|
|
795
|
+
];
|
|
796
|
+
var STARTER_LOCALE_CODES = STARTER_LOCALES.map((l) => l.code);
|
|
797
|
+
var STARTER_BY_CODE = new Map(STARTER_LOCALES.map((l) => [l.code, l]));
|
|
798
|
+
function findStarterLocale(code) {
|
|
799
|
+
return STARTER_BY_CODE.get(code);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ../../packages/shared/src/locale-resolve.ts
|
|
803
|
+
var ALIASES = {
|
|
804
|
+
cn: "zh-CN",
|
|
805
|
+
"zh-hans": "zh-CN",
|
|
806
|
+
"zh-hant": "zh-TW",
|
|
807
|
+
jp: "ja",
|
|
808
|
+
kr: "ko",
|
|
809
|
+
ua: "uk",
|
|
810
|
+
cz: "cs",
|
|
811
|
+
gr: "el",
|
|
812
|
+
iw: "he",
|
|
813
|
+
// legacy Hebrew
|
|
814
|
+
in: "id"
|
|
815
|
+
// legacy Indonesian
|
|
816
|
+
};
|
|
817
|
+
var VARIANTS_BY_LANG = (() => {
|
|
818
|
+
const map = /* @__PURE__ */ new Map();
|
|
819
|
+
for (const { code } of STARTER_LOCALES) {
|
|
820
|
+
const lang = code.split("-")[0].toLowerCase();
|
|
821
|
+
(map.get(lang) ?? map.set(lang, []).get(lang)).push(code);
|
|
822
|
+
}
|
|
823
|
+
return map;
|
|
824
|
+
})();
|
|
825
|
+
function canonicalize(input) {
|
|
826
|
+
try {
|
|
827
|
+
return Intl.getCanonicalLocales(input)[0] ?? null;
|
|
828
|
+
} catch {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function isRealLanguage(code) {
|
|
833
|
+
try {
|
|
834
|
+
const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code);
|
|
835
|
+
return !!name && name.toLowerCase() !== code.toLowerCase();
|
|
836
|
+
} catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function resolveLocaleCode(raw) {
|
|
841
|
+
const input = raw.trim();
|
|
842
|
+
if (!input) {
|
|
843
|
+
return { status: "unknown", input: raw, message: "Empty locale code." };
|
|
844
|
+
}
|
|
845
|
+
const alias = ALIASES[input.toLowerCase()];
|
|
846
|
+
if (alias) {
|
|
847
|
+
return { status: "normalized", code: alias, from: input, reason: `mapped "${input}" to catalog code "${alias}"` };
|
|
848
|
+
}
|
|
849
|
+
const canonical = canonicalize(input);
|
|
850
|
+
if (!canonical) {
|
|
851
|
+
return { status: "unknown", input, message: `"${input}" is not a valid BCP-47 locale code.` };
|
|
852
|
+
}
|
|
853
|
+
if (findStarterLocale(canonical)) {
|
|
854
|
+
return canonical === input ? { status: "ok", code: canonical } : { status: "normalized", code: canonical, from: input, reason: `casing normalized to "${canonical}"` };
|
|
855
|
+
}
|
|
856
|
+
const lang = canonical.split("-")[0].toLowerCase();
|
|
857
|
+
const hasRegionOrScript = canonical.includes("-");
|
|
858
|
+
const variants = VARIANTS_BY_LANG.get(lang) ?? [];
|
|
859
|
+
if (!hasRegionOrScript) {
|
|
860
|
+
if (variants.length > 1) {
|
|
861
|
+
return {
|
|
862
|
+
status: "ambiguous",
|
|
863
|
+
input,
|
|
864
|
+
candidates: variants,
|
|
865
|
+
message: `"${input}" is ambiguous \u2014 the catalog has ${variants.join(", ")}. Pick one.`
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (variants.length === 1) {
|
|
869
|
+
return {
|
|
870
|
+
status: "normalized",
|
|
871
|
+
code: variants[0],
|
|
872
|
+
from: input,
|
|
873
|
+
reason: `expanded "${input}" to "${variants[0]}"`
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
return isRealLanguage(canonical) ? { status: "ok", code: canonical } : { status: "unknown", input, message: `"${input}" is not a recognized language.` };
|
|
877
|
+
}
|
|
878
|
+
if (!isRealLanguage(canonical)) {
|
|
879
|
+
return { status: "unknown", input, message: `"${input}" is not a valid locale.` };
|
|
880
|
+
}
|
|
881
|
+
return canonical === input ? { status: "ok", code: canonical } : { status: "normalized", code: canonical, from: input, reason: `canonicalized to "${canonical}"` };
|
|
882
|
+
}
|
|
883
|
+
function resolveLocaleCodes(raw) {
|
|
884
|
+
const out = { codes: [], normalized: [], rejected: [] };
|
|
885
|
+
const seen = /* @__PURE__ */ new Set();
|
|
886
|
+
for (const code of raw) {
|
|
887
|
+
const res = resolveLocaleCode(code);
|
|
888
|
+
if (res.status === "ambiguous") {
|
|
889
|
+
out.rejected.push({ input: res.input, message: res.message, candidates: res.candidates });
|
|
890
|
+
} else if (res.status === "unknown") {
|
|
891
|
+
out.rejected.push({ input: res.input, message: res.message });
|
|
892
|
+
} else {
|
|
893
|
+
if (res.status === "normalized") out.normalized.push({ from: res.from, code: res.code, reason: res.reason });
|
|
894
|
+
if (!seen.has(res.code)) {
|
|
895
|
+
seen.add(res.code);
|
|
896
|
+
out.codes.push(res.code);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return out;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/tools/locales.ts
|
|
617
904
|
function registerLocaleTools(server, gql) {
|
|
618
905
|
server.registerTool(
|
|
619
906
|
"list_locales",
|
|
@@ -629,7 +916,7 @@ function registerLocaleTools(server, gql) {
|
|
|
629
916
|
"add_locale",
|
|
630
917
|
{
|
|
631
918
|
title: "Add locale",
|
|
632
|
-
description:
|
|
919
|
+
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
920
|
inputSchema: {
|
|
634
921
|
projectId: z7.string(),
|
|
635
922
|
// `code` and `locale` are aliases — accept either so the natural
|
|
@@ -650,22 +937,48 @@ function registerLocaleTools(server, gql) {
|
|
|
650
937
|
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
938
|
});
|
|
652
939
|
}
|
|
653
|
-
const
|
|
654
|
-
|
|
940
|
+
const res = resolveLocaleCode(resolvedCode);
|
|
941
|
+
if (res.status === "ambiguous") {
|
|
942
|
+
return jsonResult({
|
|
943
|
+
error: res.message,
|
|
944
|
+
hint: `Retry add_locale with one of: ${res.candidates.join(", ")}.`,
|
|
945
|
+
candidates: res.candidates
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
if (res.status === "unknown") {
|
|
949
|
+
return jsonResult({ error: res.message, hint: 'Use a valid BCP-47 code, e.g. "de-DE" or "zh-CN".' });
|
|
950
|
+
}
|
|
951
|
+
const input = { code: res.code, name, formality, gender, variant };
|
|
952
|
+
const added = (await gql.request(ADD_LOCALE, { projectId, input })).addLocale;
|
|
953
|
+
return res.status === "normalized" ? jsonResult({ ...added, note: `Interpreted "${res.from}" as "${res.code}" (${res.reason}).` }) : jsonResult(added);
|
|
655
954
|
}
|
|
656
955
|
);
|
|
657
956
|
server.registerTool(
|
|
658
957
|
"seed_starter_locales",
|
|
659
958
|
{
|
|
660
959
|
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.',
|
|
960
|
+
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
961
|
inputSchema: {
|
|
663
962
|
projectId: z7.string(),
|
|
664
963
|
codes: z7.array(z7.string()).optional().describe('BCP-47 codes to add, e.g. ["ru","de-DE","fr"].')
|
|
665
964
|
},
|
|
666
965
|
annotations: { openWorldHint: true }
|
|
667
966
|
},
|
|
668
|
-
async ({ projectId, codes }) =>
|
|
967
|
+
async ({ projectId, codes }) => {
|
|
968
|
+
if (codes && codes.length > 0) {
|
|
969
|
+
const resolved = resolveLocaleCodes(codes);
|
|
970
|
+
if (resolved.rejected.length > 0) {
|
|
971
|
+
return jsonResult({
|
|
972
|
+
error: "Some locale codes are ambiguous or invalid; nothing was added.",
|
|
973
|
+
hint: "Fix the codes below and retry. For ambiguous ones, pick a listed candidate.",
|
|
974
|
+
rejected: resolved.rejected
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
const seeded = (await gql.request(SEED_STARTER_LOCALES, { projectId, codes: resolved.codes })).seedStarterLocales;
|
|
978
|
+
return resolved.normalized.length > 0 ? jsonResult({ locales: seeded, normalized: resolved.normalized }) : jsonResult(seeded);
|
|
979
|
+
}
|
|
980
|
+
return jsonResult((await gql.request(SEED_STARTER_LOCALES, { projectId, codes })).seedStarterLocales);
|
|
981
|
+
}
|
|
669
982
|
);
|
|
670
983
|
server.registerTool(
|
|
671
984
|
"remove_locale",
|
|
@@ -942,7 +1255,7 @@ import {
|
|
|
942
1255
|
TRANSLATE_KEY,
|
|
943
1256
|
TRANSLATE_LOCALE
|
|
944
1257
|
} from "@syntholoc/sdk";
|
|
945
|
-
var FORMATS2 = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS"];
|
|
1258
|
+
var FORMATS2 = ["JSON", "YAML", "CSV", "PO", "XLIFF", "ANDROID_XML", "IOS_STRINGS", "NJK"];
|
|
946
1259
|
function registerTranslationTools(server, gql) {
|
|
947
1260
|
server.registerTool(
|
|
948
1261
|
"translate_locale",
|
|
@@ -1093,9 +1406,12 @@ function registerAllTools(server, gql, opts = {}) {
|
|
|
1093
1406
|
}
|
|
1094
1407
|
}
|
|
1095
1408
|
|
|
1409
|
+
// package.json
|
|
1410
|
+
var version = "0.3.1";
|
|
1411
|
+
|
|
1096
1412
|
// src/build-server.ts
|
|
1097
1413
|
function buildMcpServer(gql, opts = {}) {
|
|
1098
|
-
const server = new McpServer({ name: "syntholoc", version
|
|
1414
|
+
const server = new McpServer({ name: "syntholoc", version });
|
|
1099
1415
|
registerAllTools(server, gql, opts);
|
|
1100
1416
|
registerResources(server, gql);
|
|
1101
1417
|
registerPrompts(server);
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syntholoc/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Syntholoc MCP server — manage AI localization and translation from Claude Code.",
|
|
3
|
+
"version": "0.3.1",
|
|
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.
|
|
50
|
+
"@syntholoc/sdk": "0.3.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
|
}
|