@syntholoc/mcp 0.4.0 → 0.6.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.
@@ -0,0 +1,2098 @@
1
+ import { ADD_LOCALE, APPROVE_TRANSLATION, APP_INFO, BUILD_OTA_RELEASE, CANCEL_OTA_RELEASE, CONFIGURE_LLM_PROVIDER, CONFIG_RESOURCE, CONNECT_REPOSITORY, COST_ANALYTICS, CREATE_GLOSSARY_TERM, CREATE_OTA_CHANNEL, CREATE_OTA_DELIVERY_KEY, CREATE_OTA_DISTRIBUTION, CREATE_PROJECT, CREATE_STYLE_GUIDE, DELETE_GLOSSARY_TERM, DELETE_KEY, DELETE_OTA_CHANNEL, DELETE_OTA_RELEASE, DISABLE_OTA_DISTRIBUTION, DISCONNECT_REPOSITORY, ENABLE_OTA_DISTRIBUTION, EXPORT_DOCUMENT_LOCALE, EXPORT_LOCALE, GENERATE_STYLE_GUIDE, GET_JOB, GET_PROJECT, GITHUB_APP, GITHUB_INSTALLATIONS, GITHUB_INSTALLATION_REPOS, IMPORT_SOURCE_STRINGS, IMPORT_TRANSLATIONS, LIST_GLOSSARY, LIST_JOBS, LIST_KEYS, LIST_LLM_PROVIDERS, LIST_LOCALES, LIST_PROJECTS, LIST_QA_ISSUES, LIST_REPOSITORIES, LIST_STYLE_GUIDES, LIST_TRANSLATIONS, LOCALE_PROGRESS, MY_ORGANIZATIONS, OTA_CHANNELS, OTA_CHANNEL_HISTORY, OTA_DELIVERY_KEYS, OTA_DISTRIBUTION, OTA_DISTRIBUTIONS, OTA_PREVIEW_ASSET, OTA_RELEASE, OTA_RELEASES, OTA_RELEASE_DIFF, OTA_USAGE, PIN_OTA_RELEASE, PROJECTS_RESOURCE, PROJECT_ANALYTICS, PROJECT_RESOURCE, PROMOTE_OTA_RELEASE, PULL_TRANSLATIONS, PUSH_TRANSLATIONS, REJECT_TRANSLATION, REMOVE_LOCALE, RETIRE_OTA_DISTRIBUTION, REVOKE_OTA_DELIVERY_KEY, ROLLBACK_OTA_CHANNEL, RUN_LOCALE_QA, RUN_QA, SEED_STARTER_LOCALES, SET_TRANSLATION, SUGGEST_GLOSSARY_TERMS, SYNC_REPOSITORY, TM_ENTRIES, TM_SEARCH, TM_STATS, TRANSLATE_KEY, TRANSLATE_LOCALE, UPDATE_GLOSSARY_TERM, UPDATE_KEY, UPDATE_OTA_DISTRIBUTION, UPDATE_PROJECT, UPDATE_REPOSITORY, UPSERT_KEYS, USAGE, waitForJob } from "@syntholoc/sdk";
2
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ //#region src/filter.ts
5
+ /** Converts a `*`-wildcard pattern to a full-match RegExp (other chars are literal). */
6
+ function wildcardToRegExp(pattern) {
7
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
8
+ return new RegExp(`^${escaped}$`);
9
+ }
10
+ function matchesAnyPattern(name, patterns) {
11
+ return patterns.some((re) => re.test(name));
12
+ }
13
+ /**
14
+ * Overrides the server's `registerTool` so tools that fail the read-only or
15
+ * name-pattern filters are silently dropped. Tool-set (group) selection happens
16
+ * separately in `registerAllTools` by skipping whole groups. Returns the same
17
+ * server (unmodified when no per-tool filtering is requested).
18
+ *
19
+ * Callers of `registerTool` ignore its return value, so skipped tools resolve to
20
+ * `undefined` — no dummy handle is needed.
21
+ */
22
+ function applyToolFilter(server, opts) {
23
+ const patterns = (opts.toolFilter ?? []).filter(Boolean).map(wildcardToRegExp);
24
+ if (!opts.readOnly && patterns.length === 0) return server;
25
+ const original = server.registerTool.bind(server);
26
+ const filtered = (name, config, handler) => {
27
+ const readOnly = config.annotations?.readOnlyHint;
28
+ if (opts.readOnly && !readOnly) return void 0;
29
+ if (patterns.length > 0 && !matchesAnyPattern(name, patterns)) return;
30
+ return original(name, config, handler);
31
+ };
32
+ server.registerTool = filtered;
33
+ return server;
34
+ }
35
+ /** Parses a comma-separated header/env value into a trimmed, non-empty string list. */
36
+ function parseList(value) {
37
+ if (!value) return void 0;
38
+ const items = value.split(",").map((s) => s.trim()).filter(Boolean);
39
+ return items.length > 0 ? items : void 0;
40
+ }
41
+ //#endregion
42
+ //#region src/prompts.ts
43
+ function userMessage(text) {
44
+ return { messages: [{
45
+ role: "user",
46
+ content: {
47
+ type: "text",
48
+ text
49
+ }
50
+ }] };
51
+ }
52
+ /** Registers workflow prompts for MCP clients that expose server-provided prompts. */
53
+ function registerPrompts(server) {
54
+ server.registerPrompt("localize_changed", {
55
+ title: "Localize changed keys",
56
+ description: "Sync changed source strings from the local diff, translate them, and write back.",
57
+ argsSchema: { projectId: z.string().optional() }
58
+ }, ({ projectId }) => userMessage([
59
+ "Localize my recent source-string changes via Syntholoc:",
60
+ "1. Read `.syntholoc/config.json` for the source file(s) and project.",
61
+ projectId ? ` (Use project ${projectId} if config is missing.)` : "",
62
+ "2. From `git diff` on the source file(s), collect added/changed keys and `upsert_keys` (dryRun first to preview).",
63
+ " - MULTI-FILE / namespaced project (a file per namespace, e.g. messages/en/home.json): import each",
64
+ " changed file with `import_source_strings` passing `keyPrefix` = the namespace (the filename",
65
+ " without extension, e.g. \"home\"). This keeps overlapping keys from different files distinct.",
66
+ "3. Call `get_config` for `maxBulkSize`. For EACH target locale check `locale_progress` → `untranslated`.",
67
+ " - If untranslated >= maxBulkSize: `translate_locale` (scope UNTRANSLATED) and `wait_for_job`.",
68
+ " - If below the threshold: tell me the counts and ASK whether to translate now or accumulate.",
69
+ "4. `export_locale` for each translated locale and write the result back into the mapped local files.",
70
+ " - Namespaced project: call `export_locale` once per namespace with the same `keyPrefix` (the prefix",
71
+ " is stripped on export) and write each result to its file, e.g. messages/{locale}/home.json.",
72
+ "5. Summarize keys added, locales translated, and the cost from the job usage."
73
+ ].filter(Boolean).join("\n")));
74
+ server.registerPrompt("review_locale", {
75
+ title: "Review a locale",
76
+ description: "Surface low-confidence and QA-flagged translations for a locale and help fix them.",
77
+ argsSchema: {
78
+ projectId: z.string(),
79
+ locale: z.string()
80
+ }
81
+ }, ({ projectId, locale }) => userMessage([
82
+ `Review the "${locale}" locale in project ${projectId}:`,
83
+ "1. Resolve the locale id via `list_locales`.",
84
+ "2. `list_qa_issues` and `list_translations` (focus on low qaScore / non-approved).",
85
+ "3. For each problem, propose a fix; apply with `set_translation`, then `approve_translation`.",
86
+ "4. Summarize what changed and what still needs human attention."
87
+ ].join("\n")));
88
+ server.registerPrompt("bootstrap_project", {
89
+ title: "Bootstrap a project from this repo",
90
+ description: "Detect i18n files, create a Syntholoc project, seed locales, and write .syntholoc/config.json.",
91
+ argsSchema: {}
92
+ }, () => userMessage([
93
+ "Set up Syntholoc for this repository. PREFER the Git-sync path — it pulls every",
94
+ "source string (and any existing translations) in one server-side sync and keeps",
95
+ "the repo in sync going forward. Only fall back to manual imports if the user",
96
+ "declines Git or the code lives outside a supported host.",
97
+ "",
98
+ "1. Find the i18n source files and infer the source locale. Note the LAYOUT:",
99
+ " - Single file per locale (e.g. messages/en.json) → one mapping, no namespace.",
100
+ " - A directory per locale with many namespace files (e.g. messages/en/home.json, common.json) →",
101
+ " each file is a NAMESPACE. Syntholoc stores a flat key space, so each file is imported under a",
102
+ " `keyPrefix` = its namespace to avoid collisions between files.",
103
+ "2. `create_project`, then `seed_starter_locales` (or `add_locale`) for the target languages. Use valid",
104
+ " BCP-47 codes (e.g. \"zh-CN\", not \"cn\"); `add_locale` accepts `code` or `locale`. Region-ambiguous",
105
+ " languages MUST be qualified — \"pt\" → \"pt-BR\" or \"pt-PT\", \"zh\" → \"zh-CN\" or \"zh-TW\". The tools reject",
106
+ " ambiguous codes and return the candidates; if a source filename is just \"pt\", ask which region (or",
107
+ " default to the likeliest) and record the file→locale mapping in the config `translation` pattern.",
108
+ " (Target locales must exist before a Git pull so their translation files import.)",
109
+ "3. RECOMMENDED — connect the repo and sync, instead of pushing strings one by one:",
110
+ " a. `list_repositories`. If one is already connected, skip to (e).",
111
+ " b. Write `.syntholoc/config.json` FIRST and commit it to the source branch — the server reads it",
112
+ " from the repo as the SOURCE OF TRUTH for mappings on every sync/pull/write-back (Crowdin-style),",
113
+ " so it must land in the branch before the first sync. Schema (https://syntholoc.com/schemas/config.json):",
114
+ " the `projectId`, `sourceLocale`, and a Crowdin-style `files` array of `{ source, translation }`",
115
+ " patterns, e.g. { \"source\": \"messages/en/*.json\", \"translation\": \"messages/{locale}/%original_file_name%\" }.",
116
+ " - Placeholders: `{locale}` (or %locale% / %two_letters_code% / %original_file_name% / %original_path%).",
117
+ " - If a locale's ON-DISK code differs from its BCP-47 code (e.g. \"zh-CN\" stored as \"cn\", \"pt-BR\" as \"pt\"),",
118
+ " add `languagesMapping`, e.g. { \"two_letters_code\": { \"zh-CN\": \"cn\", \"pt-BR\": \"pt\" } }.",
119
+ " - Do NOT invent fields like `i18nDir`, `format: \"nested-dirs\"`, or `localeMapping` — they are not read.",
120
+ " - CRITICAL: `.syntholoc/config.json` MUST be committed and tracked — it is the mapping source of",
121
+ " truth the server reads from the branch. NEVER add `.syntholoc` (or the config file) to `.gitignore`.",
122
+ " If `.gitignore` already ignores it (a common reflex, next to `.claude`), remove that line. After",
123
+ " committing, VERIFY it is tracked (e.g. `git ls-files .syntholoc/config.json` returns the path, or",
124
+ " `git check-ignore` reports nothing) — an ignored config means every sync/pull/write-back resolves",
125
+ " zero files and silently opens no PR.",
126
+ " c. Connect it with `connect_repository`. Ask the user which method they prefer:",
127
+ " - GitHub App (no token, recommended): `get_github_app_install_url` → give the URL to the user",
128
+ " to open and authorize (you cannot click it for them; you may offer to open it via the shell).",
129
+ " Once they confirm, `list_github_installations` → `list_github_installation_repos` to pick the",
130
+ " repo, then `connect_repository` with that `installationId`.",
131
+ " - Token (any host): ask the user to paste a personal access token (repo read + PR scope), then",
132
+ " `connect_repository` with `token`. Treat the token as a secret — do not echo it back.",
133
+ " d. `files`/`paths` on `connect_repository` are an OPTIONAL fallback used only when the repo has no",
134
+ " committed `.syntholoc/config.json`. If you committed the config in (b) you may pass the same `files`",
135
+ " for redundancy, but the in-repo config wins. Same Crowdin placeholders as the config file.",
136
+ " e. `sync_repository` — imports ALL source strings from the repo at once and opens the translation PR.",
137
+ " Then `pull_translations` to import any translation files already in the repo (choose `conflict`",
138
+ " KEEP_APPROVED to preserve in-app edits).",
139
+ " f. `wait_for_job` on the returned job id, then report keys imported and the PR url.",
140
+ "4. FALLBACK — file-based import when the user does NOT want Git integration:",
141
+ " - Single file → `import_source_strings` once.",
142
+ " - Namespaced → `import_source_strings` once PER FILE with `keyPrefix` set to the namespace",
143
+ " (filename without extension). Keys become e.g. `home.title`, `common.title`.",
144
+ "5. Offer to run the localize_changed flow next."
145
+ ].join("\n")));
146
+ }
147
+ //#endregion
148
+ //#region src/resources.ts
149
+ function json(uri, data) {
150
+ return { contents: [{
151
+ uri: uri.href,
152
+ mimeType: "application/json",
153
+ text: JSON.stringify(data, null, 2)
154
+ }] };
155
+ }
156
+ /** Registers read-only MCP resources so the model can pull context cheaply. */
157
+ function registerResources(server, gql) {
158
+ server.registerResource("config", "syntholoc://config", {
159
+ title: "Syntholoc config",
160
+ description: "Server metadata incl. maxBulkSize (auto-translate batch threshold).",
161
+ mimeType: "application/json"
162
+ }, async (uri) => json(uri, (await gql.request(CONFIG_RESOURCE)).appInfo));
163
+ server.registerResource("organizations", "syntholoc://organizations", {
164
+ title: "Organizations",
165
+ 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.",
166
+ mimeType: "application/json"
167
+ }, async (uri) => json(uri, (await gql.request(MY_ORGANIZATIONS)).myProjects.map((g) => ({
168
+ ...g.organization,
169
+ viewerRole: g.viewerRole
170
+ }))));
171
+ server.registerResource("projects", "syntholoc://projects", {
172
+ title: "Projects",
173
+ description: "All localization projects in the active organization.",
174
+ mimeType: "application/json"
175
+ }, async (uri) => json(uri, (await gql.request(PROJECTS_RESOURCE)).projects));
176
+ server.registerResource("project", new ResourceTemplate("syntholoc://project/{projectId}", { list: void 0 }), {
177
+ title: "Project overview",
178
+ description: "A project with its locales and per-locale progress.",
179
+ mimeType: "application/json"
180
+ }, async (uri, { projectId }) => json(uri, await gql.request(PROJECT_RESOURCE, { id: String(projectId) })));
181
+ server.registerResource("project-keys", new ResourceTemplate("syntholoc://project/{projectId}/keys", { list: void 0 }), {
182
+ title: "Project source keys",
183
+ description: "Source keys in a project (first 100).",
184
+ mimeType: "application/json"
185
+ }, async (uri, { projectId }) => json(uri, (await gql.request(LIST_KEYS, {
186
+ projectId: String(projectId),
187
+ skip: 0,
188
+ take: 100
189
+ })).keys));
190
+ server.registerResource("project-locales", new ResourceTemplate("syntholoc://project/{projectId}/locales", { list: void 0 }), {
191
+ title: "Project locales",
192
+ description: "Target locales configured for a project.",
193
+ mimeType: "application/json"
194
+ }, async (uri, { projectId }) => json(uri, (await gql.request(LIST_LOCALES, { projectId: String(projectId) })).locales));
195
+ server.registerResource("project-progress", new ResourceTemplate("syntholoc://project/{projectId}/progress", { list: void 0 }), {
196
+ title: "Project progress",
197
+ description: "Per-locale translation progress and open QA issues for a project.",
198
+ mimeType: "application/json"
199
+ }, async (uri, { projectId }) => json(uri, (await gql.request(PROJECT_ANALYTICS, { projectId: String(projectId) })).projectAnalytics));
200
+ server.registerResource("project-jobs", new ResourceTemplate("syntholoc://project/{projectId}/jobs", { list: void 0 }), {
201
+ title: "Project jobs",
202
+ description: "Recent background jobs (translate / QA / git sync) for a project.",
203
+ mimeType: "application/json"
204
+ }, async (uri, { projectId }) => json(uri, (await gql.request(LIST_JOBS, {
205
+ projectId: String(projectId),
206
+ skip: 0,
207
+ take: 25
208
+ })).jobs));
209
+ server.registerResource("project-qa-issues", new ResourceTemplate("syntholoc://project/{projectId}/locale/{localeId}/qa-issues", { list: void 0 }), {
210
+ title: "Locale QA issues",
211
+ description: "Open QA issues for a locale, with issue-type facets.",
212
+ mimeType: "application/json"
213
+ }, async (uri, { projectId, localeId }) => json(uri, await gql.request(LIST_QA_ISSUES, {
214
+ projectId: String(projectId),
215
+ localeId: String(localeId),
216
+ skip: 0,
217
+ take: 25
218
+ })));
219
+ server.registerResource("glossary", "syntholoc://glossary", {
220
+ title: "Glossary",
221
+ description: "Organization-wide glossary terms (first 100).",
222
+ mimeType: "application/json"
223
+ }, async (uri) => json(uri, (await gql.request(LIST_GLOSSARY, {
224
+ skip: 0,
225
+ take: 100
226
+ })).glossaryTerms));
227
+ server.registerResource("style-guides", "syntholoc://style-guides", {
228
+ title: "Style guides",
229
+ description: "Organization-wide style guides.",
230
+ mimeType: "application/json"
231
+ }, async (uri) => json(uri, (await gql.request(LIST_STYLE_GUIDES, {})).styleGuides));
232
+ server.registerResource("tm", "syntholoc://tm", {
233
+ title: "Translation memory",
234
+ description: "Organization-wide Translation Memory coverage: entry counts per target locale.",
235
+ mimeType: "application/json"
236
+ }, async (uri) => json(uri, (await gql.request(TM_STATS, {})).tmStats));
237
+ }
238
+ //#endregion
239
+ //#region src/util.ts
240
+ /** Wraps a value as an MCP text result (pretty-printed JSON). */
241
+ function jsonResult(data) {
242
+ return { content: [{
243
+ type: "text",
244
+ text: JSON.stringify(data, null, 2)
245
+ }] };
246
+ }
247
+ /**
248
+ * Wraps a validation failure as an MCP tool error. Returned rather than thrown so
249
+ * the model sees an actionable message instead of a transport-level failure.
250
+ */
251
+ function errorResult(message) {
252
+ return {
253
+ content: [{
254
+ type: "text",
255
+ text: message
256
+ }],
257
+ isError: true
258
+ };
259
+ }
260
+ //#endregion
261
+ //#region src/tools/ota.ts
262
+ function registerOtaTools(server, gql) {
263
+ server.registerTool("list_ota_distributions", {
264
+ title: "otaDistributions",
265
+ description: "otaDistributions for the specified project and distribution.",
266
+ inputSchema: {
267
+ projectId: z.string(),
268
+ skip: z.number().int().min(0).optional(),
269
+ take: z.number().int().min(0).optional()
270
+ },
271
+ annotations: {
272
+ readOnlyHint: true,
273
+ destructiveHint: false,
274
+ openWorldHint: true
275
+ }
276
+ }, async (vars) => jsonResult((await gql.request(OTA_DISTRIBUTIONS, vars)).otaDistributions));
277
+ server.registerTool("get_ota_distribution", {
278
+ title: "otaDistribution",
279
+ description: "otaDistribution for the specified project and distribution.",
280
+ inputSchema: {
281
+ projectId: z.string(),
282
+ distributionId: z.string()
283
+ },
284
+ annotations: {
285
+ readOnlyHint: true,
286
+ destructiveHint: false,
287
+ openWorldHint: true
288
+ }
289
+ }, async (vars) => jsonResult((await gql.request(OTA_DISTRIBUTION, vars)).otaDistribution));
290
+ server.registerTool("list_ota_releases", {
291
+ title: "otaReleases",
292
+ description: "otaReleases for the specified project and distribution.",
293
+ inputSchema: {
294
+ projectId: z.string(),
295
+ distributionId: z.string(),
296
+ skip: z.number().int().min(0).optional(),
297
+ take: z.number().int().min(0).optional()
298
+ },
299
+ annotations: {
300
+ readOnlyHint: true,
301
+ destructiveHint: false,
302
+ openWorldHint: true
303
+ }
304
+ }, async (vars) => jsonResult((await gql.request(OTA_RELEASES, vars)).otaReleases));
305
+ server.registerTool("get_ota_release", {
306
+ title: "otaRelease",
307
+ description: "otaRelease for the specified project and distribution.",
308
+ inputSchema: {
309
+ projectId: z.string(),
310
+ distributionId: z.string(),
311
+ releaseId: z.string()
312
+ },
313
+ annotations: {
314
+ readOnlyHint: true,
315
+ destructiveHint: false,
316
+ openWorldHint: true
317
+ }
318
+ }, async (vars) => jsonResult((await gql.request(OTA_RELEASE, vars)).otaRelease));
319
+ server.registerTool("list_ota_channels", {
320
+ title: "otaChannels",
321
+ description: "otaChannels for the specified project and distribution.",
322
+ inputSchema: {
323
+ projectId: z.string(),
324
+ distributionId: z.string()
325
+ },
326
+ annotations: {
327
+ readOnlyHint: true,
328
+ destructiveHint: false,
329
+ openWorldHint: true
330
+ }
331
+ }, async (vars) => jsonResult((await gql.request(OTA_CHANNELS, vars)).otaChannels));
332
+ server.registerTool("get_ota_channel_history", {
333
+ title: "otaChannelHistory",
334
+ description: "otaChannelHistory for the specified project and distribution.",
335
+ inputSchema: {
336
+ projectId: z.string(),
337
+ distributionId: z.string(),
338
+ channelId: z.string(),
339
+ skip: z.number().int().min(0).optional(),
340
+ take: z.number().int().min(0).optional()
341
+ },
342
+ annotations: {
343
+ readOnlyHint: true,
344
+ destructiveHint: false,
345
+ openWorldHint: true
346
+ }
347
+ }, async (vars) => jsonResult((await gql.request(OTA_CHANNEL_HISTORY, vars)).otaChannelHistory));
348
+ server.registerTool("get_ota_release_diff", {
349
+ title: "otaReleaseDiff",
350
+ description: "otaReleaseDiff for the specified project and distribution.",
351
+ inputSchema: {
352
+ projectId: z.string(),
353
+ distributionId: z.string(),
354
+ from: z.string(),
355
+ to: z.string()
356
+ },
357
+ annotations: {
358
+ readOnlyHint: true,
359
+ destructiveHint: false,
360
+ openWorldHint: true
361
+ }
362
+ }, async (vars) => jsonResult((await gql.request(OTA_RELEASE_DIFF, vars)).otaReleaseDiff));
363
+ server.registerTool("list_ota_delivery_keys", {
364
+ title: "otaDeliveryKeys",
365
+ description: "otaDeliveryKeys for the specified project and distribution.",
366
+ inputSchema: {
367
+ projectId: z.string(),
368
+ distributionId: z.string(),
369
+ skip: z.number().int().min(0).optional(),
370
+ take: z.number().int().min(0).optional()
371
+ },
372
+ annotations: {
373
+ readOnlyHint: true,
374
+ destructiveHint: false,
375
+ openWorldHint: true
376
+ }
377
+ }, async (vars) => jsonResult((await gql.request(OTA_DELIVERY_KEYS, vars)).otaDeliveryKeys));
378
+ server.registerTool("get_ota_usage", {
379
+ title: "otaUsage",
380
+ description: "otaUsage for the specified project and distribution.",
381
+ inputSchema: {
382
+ projectId: z.string(),
383
+ distributionId: z.string()
384
+ },
385
+ annotations: {
386
+ readOnlyHint: true,
387
+ destructiveHint: false,
388
+ openWorldHint: true
389
+ }
390
+ }, async (vars) => jsonResult((await gql.request(OTA_USAGE, vars)).otaUsage));
391
+ server.registerTool("create_ota_distribution", {
392
+ title: "createOtaDistribution",
393
+ description: "createOtaDistribution for the specified project and distribution.",
394
+ inputSchema: {
395
+ projectId: z.string(),
396
+ input: z.object({
397
+ name: z.string(),
398
+ platform: z.enum([
399
+ "WEB",
400
+ "ANDROID",
401
+ "IOS"
402
+ ]),
403
+ catalogVersion: z.string(),
404
+ accessMode: z.enum(["PUBLIC", "PROTECTED"]),
405
+ config: z.record(z.string(), z.unknown())
406
+ })
407
+ },
408
+ annotations: {
409
+ readOnlyHint: false,
410
+ destructiveHint: true,
411
+ openWorldHint: true
412
+ }
413
+ }, async (vars) => jsonResult((await gql.request(CREATE_OTA_DISTRIBUTION, vars)).createOtaDistribution));
414
+ server.registerTool("update_ota_distribution", {
415
+ title: "updateOtaDistribution",
416
+ description: "updateOtaDistribution for the specified project and distribution.",
417
+ inputSchema: {
418
+ projectId: z.string(),
419
+ distributionId: z.string(),
420
+ name: z.string(),
421
+ config: z.record(z.string(), z.unknown()),
422
+ expectedConfigVersion: z.number().int().min(0)
423
+ },
424
+ annotations: {
425
+ readOnlyHint: false,
426
+ destructiveHint: true,
427
+ openWorldHint: true
428
+ }
429
+ }, async (vars) => jsonResult((await gql.request(UPDATE_OTA_DISTRIBUTION, vars)).updateOtaDistribution));
430
+ server.registerTool("disable_ota_distribution", {
431
+ title: "disableOtaDistribution",
432
+ description: "disableOtaDistribution for the specified project and distribution.",
433
+ inputSchema: {
434
+ projectId: z.string(),
435
+ distributionId: z.string()
436
+ },
437
+ annotations: {
438
+ readOnlyHint: false,
439
+ destructiveHint: true,
440
+ openWorldHint: true
441
+ }
442
+ }, async (vars) => jsonResult((await gql.request(DISABLE_OTA_DISTRIBUTION, vars)).disableOtaDistribution));
443
+ server.registerTool("enable_ota_distribution", {
444
+ title: "enableOtaDistribution",
445
+ description: "enableOtaDistribution for the specified project and distribution.",
446
+ inputSchema: {
447
+ projectId: z.string(),
448
+ distributionId: z.string()
449
+ },
450
+ annotations: {
451
+ readOnlyHint: false,
452
+ destructiveHint: true,
453
+ openWorldHint: true
454
+ }
455
+ }, async (vars) => jsonResult((await gql.request(ENABLE_OTA_DISTRIBUTION, vars)).enableOtaDistribution));
456
+ server.registerTool("retire_ota_distribution", {
457
+ title: "retireOtaDistribution",
458
+ description: "retireOtaDistribution for the specified project and distribution.",
459
+ inputSchema: {
460
+ projectId: z.string(),
461
+ distributionId: z.string()
462
+ },
463
+ annotations: {
464
+ readOnlyHint: false,
465
+ destructiveHint: true,
466
+ openWorldHint: true
467
+ }
468
+ }, async (vars) => jsonResult((await gql.request(RETIRE_OTA_DISTRIBUTION, vars)).retireOtaDistribution));
469
+ server.registerTool("build_ota_release", {
470
+ title: "buildOtaRelease",
471
+ description: "Build a frozen translation release without publishing it. Idempotency key is required. Poll the returned jobId with wait_for_job.",
472
+ inputSchema: {
473
+ projectId: z.string(),
474
+ distributionId: z.string(),
475
+ idempotencyKey: z.string()
476
+ },
477
+ annotations: {
478
+ readOnlyHint: false,
479
+ destructiveHint: true,
480
+ openWorldHint: true
481
+ }
482
+ }, async (vars) => jsonResult((await gql.request(BUILD_OTA_RELEASE, vars)).buildOtaRelease));
483
+ server.registerTool("cancel_ota_release", {
484
+ title: "cancelOtaRelease",
485
+ description: "cancelOtaRelease for the specified project and distribution.",
486
+ inputSchema: {
487
+ projectId: z.string(),
488
+ distributionId: z.string(),
489
+ releaseId: z.string()
490
+ },
491
+ annotations: {
492
+ readOnlyHint: false,
493
+ destructiveHint: true,
494
+ openWorldHint: true
495
+ }
496
+ }, async (vars) => jsonResult((await gql.request(CANCEL_OTA_RELEASE, vars)).cancelOtaRelease));
497
+ server.registerTool("promote_ota_release", {
498
+ title: "promoteOtaRelease",
499
+ description: "Publish the specified READY release to a channel, including production. Requires the currently observed generation.",
500
+ inputSchema: {
501
+ projectId: z.string(),
502
+ distributionId: z.string(),
503
+ releaseId: z.string(),
504
+ channel: z.string(),
505
+ expectedGeneration: z.number().int().min(0),
506
+ reason: z.string().optional()
507
+ },
508
+ annotations: {
509
+ readOnlyHint: false,
510
+ destructiveHint: true,
511
+ openWorldHint: true
512
+ }
513
+ }, async (vars) => jsonResult((await gql.request(PROMOTE_OTA_RELEASE, vars)).promoteOtaRelease));
514
+ server.registerTool("rollback_ota_channel", {
515
+ title: "rollbackOtaChannel",
516
+ description: "Roll a channel back to a previously published release. Requires a reason and the observed generation.",
517
+ inputSchema: {
518
+ projectId: z.string(),
519
+ distributionId: z.string(),
520
+ releaseId: z.string(),
521
+ channel: z.string(),
522
+ expectedGeneration: z.number().int().min(0),
523
+ reason: z.string()
524
+ },
525
+ annotations: {
526
+ readOnlyHint: false,
527
+ destructiveHint: true,
528
+ openWorldHint: true
529
+ }
530
+ }, async (vars) => jsonResult((await gql.request(ROLLBACK_OTA_CHANNEL, vars)).rollbackOtaChannel));
531
+ server.registerTool("create_ota_channel", {
532
+ title: "createOtaChannel",
533
+ description: "createOtaChannel for the specified project and distribution.",
534
+ inputSchema: {
535
+ projectId: z.string(),
536
+ distributionId: z.string(),
537
+ name: z.string()
538
+ },
539
+ annotations: {
540
+ readOnlyHint: false,
541
+ destructiveHint: true,
542
+ openWorldHint: true
543
+ }
544
+ }, async (vars) => jsonResult((await gql.request(CREATE_OTA_CHANNEL, vars)).createOtaChannel));
545
+ server.registerTool("delete_ota_channel", {
546
+ title: "deleteOtaChannel",
547
+ description: "deleteOtaChannel for the specified project and distribution.",
548
+ inputSchema: {
549
+ projectId: z.string(),
550
+ distributionId: z.string(),
551
+ channelId: z.string()
552
+ },
553
+ annotations: {
554
+ readOnlyHint: false,
555
+ destructiveHint: true,
556
+ openWorldHint: true
557
+ }
558
+ }, async (vars) => jsonResult((await gql.request(DELETE_OTA_CHANNEL, vars)).deleteOtaChannel));
559
+ server.registerTool("create_ota_delivery_key", {
560
+ title: "createOtaDeliveryKey",
561
+ description: "Create a scoped read-only delivery credential. Returns the plaintext secret once; never use it as a management API key.",
562
+ inputSchema: {
563
+ projectId: z.string(),
564
+ distributionId: z.string(),
565
+ name: z.string(),
566
+ allowedChannelIds: z.array(z.string()).min(1).max(50),
567
+ expiresAt: z.string().optional()
568
+ },
569
+ annotations: {
570
+ readOnlyHint: false,
571
+ destructiveHint: true,
572
+ openWorldHint: true
573
+ }
574
+ }, async (vars) => jsonResult((await gql.request(CREATE_OTA_DELIVERY_KEY, vars)).createOtaDeliveryKey));
575
+ server.registerTool("revoke_ota_delivery_key", {
576
+ title: "revokeOtaDeliveryKey",
577
+ description: "revokeOtaDeliveryKey for the specified project and distribution.",
578
+ inputSchema: {
579
+ projectId: z.string(),
580
+ distributionId: z.string(),
581
+ keyId: z.string()
582
+ },
583
+ annotations: {
584
+ readOnlyHint: false,
585
+ destructiveHint: true,
586
+ openWorldHint: true
587
+ }
588
+ }, async (vars) => jsonResult((await gql.request(REVOKE_OTA_DELIVERY_KEY, vars)).revokeOtaDeliveryKey));
589
+ server.registerTool("pin_ota_release", {
590
+ title: "pinOtaRelease",
591
+ description: "pinOtaRelease for the specified project and distribution.",
592
+ inputSchema: {
593
+ projectId: z.string(),
594
+ distributionId: z.string(),
595
+ releaseId: z.string(),
596
+ pinned: z.boolean()
597
+ },
598
+ annotations: {
599
+ readOnlyHint: false,
600
+ destructiveHint: true,
601
+ openWorldHint: true
602
+ }
603
+ }, async (vars) => jsonResult((await gql.request(PIN_OTA_RELEASE, vars)).pinOtaRelease));
604
+ server.registerTool("delete_ota_release", {
605
+ title: "deleteOtaRelease",
606
+ description: "deleteOtaRelease for the specified project and distribution.",
607
+ inputSchema: {
608
+ projectId: z.string(),
609
+ distributionId: z.string(),
610
+ releaseId: z.string()
611
+ },
612
+ annotations: {
613
+ readOnlyHint: false,
614
+ destructiveHint: true,
615
+ openWorldHint: true
616
+ }
617
+ }, async (vars) => jsonResult((await gql.request(DELETE_OTA_RELEASE, vars)).deleteOtaRelease));
618
+ server.registerTool("preview_ota_asset", {
619
+ title: "Preview OTA asset",
620
+ description: "Read a READY asset through management authentication before publication. Requires translator access.",
621
+ inputSchema: {
622
+ projectId: z.string(),
623
+ distributionId: z.string(),
624
+ releaseId: z.string(),
625
+ assetId: z.string()
626
+ },
627
+ annotations: {
628
+ readOnlyHint: true,
629
+ openWorldHint: true
630
+ }
631
+ }, async (vars) => jsonResult((await gql.request(OTA_PREVIEW_ASSET, vars)).otaPreviewAsset));
632
+ }
633
+ //#endregion
634
+ //#region src/tools/analytics.ts
635
+ function registerAnalyticsTools(server, gql) {
636
+ server.registerTool("project_analytics", {
637
+ title: "Project analytics",
638
+ description: "Real-time quality + progress metrics for a project (per-locale progress, open issues).",
639
+ inputSchema: { projectId: z.string() },
640
+ annotations: {
641
+ readOnlyHint: true,
642
+ openWorldHint: true
643
+ }
644
+ }, async ({ projectId }) => jsonResult((await gql.request(PROJECT_ANALYTICS, { projectId })).projectAnalytics));
645
+ server.registerTool("cost_analytics", {
646
+ title: "Cost analytics",
647
+ description: "LLM token/cost time-series and per-model breakdown over a window (default 30 days).",
648
+ inputSchema: {
649
+ projectId: z.string().optional(),
650
+ days: z.number().int().min(1).max(365).optional()
651
+ },
652
+ annotations: {
653
+ readOnlyHint: true,
654
+ openWorldHint: true
655
+ }
656
+ }, async (vars) => jsonResult((await gql.request(COST_ANALYTICS, vars)).costAnalytics));
657
+ server.registerTool("usage", {
658
+ title: "Org usage & plan",
659
+ description: "Organization plan, hosted words and seat usage.",
660
+ inputSchema: {},
661
+ annotations: {
662
+ readOnlyHint: true,
663
+ openWorldHint: true
664
+ }
665
+ }, async () => jsonResult((await gql.request(USAGE)).usage));
666
+ server.registerTool("locale_progress", {
667
+ title: "Locale progress",
668
+ description: "Progress counts for one locale: total / untranslated / autoTranslated / humanApproved. The `untranslated` count drives the batch threshold (see get_config).",
669
+ inputSchema: {
670
+ projectId: z.string(),
671
+ localeId: z.string()
672
+ },
673
+ annotations: {
674
+ readOnlyHint: true,
675
+ openWorldHint: true
676
+ }
677
+ }, async (vars) => jsonResult((await gql.request(LOCALE_PROGRESS, vars)).localeProgress));
678
+ }
679
+ //#endregion
680
+ //#region src/tools/config.ts
681
+ function registerConfigTools(server, gql) {
682
+ server.registerTool("get_config", {
683
+ title: "Get Syntholoc config",
684
+ description: "Server metadata incl. `maxBulkSize` — the max keys per LLM batch. Use it as the auto-translate threshold: translate proactively only when pending untranslated keys reach this number; otherwise ask the user (an explicit translate request always runs).",
685
+ inputSchema: {},
686
+ annotations: {
687
+ readOnlyHint: true,
688
+ openWorldHint: true
689
+ }
690
+ }, async () => jsonResult((await gql.request(APP_INFO)).appInfo));
691
+ }
692
+ //#endregion
693
+ //#region src/tools/git.ts
694
+ /** One Crowdin-style file mapping: a source glob/path + its per-locale translation pattern. */
695
+ const fileMapping = z.object({
696
+ source: z.string().describe("Source file path or glob, e.g. \"messages/en.json\" or \"locales/en/*.json\"."),
697
+ translation: z.string().describe("Translation pattern with Crowdin placeholders, e.g. \"messages/%two_letters_code%/%original_file_name%\" or \"locales/%locale%/%original_file_name%\"."),
698
+ languagesMapping: z.record(z.string(), z.record(z.string(), z.string())).optional().describe("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\".")
699
+ });
700
+ function registerGitTools(server, gql) {
701
+ server.registerTool("list_repositories", {
702
+ title: "List repositories",
703
+ description: "Lists git repositories connected to a project.",
704
+ inputSchema: { projectId: z.string() },
705
+ annotations: {
706
+ readOnlyHint: true,
707
+ openWorldHint: true
708
+ }
709
+ }, async ({ projectId }) => jsonResult((await gql.request(LIST_REPOSITORIES, { projectId })).repositories));
710
+ server.registerTool("get_github_app_install_url", {
711
+ title: "GitHub App install URL",
712
+ 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 — fall back to the token (PAT) path via `connect_repository`.",
713
+ inputSchema: { projectId: z.string() },
714
+ annotations: {
715
+ readOnlyHint: true,
716
+ openWorldHint: true
717
+ }
718
+ }, async ({ projectId }) => jsonResult((await gql.request(GITHUB_APP, { projectId })).githubApp));
719
+ server.registerTool("list_github_installations", {
720
+ title: "List GitHub installations",
721
+ 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`.",
722
+ inputSchema: { projectId: z.string() },
723
+ annotations: {
724
+ readOnlyHint: true,
725
+ openWorldHint: true
726
+ }
727
+ }, async ({ projectId }) => jsonResult((await gql.request(GITHUB_INSTALLATIONS, { projectId })).githubInstallations));
728
+ server.registerTool("list_github_installation_repos", {
729
+ title: "List installation repositories",
730
+ 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`.",
731
+ inputSchema: {
732
+ projectId: z.string(),
733
+ installationId: z.number().int().describe("From `list_github_installations`.")
734
+ },
735
+ annotations: {
736
+ readOnlyHint: true,
737
+ openWorldHint: true
738
+ }
739
+ }, async ({ projectId, installationId }) => jsonResult((await gql.request(GITHUB_INSTALLATION_REPOS, {
740
+ projectId,
741
+ installationId
742
+ })).githubInstallationRepos));
743
+ server.registerTool("connect_repository", {
744
+ title: "Connect repository",
745
+ description: "Connects a git repository to the project so syncs can pull source strings and push translation PRs. Provide EITHER `installationId` (GitHub App mode — after the browser authorize flow: `get_github_app_install_url` → `list_github_installations` → `list_github_installation_repos`) OR `token` (PAT mode — 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.",
746
+ inputSchema: {
747
+ projectId: z.string(),
748
+ provider: z.enum([
749
+ "GITHUB",
750
+ "GITLAB",
751
+ "BITBUCKET"
752
+ ]).default("GITHUB"),
753
+ fullName: z.string().describe("owner/repo, e.g. \"acme/webapp\"."),
754
+ branch: z.string().optional().describe("Source branch (default \"main\")."),
755
+ files: z.array(fileMapping).optional().describe("Crowdin-style source→translation mappings. Fallback only — an in-repo `.syntholoc/config.json` wins."),
756
+ paths: z.array(z.string()).optional().describe("Simple mode: source file paths/globs to track (use instead of `files`). Fallback for in-repo config."),
757
+ installationId: z.number().int().optional().describe("GitHub App installation id. Mutually exclusive with `token`."),
758
+ token: z.string().optional().describe("Access token (PAT mode), stored encrypted. Mutually exclusive with `installationId`."),
759
+ translationBranch: z.string().optional().describe("Branch translations are committed to (default l10n_<branch>)."),
760
+ prTargetBranch: z.string().optional().describe("Branch translation PRs target (default = branch)."),
761
+ autoSyncEnabled: z.boolean().optional().describe("Enable scheduled auto-sync.")
762
+ },
763
+ annotations: { openWorldHint: true }
764
+ }, async ({ projectId, ...input }) => jsonResult((await gql.request(CONNECT_REPOSITORY, {
765
+ projectId,
766
+ input
767
+ })).connectRepository));
768
+ server.registerTool("update_repository", {
769
+ title: "Update repository",
770
+ description: "Updates an already-connected repository in place — 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 — 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.",
771
+ inputSchema: {
772
+ projectId: z.string(),
773
+ repositoryId: z.string(),
774
+ branch: z.string().optional().describe("Source branch."),
775
+ files: z.array(fileMapping).optional().describe("Crowdin-style source→translation mappings (replaces existing)."),
776
+ paths: z.array(z.string()).optional().describe("Simple mode: source file paths/globs to track (replaces existing; use instead of `files`)."),
777
+ translationBranch: z.string().optional().describe("Branch translations are committed to."),
778
+ prTargetBranch: z.string().optional().describe("Branch translation PRs target."),
779
+ pushSources: z.boolean().optional().describe("Rewrite the source file(s) in the repo from in-app keys."),
780
+ autoSyncEnabled: z.boolean().optional().describe("Enable scheduled auto-sync."),
781
+ syncIntervalHours: z.number().int().optional().describe("Auto-sync interval in hours.")
782
+ },
783
+ annotations: { openWorldHint: true }
784
+ }, async ({ projectId, repositoryId, ...input }) => jsonResult((await gql.request(UPDATE_REPOSITORY, {
785
+ projectId,
786
+ id: repositoryId,
787
+ input
788
+ })).updateRepository));
789
+ server.registerTool("disconnect_repository", {
790
+ title: "Disconnect repository",
791
+ description: "Removes the link to a connected repository and its stored credentials. The repository itself is not affected. Destructive — confirm with the user first.",
792
+ inputSchema: {
793
+ projectId: z.string(),
794
+ repositoryId: z.string()
795
+ },
796
+ annotations: {
797
+ destructiveHint: true,
798
+ openWorldHint: true
799
+ }
800
+ }, async ({ projectId, repositoryId }) => jsonResult((await gql.request(DISCONNECT_REPOSITORY, {
801
+ projectId,
802
+ id: repositoryId
803
+ })).disconnectRepository));
804
+ server.registerTool("sync_repository", {
805
+ title: "Sync repository",
806
+ description: "Queues an immediate sync of a connected repository (pull → translate → PR).",
807
+ inputSchema: {
808
+ projectId: z.string(),
809
+ repositoryId: z.string()
810
+ },
811
+ annotations: { openWorldHint: true }
812
+ }, async ({ projectId, repositoryId }) => jsonResult((await gql.request(SYNC_REPOSITORY, {
813
+ projectId,
814
+ id: repositoryId
815
+ })).syncRepository));
816
+ server.registerTool("push_translations", {
817
+ title: "Push translations (PR)",
818
+ description: "Queues write-back of a locale: export approved translations and open a PR.",
819
+ inputSchema: {
820
+ projectId: z.string(),
821
+ repositoryId: z.string(),
822
+ localeId: z.string()
823
+ },
824
+ annotations: { openWorldHint: true }
825
+ }, async (vars) => jsonResult((await gql.request(PUSH_TRANSLATIONS, vars)).pushTranslations));
826
+ server.registerTool("pull_translations", {
827
+ title: "Pull translations",
828
+ description: "Queues a pull of translation files from the connected repo, importing them per locale. Returns the tracking job ids — the import itself runs in the background. `autoApprove` lands them as APPROVED; `conflict` is OVERWRITE | SKIP_EXISTING | KEEP_APPROVED.",
829
+ inputSchema: {
830
+ projectId: z.string(),
831
+ repositoryId: z.string(),
832
+ autoApprove: z.boolean().default(false),
833
+ conflict: z.enum([
834
+ "OVERWRITE",
835
+ "SKIP_EXISTING",
836
+ "KEEP_APPROVED"
837
+ ]).default("OVERWRITE")
838
+ },
839
+ annotations: { openWorldHint: true }
840
+ }, async ({ projectId, repositoryId, autoApprove, conflict }) => jsonResult((await gql.request(PULL_TRANSLATIONS, {
841
+ projectId,
842
+ repositoryId,
843
+ input: {
844
+ autoApprove,
845
+ conflict
846
+ }
847
+ })).pullTranslations));
848
+ }
849
+ //#endregion
850
+ //#region src/tools/glossary.ts
851
+ function registerGlossaryTools(server, gql) {
852
+ server.registerTool("list_glossary", {
853
+ title: "List glossary terms",
854
+ description: "Lists glossary terms (project-scoped if projectId given, else org-wide).",
855
+ inputSchema: {
856
+ projectId: z.string().optional(),
857
+ skip: z.number().int().min(0).default(0),
858
+ take: z.number().int().min(1).max(100).default(25)
859
+ },
860
+ annotations: {
861
+ readOnlyHint: true,
862
+ openWorldHint: true
863
+ }
864
+ }, async (vars) => jsonResult((await gql.request(LIST_GLOSSARY, vars)).glossaryTerms));
865
+ server.registerTool("upsert_glossary_term", {
866
+ title: "Create or update glossary term",
867
+ description: "Creates a glossary term, or updates it when `termId` is given. `translations` is a per-locale map, e.g. {\"de-DE\":\"…\"}. `forbidden` lists disallowed renderings.",
868
+ inputSchema: {
869
+ termId: z.string().optional().describe("Provide to update an existing term"),
870
+ projectId: z.string().optional().describe("Project scope; omit for org-wide (create only)"),
871
+ term: z.string().optional(),
872
+ description: z.string().optional(),
873
+ partOfSpeech: z.string().optional(),
874
+ doNotTranslate: z.boolean().optional(),
875
+ forbidden: z.array(z.string()).optional(),
876
+ translations: z.record(z.string(), z.string()).optional()
877
+ },
878
+ annotations: {
879
+ idempotentHint: true,
880
+ openWorldHint: true
881
+ }
882
+ }, async ({ termId, projectId, ...rest }) => {
883
+ if (termId) return jsonResult((await gql.request(UPDATE_GLOSSARY_TERM, {
884
+ id: termId,
885
+ input: rest
886
+ })).updateGlossaryTerm);
887
+ if (!rest.term) throw new Error("`term` is required when creating a glossary term");
888
+ return jsonResult((await gql.request(CREATE_GLOSSARY_TERM, { input: {
889
+ term: rest.term,
890
+ ...rest,
891
+ projectId
892
+ } })).createGlossaryTerm);
893
+ });
894
+ server.registerTool("delete_glossary_term", {
895
+ title: "Delete glossary term",
896
+ description: "Deletes a glossary term. Destructive — confirm first.",
897
+ inputSchema: { termId: z.string() },
898
+ annotations: {
899
+ destructiveHint: true,
900
+ openWorldHint: true
901
+ }
902
+ }, async ({ termId }) => jsonResult((await gql.request(DELETE_GLOSSARY_TERM, { id: termId })).deleteGlossaryTerm));
903
+ server.registerTool("suggest_glossary_terms", {
904
+ title: "Suggest glossary terms",
905
+ description: "AI-suggested glossary terms extracted from the project source strings.",
906
+ inputSchema: { projectId: z.string() },
907
+ annotations: {
908
+ readOnlyHint: true,
909
+ openWorldHint: true
910
+ }
911
+ }, async ({ projectId }) => jsonResult((await gql.request(SUGGEST_GLOSSARY_TERMS, { projectId })).suggestGlossaryTerms));
912
+ }
913
+ //#endregion
914
+ //#region src/tools/jobs.ts
915
+ function registerJobTools(server, gql) {
916
+ server.registerTool("get_job", {
917
+ title: "Get job",
918
+ description: "Status, progress, token usage and cost of a background job by id.",
919
+ inputSchema: {
920
+ jobId: z.string(),
921
+ projectId: z.string()
922
+ },
923
+ annotations: {
924
+ readOnlyHint: true,
925
+ openWorldHint: true
926
+ }
927
+ }, async ({ jobId, projectId }) => jsonResult((await gql.request(GET_JOB, {
928
+ id: jobId,
929
+ projectId
930
+ })).job));
931
+ server.registerTool("list_jobs", {
932
+ title: "List jobs",
933
+ description: "Recent background jobs for a project (translate / QA / git sync).",
934
+ inputSchema: {
935
+ projectId: z.string(),
936
+ skip: z.number().int().min(0).default(0),
937
+ take: z.number().int().min(1).max(100).default(25)
938
+ },
939
+ annotations: {
940
+ readOnlyHint: true,
941
+ openWorldHint: true
942
+ }
943
+ }, async (vars) => jsonResult((await gql.request(LIST_JOBS, vars)).jobs));
944
+ server.registerTool("wait_for_job", {
945
+ title: "Wait for job",
946
+ description: "Polls a job until it reaches a terminal state (COMPLETED/FAILED/CANCELLED) or the timeout elapses, then returns its final detail. Use after translate/QA to follow a run.",
947
+ inputSchema: {
948
+ jobId: z.string(),
949
+ projectId: z.string(),
950
+ timeoutSec: z.number().int().min(1).max(900).default(180),
951
+ pollIntervalSec: z.number().int().min(1).max(30).default(2)
952
+ },
953
+ annotations: {
954
+ readOnlyHint: true,
955
+ openWorldHint: true
956
+ }
957
+ }, async ({ jobId, projectId, timeoutSec, pollIntervalSec }) => {
958
+ const { job, timedOut } = await waitForJob(gql, projectId, jobId, {
959
+ timeoutSec,
960
+ pollIntervalSec
961
+ });
962
+ return jsonResult({
963
+ ...job,
964
+ timedOut
965
+ });
966
+ });
967
+ }
968
+ //#endregion
969
+ //#region src/tools/keys.ts
970
+ const FORMATS$1 = [
971
+ "JSON",
972
+ "YAML",
973
+ "MARKDOWN",
974
+ "MDX",
975
+ "CSV",
976
+ "PO",
977
+ "XLIFF",
978
+ "ANDROID_XML",
979
+ "IOS_STRINGS",
980
+ "IOS_STRINGSDICT",
981
+ "IOS_XCSTRINGS",
982
+ "NJK"
983
+ ];
984
+ /** Document formats: segments only have an identity within a file, so the path is required. */
985
+ const DOCUMENT_FORMATS$1 = /* @__PURE__ */ new Set(["MARKDOWN", "MDX"]);
986
+ function registerKeyTools(server, gql) {
987
+ server.registerTool("list_keys", {
988
+ title: "List keys",
989
+ description: "Lists source keys. With `localeId` + `status` (untranslated | flagged | approved) the result `totalCount` is the pending count for that status — use it for the batch threshold.",
990
+ inputSchema: {
991
+ projectId: z.string(),
992
+ localeId: z.string().optional(),
993
+ status: z.enum([
994
+ "untranslated",
995
+ "flagged",
996
+ "approved"
997
+ ]).optional(),
998
+ search: z.string().optional(),
999
+ skip: z.number().int().min(0).default(0),
1000
+ take: z.number().int().min(1).max(200).default(25)
1001
+ },
1002
+ annotations: {
1003
+ readOnlyHint: true,
1004
+ openWorldHint: true
1005
+ }
1006
+ }, async (vars) => jsonResult((await gql.request(LIST_KEYS, vars)).keys));
1007
+ server.registerTool("upsert_keys", {
1008
+ title: "Upsert keys (bulk)",
1009
+ description: "Bulk-upserts source keys by name (delta sync from local i18n files). Keys not listed are left untouched. Returns added/updated/unchanged counts; set `dryRun` to preview.",
1010
+ inputSchema: {
1011
+ projectId: z.string(),
1012
+ keys: z.array(z.object({
1013
+ key: z.string(),
1014
+ sourceText: z.string(),
1015
+ context: z.string().optional(),
1016
+ maxLength: z.number().int().positive().optional()
1017
+ })).min(1),
1018
+ dryRun: z.boolean().default(false)
1019
+ },
1020
+ annotations: {
1021
+ idempotentHint: true,
1022
+ openWorldHint: true
1023
+ }
1024
+ }, async ({ projectId, keys, dryRun }) => jsonResult((await gql.request(UPSERT_KEYS, {
1025
+ projectId,
1026
+ input: keys,
1027
+ dryRun
1028
+ })).upsertKeys));
1029
+ server.registerTool("update_key", {
1030
+ title: "Update key",
1031
+ description: "Updates a single key (source text, context, max length).",
1032
+ inputSchema: {
1033
+ projectId: z.string(),
1034
+ keyId: z.string(),
1035
+ sourceText: z.string().optional(),
1036
+ context: z.string().optional(),
1037
+ maxLength: z.number().int().positive().optional()
1038
+ },
1039
+ annotations: {
1040
+ idempotentHint: true,
1041
+ openWorldHint: true
1042
+ }
1043
+ }, async ({ projectId, keyId, ...input }) => jsonResult((await gql.request(UPDATE_KEY, {
1044
+ projectId,
1045
+ id: keyId,
1046
+ input
1047
+ })).updateKey));
1048
+ server.registerTool("delete_key", {
1049
+ title: "Delete key",
1050
+ description: "Deletes a key and its translations. Destructive — confirm first.",
1051
+ inputSchema: {
1052
+ projectId: z.string(),
1053
+ keyId: z.string()
1054
+ },
1055
+ annotations: {
1056
+ destructiveHint: true,
1057
+ openWorldHint: true
1058
+ }
1059
+ }, async ({ projectId, keyId }) => jsonResult((await gql.request(DELETE_KEY, {
1060
+ projectId,
1061
+ id: keyId
1062
+ })).deleteKey));
1063
+ server.registerTool("import_source_strings", {
1064
+ title: "Import source strings",
1065
+ 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` — 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 — call once per file. Use `dryRun` to preview. For MARKDOWN and MDX `sourceFile` is REQUIRED: the document is split into per-block segments, and without the file path a re-import cannot tell a moved paragraph from an edited one.",
1066
+ inputSchema: {
1067
+ projectId: z.string(),
1068
+ content: z.string(),
1069
+ format: z.enum(FORMATS$1),
1070
+ keyPrefix: z.string().optional().describe("Namespace prefix, e.g. \"home\" (per-file, multi-file projects)."),
1071
+ sourceFile: z.string().optional().describe("Real source file path, e.g. \"messages/en/home.json\" — stored for the file tree + reliable export."),
1072
+ dryRun: z.boolean().default(false)
1073
+ },
1074
+ annotations: {
1075
+ idempotentHint: true,
1076
+ openWorldHint: true
1077
+ }
1078
+ }, async ({ projectId, content, format, keyPrefix, sourceFile, dryRun }) => {
1079
+ if (DOCUMENT_FORMATS$1.has(format) && !sourceFile?.trim()) return errorResult(`${format} needs a sourceFile — a document's segments only have an identity within a file.`);
1080
+ return jsonResult((await gql.request(IMPORT_SOURCE_STRINGS, {
1081
+ projectId,
1082
+ input: {
1083
+ content,
1084
+ format,
1085
+ keyPrefix,
1086
+ sourceFile,
1087
+ dryRun
1088
+ }
1089
+ })).importSourceStrings);
1090
+ });
1091
+ }
1092
+ //#endregion
1093
+ //#region ../../packages/shared/src/locales.ts
1094
+ const STARTER_LOCALES = [
1095
+ {
1096
+ code: "ar",
1097
+ name: "Arabic",
1098
+ nativeName: "العربية",
1099
+ rtl: true
1100
+ },
1101
+ {
1102
+ code: "az",
1103
+ name: "Azerbaijani",
1104
+ nativeName: "Azərbaycan"
1105
+ },
1106
+ {
1107
+ code: "be",
1108
+ name: "Belarusian",
1109
+ nativeName: "Беларуская"
1110
+ },
1111
+ {
1112
+ code: "bn",
1113
+ name: "Bengali",
1114
+ nativeName: "বাংলা"
1115
+ },
1116
+ {
1117
+ code: "cs",
1118
+ name: "Czech",
1119
+ nativeName: "Čeština"
1120
+ },
1121
+ {
1122
+ code: "de",
1123
+ name: "German",
1124
+ nativeName: "Deutsch"
1125
+ },
1126
+ {
1127
+ code: "el",
1128
+ name: "Greek",
1129
+ nativeName: "Ελληνικά"
1130
+ },
1131
+ {
1132
+ code: "en",
1133
+ name: "English",
1134
+ nativeName: "English"
1135
+ },
1136
+ {
1137
+ code: "es",
1138
+ name: "Spanish",
1139
+ nativeName: "Español"
1140
+ },
1141
+ {
1142
+ code: "es-419",
1143
+ name: "Spanish (Latin America)",
1144
+ nativeName: "Español (Latinoamérica)"
1145
+ },
1146
+ {
1147
+ code: "fa",
1148
+ name: "Persian",
1149
+ nativeName: "فارسی",
1150
+ rtl: true
1151
+ },
1152
+ {
1153
+ code: "fr",
1154
+ name: "French",
1155
+ nativeName: "Français"
1156
+ },
1157
+ {
1158
+ code: "hi",
1159
+ name: "Hindi",
1160
+ nativeName: "हिन्दी"
1161
+ },
1162
+ {
1163
+ code: "hy",
1164
+ name: "Armenian",
1165
+ nativeName: "Հայերեն"
1166
+ },
1167
+ {
1168
+ code: "id",
1169
+ name: "Indonesian",
1170
+ nativeName: "Bahasa Indonesia"
1171
+ },
1172
+ {
1173
+ code: "it",
1174
+ name: "Italian",
1175
+ nativeName: "Italiano"
1176
+ },
1177
+ {
1178
+ code: "ja",
1179
+ name: "Japanese",
1180
+ nativeName: "日本語"
1181
+ },
1182
+ {
1183
+ code: "ka",
1184
+ name: "Georgian",
1185
+ nativeName: "ქართული"
1186
+ },
1187
+ {
1188
+ code: "kk",
1189
+ name: "Kazakh",
1190
+ nativeName: "Қазақ тілі"
1191
+ },
1192
+ {
1193
+ code: "ko",
1194
+ name: "Korean",
1195
+ nativeName: "한국어"
1196
+ },
1197
+ {
1198
+ code: "lv",
1199
+ name: "Latvian",
1200
+ nativeName: "Latviešu"
1201
+ },
1202
+ {
1203
+ code: "ms",
1204
+ name: "Malay",
1205
+ nativeName: "Bahasa Melayu"
1206
+ },
1207
+ {
1208
+ code: "nl",
1209
+ name: "Dutch",
1210
+ nativeName: "Nederlands"
1211
+ },
1212
+ {
1213
+ code: "pl",
1214
+ name: "Polish",
1215
+ nativeName: "Polski"
1216
+ },
1217
+ {
1218
+ code: "pt-BR",
1219
+ name: "Portuguese (Brazil)",
1220
+ nativeName: "Português (Brasil)"
1221
+ },
1222
+ {
1223
+ code: "pt-PT",
1224
+ name: "Portuguese (Portugal)",
1225
+ nativeName: "Português (Portugal)"
1226
+ },
1227
+ {
1228
+ code: "ro",
1229
+ name: "Romanian",
1230
+ nativeName: "Română"
1231
+ },
1232
+ {
1233
+ code: "ru",
1234
+ name: "Russian",
1235
+ nativeName: "Русский"
1236
+ },
1237
+ {
1238
+ code: "sk",
1239
+ name: "Slovak",
1240
+ nativeName: "Slovenčina"
1241
+ },
1242
+ {
1243
+ code: "sl",
1244
+ name: "Slovenian",
1245
+ nativeName: "Slovenščina"
1246
+ },
1247
+ {
1248
+ code: "sw",
1249
+ name: "Swahili",
1250
+ nativeName: "Kiswahili"
1251
+ },
1252
+ {
1253
+ code: "th",
1254
+ name: "Thai",
1255
+ nativeName: "ไทย"
1256
+ },
1257
+ {
1258
+ code: "tl",
1259
+ name: "Filipino",
1260
+ nativeName: "Filipino"
1261
+ },
1262
+ {
1263
+ code: "tr",
1264
+ name: "Turkish",
1265
+ nativeName: "Türkçe"
1266
+ },
1267
+ {
1268
+ code: "uk",
1269
+ name: "Ukrainian",
1270
+ nativeName: "Українська"
1271
+ },
1272
+ {
1273
+ code: "ur",
1274
+ name: "Urdu",
1275
+ nativeName: "اردو",
1276
+ rtl: true
1277
+ },
1278
+ {
1279
+ code: "uz",
1280
+ name: "Uzbek",
1281
+ nativeName: "Oʻzbek"
1282
+ },
1283
+ {
1284
+ code: "vi",
1285
+ name: "Vietnamese",
1286
+ nativeName: "Tiếng Việt"
1287
+ },
1288
+ {
1289
+ code: "zh-CN",
1290
+ name: "Chinese (Simplified)",
1291
+ nativeName: "简体中文"
1292
+ },
1293
+ {
1294
+ code: "zh-TW",
1295
+ name: "Chinese (Traditional)",
1296
+ nativeName: "繁體中文"
1297
+ }
1298
+ ];
1299
+ STARTER_LOCALES.map((l) => l.code);
1300
+ const STARTER_BY_CODE = new Map(STARTER_LOCALES.map((l) => [l.code, l]));
1301
+ /** Looks up a starter-catalog locale by BCP-47 code. */
1302
+ function findStarterLocale(code) {
1303
+ return STARTER_BY_CODE.get(code);
1304
+ }
1305
+ //#endregion
1306
+ //#region ../../packages/shared/src/locale-resolve.ts
1307
+ /**
1308
+ * Resolves a user- or agent-supplied locale code to a canonical, unambiguous
1309
+ * BCP-47 tag before it is persisted as a project target locale.
1310
+ *
1311
+ * Motivation: `add_locale` / `seed_starter_locales` used to accept ANY string
1312
+ * verbatim, so an LLM setting up sync could create a target like `pt` (a valid
1313
+ * ISO-639 language, but ambiguous — Brazil vs Portugal) or `cn` (not a real
1314
+ * BCP-47 tag at all). Both then rendered as raw codes in the UI. This resolver
1315
+ * gives callers actionable feedback: normalize known aliases/casing, and reject
1316
+ * ambiguous macro-languages so the caller must pick a concrete variant.
1317
+ */
1318
+ /**
1319
+ * Common non-BCP-47 shorthands people and LLMs reach for, mapped to the
1320
+ * catalog code they almost certainly mean. Keyed by lower-cased input.
1321
+ * Kept deliberately conservative — only unambiguous, well-known cases.
1322
+ */
1323
+ const ALIASES = {
1324
+ cn: "zh-CN",
1325
+ "zh-hans": "zh-CN",
1326
+ "zh-hant": "zh-TW",
1327
+ jp: "ja",
1328
+ kr: "ko",
1329
+ ua: "uk",
1330
+ cz: "cs",
1331
+ gr: "el",
1332
+ iw: "he",
1333
+ in: "id"
1334
+ };
1335
+ /** Catalog codes grouped by their primary language subtag (e.g. pt → [pt-BR, pt-PT]). */
1336
+ const VARIANTS_BY_LANG = (() => {
1337
+ const map = /* @__PURE__ */ new Map();
1338
+ for (const { code } of STARTER_LOCALES) {
1339
+ const lang = code.split("-")[0].toLowerCase();
1340
+ (map.get(lang) ?? map.set(lang, []).get(lang)).push(code);
1341
+ }
1342
+ return map;
1343
+ })();
1344
+ /** Canonicalizes casing/structure via the runtime CLDR (pt-br → pt-BR). */
1345
+ function canonicalize(input) {
1346
+ try {
1347
+ return Intl.getCanonicalLocales(input)[0] ?? null;
1348
+ } catch {
1349
+ return null;
1350
+ }
1351
+ }
1352
+ /** Whether a structurally-valid tag names a language the runtime actually knows. */
1353
+ function isRealLanguage(code) {
1354
+ try {
1355
+ return !!new Intl.DisplayNames(["en"], {
1356
+ type: "language",
1357
+ fallback: "none"
1358
+ }).of(code);
1359
+ } catch {
1360
+ return false;
1361
+ }
1362
+ }
1363
+ /**
1364
+ * Resolves a locale code to one of four outcomes. Callers should persist the
1365
+ * resolved `code` for `ok`/`normalized`, and reject `ambiguous`/`unknown` with
1366
+ * the provided message (and candidate list, for ambiguous).
1367
+ */
1368
+ function resolveLocaleCode(raw) {
1369
+ const input = raw.trim();
1370
+ if (!input) return {
1371
+ status: "unknown",
1372
+ input: raw,
1373
+ message: "Empty locale code."
1374
+ };
1375
+ const alias = ALIASES[input.toLowerCase()];
1376
+ if (alias) return {
1377
+ status: "normalized",
1378
+ code: alias,
1379
+ from: input,
1380
+ reason: `mapped "${input}" to catalog code "${alias}"`
1381
+ };
1382
+ const canonical = canonicalize(input);
1383
+ if (!canonical) return {
1384
+ status: "unknown",
1385
+ input,
1386
+ message: `"${input}" is not a valid BCP-47 locale code.`
1387
+ };
1388
+ if (findStarterLocale(canonical)) return canonical === input ? {
1389
+ status: "ok",
1390
+ code: canonical
1391
+ } : {
1392
+ status: "normalized",
1393
+ code: canonical,
1394
+ from: input,
1395
+ reason: `casing normalized to "${canonical}"`
1396
+ };
1397
+ const lang = canonical.split("-")[0].toLowerCase();
1398
+ const hasRegionOrScript = canonical.includes("-");
1399
+ const variants = VARIANTS_BY_LANG.get(lang) ?? [];
1400
+ if (!hasRegionOrScript) {
1401
+ if (variants.length > 1) return {
1402
+ status: "ambiguous",
1403
+ input,
1404
+ candidates: variants,
1405
+ message: `"${input}" is ambiguous — the catalog has ${variants.join(", ")}. Pick one.`
1406
+ };
1407
+ if (variants.length === 1) return {
1408
+ status: "normalized",
1409
+ code: variants[0],
1410
+ from: input,
1411
+ reason: `expanded "${input}" to "${variants[0]}"`
1412
+ };
1413
+ return isRealLanguage(canonical) ? {
1414
+ status: "ok",
1415
+ code: canonical
1416
+ } : {
1417
+ status: "unknown",
1418
+ input,
1419
+ message: `"${input}" is not a recognized language.`
1420
+ };
1421
+ }
1422
+ if (!isRealLanguage(canonical)) return {
1423
+ status: "unknown",
1424
+ input,
1425
+ message: `"${input}" is not a valid locale.`
1426
+ };
1427
+ return canonical === input ? {
1428
+ status: "ok",
1429
+ code: canonical
1430
+ } : {
1431
+ status: "normalized",
1432
+ code: canonical,
1433
+ from: input,
1434
+ reason: `canonicalized to "${canonical}"`
1435
+ };
1436
+ }
1437
+ /** Resolves a batch of codes, partitioning them into usable / normalized / rejected. */
1438
+ function resolveLocaleCodes(raw) {
1439
+ const out = {
1440
+ codes: [],
1441
+ normalized: [],
1442
+ rejected: []
1443
+ };
1444
+ const seen = /* @__PURE__ */ new Set();
1445
+ for (const code of raw) {
1446
+ const res = resolveLocaleCode(code);
1447
+ if (res.status === "ambiguous") out.rejected.push({
1448
+ input: res.input,
1449
+ message: res.message,
1450
+ candidates: res.candidates
1451
+ });
1452
+ else if (res.status === "unknown") out.rejected.push({
1453
+ input: res.input,
1454
+ message: res.message
1455
+ });
1456
+ else {
1457
+ if (res.status === "normalized") out.normalized.push({
1458
+ from: res.from,
1459
+ code: res.code,
1460
+ reason: res.reason
1461
+ });
1462
+ if (!seen.has(res.code)) {
1463
+ seen.add(res.code);
1464
+ out.codes.push(res.code);
1465
+ }
1466
+ }
1467
+ }
1468
+ return out;
1469
+ }
1470
+ //#endregion
1471
+ //#region ../../packages/shared/src/constants.ts
1472
+ /**
1473
+ * Every tier, mirroring the `OrgPlan` enum in the Prisma schema. This package
1474
+ * has no dependency on `@syntholoc/db` (it is imported by the CLI and the web
1475
+ * app, neither of which should pull in a database client), so the list is
1476
+ * restated here and pinned to the real enum by a compile-time assertion in the
1477
+ * API — see `apps/api/src/common/plan.assert.ts`.
1478
+ */
1479
+ const ORG_PLANS = [
1480
+ "FREE",
1481
+ "PRO",
1482
+ "ENTERPRISE"
1483
+ ];
1484
+ /**
1485
+ * True when the organization's plan may use metered features (site scanning,
1486
+ * the static code index).
1487
+ *
1488
+ * An exhaustive switch, not a membership test against a list of paid tiers: a
1489
+ * tier added to the enum and forgotten here becomes a type error rather than
1490
+ * silently free. That matters in the direction it fails — a new paid tier that
1491
+ * quietly reads as unpaid locks paying customers out of what they bought.
1492
+ */
1493
+ function isPaidPlan(plan) {
1494
+ switch (plan) {
1495
+ case "PRO":
1496
+ case "ENTERPRISE": return true;
1497
+ case "FREE": return false;
1498
+ default: return false;
1499
+ }
1500
+ }
1501
+ ORG_PLANS.filter(isPaidPlan);
1502
+ //#endregion
1503
+ //#region src/tools/locales.ts
1504
+ function registerLocaleTools(server, gql) {
1505
+ server.registerTool("list_locales", {
1506
+ title: "List locales",
1507
+ description: "Lists the target locales configured for a project.",
1508
+ inputSchema: { projectId: z.string() },
1509
+ annotations: {
1510
+ readOnlyHint: true,
1511
+ openWorldHint: true
1512
+ }
1513
+ }, async ({ projectId }) => jsonResult((await gql.request(LIST_LOCALES, { projectId })).locales));
1514
+ server.registerTool("add_locale", {
1515
+ title: "Add locale",
1516
+ 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\" → \"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).",
1517
+ inputSchema: {
1518
+ projectId: z.string(),
1519
+ code: z.string().optional().describe("BCP-47 code, e.g. \"de-DE\" or \"ru\". Alias: `locale`."),
1520
+ locale: z.string().optional().describe("Alias for `code`."),
1521
+ name: z.string().optional(),
1522
+ formality: z.string().optional(),
1523
+ gender: z.string().optional(),
1524
+ variant: z.string().optional()
1525
+ },
1526
+ annotations: { openWorldHint: true }
1527
+ }, async ({ projectId, code, locale, name, formality, gender, variant }) => {
1528
+ const resolvedCode = code ?? locale;
1529
+ if (!resolvedCode) return jsonResult({ 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." });
1530
+ const res = resolveLocaleCode(resolvedCode);
1531
+ if (res.status === "ambiguous") return jsonResult({
1532
+ error: res.message,
1533
+ hint: `Retry add_locale with one of: ${res.candidates.join(", ")}.`,
1534
+ candidates: res.candidates
1535
+ });
1536
+ if (res.status === "unknown") return jsonResult({
1537
+ error: res.message,
1538
+ hint: "Use a valid BCP-47 code, e.g. \"de-DE\" or \"zh-CN\"."
1539
+ });
1540
+ const input = {
1541
+ code: res.code,
1542
+ name,
1543
+ formality,
1544
+ gender,
1545
+ variant
1546
+ };
1547
+ const added = (await gql.request(ADD_LOCALE, {
1548
+ projectId,
1549
+ input
1550
+ })).addLocale;
1551
+ return res.status === "normalized" ? jsonResult({
1552
+ ...added,
1553
+ note: `Interpreted "${res.from}" as "${res.code}" (${res.reason}).`
1554
+ }) : jsonResult(added);
1555
+ });
1556
+ server.registerTool("seed_starter_locales", {
1557
+ title: "Seed starter locales",
1558
+ description: "Adds MULTIPLE target locales in one call — 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\".",
1559
+ inputSchema: {
1560
+ projectId: z.string(),
1561
+ codes: z.array(z.string()).optional().describe("BCP-47 codes to add, e.g. [\"ru\",\"de-DE\",\"fr\"].")
1562
+ },
1563
+ annotations: { openWorldHint: true }
1564
+ }, async ({ projectId, codes }) => {
1565
+ if (codes && codes.length > 0) {
1566
+ const resolved = resolveLocaleCodes(codes);
1567
+ if (resolved.rejected.length > 0) return jsonResult({
1568
+ error: "Some locale codes are ambiguous or invalid; nothing was added.",
1569
+ hint: "Fix the codes below and retry. For ambiguous ones, pick a listed candidate.",
1570
+ rejected: resolved.rejected
1571
+ });
1572
+ const seeded = (await gql.request(SEED_STARTER_LOCALES, {
1573
+ projectId,
1574
+ codes: resolved.codes
1575
+ })).seedStarterLocales;
1576
+ return resolved.normalized.length > 0 ? jsonResult({
1577
+ locales: seeded,
1578
+ normalized: resolved.normalized
1579
+ }) : jsonResult(seeded);
1580
+ }
1581
+ return jsonResult((await gql.request(SEED_STARTER_LOCALES, {
1582
+ projectId,
1583
+ codes
1584
+ })).seedStarterLocales);
1585
+ });
1586
+ server.registerTool("remove_locale", {
1587
+ title: "Remove locale",
1588
+ description: "Removes a target locale and its translations. Destructive — confirm first.",
1589
+ inputSchema: {
1590
+ projectId: z.string(),
1591
+ localeId: z.string()
1592
+ },
1593
+ annotations: {
1594
+ destructiveHint: true,
1595
+ openWorldHint: true
1596
+ }
1597
+ }, async ({ projectId, localeId }) => jsonResult((await gql.request(REMOVE_LOCALE, {
1598
+ projectId,
1599
+ id: localeId
1600
+ })).removeLocale));
1601
+ }
1602
+ //#endregion
1603
+ //#region src/tools/projects.ts
1604
+ function registerProjectTools(server, gql) {
1605
+ server.registerTool("list_organizations", {
1606
+ title: "List organizations",
1607
+ description: "Organizations you belong to (id, name, slug, your role). To act in a specific org, set the `syntholoc-org-id` request header (hosted transport); otherwise the earliest org is used.",
1608
+ inputSchema: {},
1609
+ annotations: {
1610
+ readOnlyHint: true,
1611
+ openWorldHint: true
1612
+ }
1613
+ }, async () => jsonResult((await gql.request(MY_ORGANIZATIONS)).myProjects.map((g) => ({
1614
+ ...g.organization,
1615
+ viewerRole: g.viewerRole
1616
+ }))));
1617
+ server.registerTool("list_projects", {
1618
+ title: "List projects",
1619
+ description: "Lists localization projects in the active organization (paginated).",
1620
+ inputSchema: {
1621
+ skip: z.number().int().min(0).default(0),
1622
+ take: z.number().int().min(1).max(100).default(25)
1623
+ },
1624
+ annotations: {
1625
+ readOnlyHint: true,
1626
+ openWorldHint: true
1627
+ }
1628
+ }, async ({ skip, take }) => jsonResult((await gql.request(LIST_PROJECTS, {
1629
+ skip,
1630
+ take
1631
+ })).projects));
1632
+ server.registerTool("get_project", {
1633
+ title: "Get project",
1634
+ description: "Project settings plus analytics, including per-locale progress with the `untranslated` count used to decide the batch threshold (see get_config).",
1635
+ inputSchema: { projectId: z.string() },
1636
+ annotations: {
1637
+ readOnlyHint: true,
1638
+ openWorldHint: true
1639
+ }
1640
+ }, async ({ projectId }) => jsonResult(await gql.request(GET_PROJECT, { id: projectId })));
1641
+ server.registerTool("create_project", {
1642
+ title: "Create project",
1643
+ description: "Creates a localization project. `sourceLocale` defaults to \"en\".",
1644
+ inputSchema: {
1645
+ name: z.string(),
1646
+ sourceLocale: z.string().default("en"),
1647
+ description: z.string().optional(),
1648
+ context: z.string().optional(),
1649
+ requireReview: z.boolean().optional(),
1650
+ qaCriticEnabled: z.boolean().optional()
1651
+ },
1652
+ annotations: { openWorldHint: true }
1653
+ }, async (input) => jsonResult((await gql.request(CREATE_PROJECT, { input })).createProject));
1654
+ server.registerTool("update_project", {
1655
+ title: "Update project",
1656
+ description: "Updates project settings (name, source locale, review/QA flags, context).",
1657
+ inputSchema: {
1658
+ projectId: z.string(),
1659
+ name: z.string().optional(),
1660
+ sourceLocale: z.string().optional(),
1661
+ description: z.string().optional(),
1662
+ context: z.string().optional(),
1663
+ requireReview: z.boolean().optional(),
1664
+ qaCriticEnabled: z.boolean().optional(),
1665
+ exportApprovedOnly: z.boolean().optional(),
1666
+ sortKeysAlphabetically: z.boolean().optional(),
1667
+ status: z.string().optional()
1668
+ },
1669
+ annotations: {
1670
+ idempotentHint: true,
1671
+ openWorldHint: true
1672
+ }
1673
+ }, async ({ projectId, ...input }) => jsonResult((await gql.request(UPDATE_PROJECT, {
1674
+ id: projectId,
1675
+ input
1676
+ })).updateProject));
1677
+ }
1678
+ //#endregion
1679
+ //#region src/generated/input-schemas.ts
1680
+ const isDefinedNonNullAny = (v) => v !== void 0 && v !== null;
1681
+ z.any().refine((v) => isDefinedNonNullAny(v));
1682
+ z.enum([
1683
+ "DYNAMIC",
1684
+ "NOT_CAPTURED",
1685
+ "NOT_IN_CODE",
1686
+ "SCREENSHOT",
1687
+ "SERVER",
1688
+ "TEST",
1689
+ "UNKNOWN"
1690
+ ]);
1691
+ z.enum(["ALL", "UNREVIEWED"]);
1692
+ z.enum([
1693
+ "ALL",
1694
+ "DRAFT",
1695
+ "UNAPPROVED",
1696
+ "UNTRANSLATED"
1697
+ ]);
1698
+ function ConfigureProviderInputSchema() {
1699
+ return z.object({
1700
+ apiKey: z.string(),
1701
+ baseUrl: z.string().nullish(),
1702
+ defaultModel: z.string().nullish(),
1703
+ label: z.string(),
1704
+ type: z.string()
1705
+ });
1706
+ }
1707
+ //#endregion
1708
+ //#region src/tools/providers.ts
1709
+ function registerProviderTools(server, gql) {
1710
+ server.registerTool("list_llm_providers", {
1711
+ title: "List LLM providers",
1712
+ description: "Configured LLM providers for the org (never exposes the API key).",
1713
+ inputSchema: {},
1714
+ annotations: {
1715
+ readOnlyHint: true,
1716
+ openWorldHint: true
1717
+ }
1718
+ }, async () => jsonResult((await gql.request(LIST_LLM_PROVIDERS)).llmProviders));
1719
+ server.registerTool("configure_llm_provider", {
1720
+ title: "Configure LLM provider",
1721
+ description: "Adds a BYOK LLM provider (APISTOCK | OPENROUTER | OPENAI | ANTHROPIC). The key is stored encrypted at rest.",
1722
+ inputSchema: {
1723
+ ...ConfigureProviderInputSchema().shape,
1724
+ type: z.enum([
1725
+ "APISTOCK",
1726
+ "OPENROUTER",
1727
+ "OPENAI",
1728
+ "ANTHROPIC"
1729
+ ])
1730
+ },
1731
+ annotations: { openWorldHint: true }
1732
+ }, async (input) => jsonResult((await gql.request(CONFIGURE_LLM_PROVIDER, { input })).configureLlmProvider));
1733
+ }
1734
+ //#endregion
1735
+ //#region src/tools/qa.ts
1736
+ function registerQaTools(server, gql) {
1737
+ server.registerTool("run_locale_qa", {
1738
+ title: "Run locale QA",
1739
+ description: "Queues an LLM QA-critic pass over a locale's non-approved translations. `scope` UNREVIEWED (default) critiques only translations not yet reviewed at their current text; ALL re-critiques every one. Returns tracking `jobIds`.",
1740
+ inputSchema: {
1741
+ projectId: z.string(),
1742
+ localeId: z.string(),
1743
+ scope: z.enum(["UNREVIEWED", "ALL"]).optional()
1744
+ },
1745
+ annotations: { openWorldHint: true }
1746
+ }, async (vars) => jsonResult((await gql.request(RUN_LOCALE_QA, vars)).runLocaleQa));
1747
+ server.registerTool("run_qa", {
1748
+ title: "Run QA on a translation",
1749
+ description: "Runs QA on one translation and returns its score and issues (synchronous).",
1750
+ inputSchema: {
1751
+ projectId: z.string(),
1752
+ translationId: z.string()
1753
+ },
1754
+ annotations: { openWorldHint: true }
1755
+ }, async (vars) => jsonResult((await gql.request(RUN_QA, vars)).runQa));
1756
+ server.registerTool("list_qa_issues", {
1757
+ title: "List QA issues",
1758
+ description: "Open QA issues for a locale, plus the issue-type facets (filter tabs).",
1759
+ inputSchema: {
1760
+ projectId: z.string(),
1761
+ localeId: z.string(),
1762
+ type: z.string().optional(),
1763
+ skip: z.number().int().min(0).default(0),
1764
+ take: z.number().int().min(1).max(100).default(25)
1765
+ },
1766
+ annotations: {
1767
+ readOnlyHint: true,
1768
+ openWorldHint: true
1769
+ }
1770
+ }, async (vars) => jsonResult(await gql.request(LIST_QA_ISSUES, vars)));
1771
+ }
1772
+ //#endregion
1773
+ //#region src/tools/styleguides.ts
1774
+ function registerStyleGuideTools(server, gql) {
1775
+ server.registerTool("list_style_guides", {
1776
+ title: "List style guides",
1777
+ description: "Lists style guides (project-scoped if projectId given, else org-wide).",
1778
+ inputSchema: { projectId: z.string().optional() },
1779
+ annotations: {
1780
+ readOnlyHint: true,
1781
+ openWorldHint: true
1782
+ }
1783
+ }, async ({ projectId }) => jsonResult((await gql.request(LIST_STYLE_GUIDES, { projectId })).styleGuides));
1784
+ server.registerTool("generate_style_guide", {
1785
+ title: "Generate style guide (draft)",
1786
+ description: "Drafts a tone/rules/prompt-fragment style guide from the project (optionally per locale). Returns a draft — persist it with create_style_guide.",
1787
+ inputSchema: {
1788
+ projectId: z.string(),
1789
+ localeCode: z.string().optional()
1790
+ },
1791
+ annotations: { openWorldHint: true }
1792
+ }, async ({ projectId, localeCode }) => jsonResult((await gql.request(GENERATE_STYLE_GUIDE, { input: {
1793
+ projectId,
1794
+ localeCode
1795
+ } })).generateStyleGuide));
1796
+ server.registerTool("create_style_guide", {
1797
+ title: "Create style guide",
1798
+ description: "Persists a style guide (tone, rules JSON, prompt fragment), project- or locale-scoped.",
1799
+ inputSchema: {
1800
+ name: z.string(),
1801
+ projectId: z.string().optional(),
1802
+ localeCode: z.string().optional(),
1803
+ tone: z.string().optional(),
1804
+ promptFragment: z.string().optional(),
1805
+ rules: z.array(z.string()).optional()
1806
+ },
1807
+ annotations: { openWorldHint: true }
1808
+ }, async (input) => jsonResult((await gql.request(CREATE_STYLE_GUIDE, { input })).createStyleGuide));
1809
+ }
1810
+ //#endregion
1811
+ //#region src/tools/tm.ts
1812
+ /** Translation Memory: concordance search, coverage stats, and browsing. */
1813
+ function registerTmTools(server, gql) {
1814
+ server.registerTool("tm_search", {
1815
+ title: "Search translation memory",
1816
+ description: "Concordance search — the nearest prior translations for a source string in a target locale, with cosine distance (0 = identical). Scope to a project with `projectId` (includes its group siblings); omit for the whole organization. Requires an embedding-capable provider.",
1817
+ inputSchema: {
1818
+ sourceText: z.string(),
1819
+ targetLocale: z.string(),
1820
+ projectId: z.string().optional(),
1821
+ limit: z.number().int().min(1).max(20).default(5)
1822
+ },
1823
+ annotations: {
1824
+ readOnlyHint: true,
1825
+ openWorldHint: true
1826
+ }
1827
+ }, async ({ sourceText, targetLocale, projectId, limit }) => jsonResult((await gql.request(TM_SEARCH, {
1828
+ sourceText,
1829
+ targetLocale,
1830
+ projectId,
1831
+ limit
1832
+ })).tmSearch));
1833
+ server.registerTool("tm_stats", {
1834
+ title: "Translation memory stats",
1835
+ description: "Entry counts per target locale (org-wide, or for a project with `projectId`).",
1836
+ inputSchema: { projectId: z.string().optional() },
1837
+ annotations: {
1838
+ readOnlyHint: true,
1839
+ openWorldHint: true
1840
+ }
1841
+ }, async ({ projectId }) => jsonResult((await gql.request(TM_STATS, { projectId })).tmStats));
1842
+ server.registerTool("list_tm", {
1843
+ title: "Browse translation memory",
1844
+ description: "Lists stored TM entries (paginated, newest first), optionally filtered by project and locale.",
1845
+ inputSchema: {
1846
+ projectId: z.string().optional(),
1847
+ targetLocale: z.string().optional(),
1848
+ skip: z.number().int().min(0).default(0),
1849
+ take: z.number().int().min(1).max(100).default(25)
1850
+ },
1851
+ annotations: {
1852
+ readOnlyHint: true,
1853
+ openWorldHint: true
1854
+ }
1855
+ }, async (vars) => jsonResult((await gql.request(TM_ENTRIES, vars)).tmEntries));
1856
+ }
1857
+ //#endregion
1858
+ //#region src/tools/translations.ts
1859
+ const FORMATS = [
1860
+ "JSON",
1861
+ "YAML",
1862
+ "MARKDOWN",
1863
+ "MDX",
1864
+ "CSV",
1865
+ "PO",
1866
+ "XLIFF",
1867
+ "ANDROID_XML",
1868
+ "IOS_STRINGS",
1869
+ "IOS_STRINGSDICT",
1870
+ "IOS_XCSTRINGS",
1871
+ "NJK"
1872
+ ];
1873
+ /** Document formats: exported whole via `export_document`, imported by position. */
1874
+ const DOCUMENT_FORMATS = /* @__PURE__ */ new Set(["MARKDOWN", "MDX"]);
1875
+ function registerTranslationTools(server, gql) {
1876
+ server.registerTool("translate_locale", {
1877
+ title: "Translate locale",
1878
+ description: "Queues AI translation of a locale (scope ALL | UNAPPROVED | UNTRANSLATED) and returns the tracking `jobIds`. Executes immediately and unconditionally — the batch threshold is a client-side policy for PROACTIVE syncs, not for this tool.",
1879
+ inputSchema: {
1880
+ projectId: z.string(),
1881
+ localeId: z.string(),
1882
+ scope: z.enum([
1883
+ "ALL",
1884
+ "UNAPPROVED",
1885
+ "UNTRANSLATED"
1886
+ ]).default("UNTRANSLATED")
1887
+ },
1888
+ annotations: { openWorldHint: true }
1889
+ }, async (vars) => jsonResult((await gql.request(TRANSLATE_LOCALE, vars)).translateLocale));
1890
+ server.registerTool("translate_key", {
1891
+ title: "Translate key",
1892
+ description: "Queues AI translation of a single key into a locale. Returns tracking `jobIds`.",
1893
+ inputSchema: {
1894
+ projectId: z.string(),
1895
+ keyId: z.string(),
1896
+ localeId: z.string()
1897
+ },
1898
+ annotations: { openWorldHint: true }
1899
+ }, async (vars) => jsonResult((await gql.request(TRANSLATE_KEY, vars)).translateKey));
1900
+ server.registerTool("set_translation", {
1901
+ title: "Set translation",
1902
+ description: "Manually sets the translation text for a key in a locale.",
1903
+ inputSchema: {
1904
+ projectId: z.string(),
1905
+ keyId: z.string(),
1906
+ localeId: z.string(),
1907
+ text: z.string(),
1908
+ origin: z.string().optional()
1909
+ },
1910
+ annotations: {
1911
+ idempotentHint: true,
1912
+ openWorldHint: true
1913
+ }
1914
+ }, async ({ projectId, keyId, localeId, text, origin }) => jsonResult((await gql.request(SET_TRANSLATION, {
1915
+ projectId,
1916
+ input: {
1917
+ keyId,
1918
+ localeId,
1919
+ text,
1920
+ origin
1921
+ }
1922
+ })).setTranslation));
1923
+ server.registerTool("list_translations", {
1924
+ title: "List translations",
1925
+ description: "Lists translations for a locale with status, origin, model and QA score.",
1926
+ inputSchema: {
1927
+ projectId: z.string(),
1928
+ localeId: z.string(),
1929
+ skip: z.number().int().min(0).default(0),
1930
+ take: z.number().int().min(1).max(200).default(25)
1931
+ },
1932
+ annotations: {
1933
+ readOnlyHint: true,
1934
+ openWorldHint: true
1935
+ }
1936
+ }, async (vars) => jsonResult((await gql.request(LIST_TRANSLATIONS, vars)).translations));
1937
+ server.registerTool("approve_translation", {
1938
+ title: "Approve translation",
1939
+ description: "Marks a translation APPROVED.",
1940
+ inputSchema: {
1941
+ projectId: z.string(),
1942
+ translationId: z.string()
1943
+ },
1944
+ annotations: {
1945
+ idempotentHint: true,
1946
+ openWorldHint: true
1947
+ }
1948
+ }, async ({ projectId, translationId }) => jsonResult((await gql.request(APPROVE_TRANSLATION, {
1949
+ projectId,
1950
+ id: translationId
1951
+ })).approveTranslation));
1952
+ server.registerTool("reject_translation", {
1953
+ title: "Reject translation",
1954
+ description: "Marks a translation REJECTED (queued for re-translation).",
1955
+ inputSchema: {
1956
+ projectId: z.string(),
1957
+ translationId: z.string()
1958
+ },
1959
+ annotations: {
1960
+ idempotentHint: true,
1961
+ openWorldHint: true
1962
+ }
1963
+ }, async ({ projectId, translationId }) => jsonResult((await gql.request(REJECT_TRANSLATION, {
1964
+ projectId,
1965
+ id: translationId
1966
+ })).rejectTranslation));
1967
+ server.registerTool("export_locale", {
1968
+ title: "Export locale",
1969
+ description: "Exports a locale to a file string in the chosen format — 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 — reliable. `keyPrefix` is the legacy name-prefix heuristic. For MARKDOWN and MDX use `export_document` instead: a document is spliced into its original file, which the server reads from the connected repository.",
1970
+ inputSchema: {
1971
+ projectId: z.string(),
1972
+ localeId: z.string(),
1973
+ format: z.enum(FORMATS).default("JSON"),
1974
+ onlyApproved: z.boolean().optional(),
1975
+ sourceFile: z.string().optional().describe("Real source file path to export, e.g. \"messages/en/home.json\" (reliable, preferred)."),
1976
+ keyPrefix: z.string().optional().describe("Legacy namespace prefix, e.g. \"home\".")
1977
+ },
1978
+ annotations: {
1979
+ readOnlyHint: true,
1980
+ openWorldHint: true
1981
+ }
1982
+ }, async (vars) => jsonResult({ content: (await gql.request(EXPORT_LOCALE, vars)).exportLocale }));
1983
+ server.registerTool("export_document", {
1984
+ title: "Export document locale",
1985
+ description: "Exports one locale of a Markdown or MDX source document. Unlike `export_locale` this returns the whole translated document: the server reads the original file from the connected repository and splices the translations into it, so headings, list markers, code fences and indentation are preserved byte for byte. Segments with no translation keep their source text.",
1986
+ inputSchema: {
1987
+ projectId: z.string(),
1988
+ localeId: z.string(),
1989
+ sourceFile: z.string().describe("Path of the source document in the repository, e.g. \"docs/guide.md\"."),
1990
+ repositoryId: z.string().optional().describe("Only needed when the project has several repositories and the path is ambiguous."),
1991
+ onlyApproved: z.boolean().optional()
1992
+ },
1993
+ annotations: {
1994
+ readOnlyHint: true,
1995
+ openWorldHint: true
1996
+ }
1997
+ }, async (vars) => {
1998
+ if (!vars.sourceFile.trim()) return errorResult("export_document needs a sourceFile — the path of the document in the repository.");
1999
+ return jsonResult({ content: (await gql.request(EXPORT_DOCUMENT_LOCALE, vars)).exportDocumentLocale });
2000
+ });
2001
+ server.registerTool("import_translations", {
2002
+ title: "Import translations",
2003
+ 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. For MARKDOWN and MDX `sourceFile` is REQUIRED: a translated document has translated headings, so it is matched to the source by segment position and the two must have the same structure.",
2004
+ inputSchema: {
2005
+ projectId: z.string(),
2006
+ localeId: z.string(),
2007
+ content: z.string(),
2008
+ format: z.enum(FORMATS),
2009
+ keyPrefix: z.string().optional().describe("Namespace prefix, e.g. \"home\" (per-file, multi-file projects)."),
2010
+ sourceFile: z.string().optional().describe("Source file these translations belong to; REQUIRED for MARKDOWN/MDX, e.g. \"docs/guide.md\"."),
2011
+ autoApprove: z.boolean().default(false),
2012
+ conflict: z.enum([
2013
+ "OVERWRITE",
2014
+ "SKIP_EXISTING",
2015
+ "KEEP_APPROVED"
2016
+ ]).default("OVERWRITE"),
2017
+ dryRun: z.boolean().default(false)
2018
+ },
2019
+ annotations: {
2020
+ idempotentHint: true,
2021
+ openWorldHint: true
2022
+ }
2023
+ }, async ({ projectId, localeId, content, format, keyPrefix, sourceFile, autoApprove, conflict, dryRun }) => {
2024
+ if (DOCUMENT_FORMATS.has(format) && !sourceFile?.trim()) return errorResult(`${format} needs a sourceFile — a translated document is aligned to its source by position.`);
2025
+ return jsonResult((await gql.request(IMPORT_TRANSLATIONS, {
2026
+ projectId,
2027
+ localeId,
2028
+ input: {
2029
+ content,
2030
+ format,
2031
+ keyPrefix,
2032
+ sourceFile,
2033
+ autoApprove,
2034
+ conflict,
2035
+ dryRun
2036
+ }
2037
+ })).importTranslations);
2038
+ });
2039
+ }
2040
+ //#endregion
2041
+ //#region src/tools/index.ts
2042
+ /** Tool sets by product area — the unit of `toolSets` (Crowdin's `crowdin-tool-sets`). */
2043
+ const TOOL_SETS = {
2044
+ ota: registerOtaTools,
2045
+ config: registerConfigTools,
2046
+ projects: registerProjectTools,
2047
+ locales: registerLocaleTools,
2048
+ keys: registerKeyTools,
2049
+ translations: registerTranslationTools,
2050
+ qa: registerQaTools,
2051
+ glossary: registerGlossaryTools,
2052
+ styleguides: registerStyleGuideTools,
2053
+ tm: registerTmTools,
2054
+ git: registerGitTools,
2055
+ providers: registerProviderTools,
2056
+ jobs: registerJobTools,
2057
+ analytics: registerAnalyticsTools
2058
+ };
2059
+ /** The valid tool-set names clients may request. */
2060
+ const TOOL_SET_NAMES = Object.keys(TOOL_SETS);
2061
+ /** Resolves which tool sets to load. Empty / undefined / ['all'] → every set. */
2062
+ function selectSets(requested) {
2063
+ if (!requested || requested.length === 0 || requested.includes("all")) return TOOL_SET_NAMES;
2064
+ return TOOL_SET_NAMES.filter((name) => requested.includes(name));
2065
+ }
2066
+ /**
2067
+ * Registers the Syntholoc tool surface, honouring optional server-side filters:
2068
+ * tool-set (group) selection, read-only mode, and name-pattern filtering.
2069
+ */
2070
+ function registerAllTools(server, gql, opts = {}) {
2071
+ const target = applyToolFilter(server, opts);
2072
+ for (const name of selectSets(opts.toolSets)) TOOL_SETS[name]?.(target, gql);
2073
+ }
2074
+ //#endregion
2075
+ //#region package.json
2076
+ var version = "0.6.1";
2077
+ //#endregion
2078
+ //#region src/build-server.ts
2079
+ /**
2080
+ * Builds a fully-configured Syntholoc MCP server (tools + resources + prompts)
2081
+ * bound to the given GraphQL client. Shared by the stdio entrypoint and the
2082
+ * NestJS remote `/mcp` (Streamable HTTP) endpoint.
2083
+ *
2084
+ * `opts` optionally narrows the exposed tool surface (tool sets, read-only mode,
2085
+ * name-pattern filter) so the agent sees only what a given client needs.
2086
+ */
2087
+ function buildMcpServer(gql, opts = {}) {
2088
+ const server = new McpServer({
2089
+ name: "syntholoc",
2090
+ version
2091
+ });
2092
+ registerAllTools(server, gql, opts);
2093
+ registerResources(server, gql);
2094
+ registerPrompts(server);
2095
+ return server;
2096
+ }
2097
+ //#endregion
2098
+ export { TOOL_SET_NAMES as n, parseList as r, buildMcpServer as t };