@syntholoc/mcp 0.5.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.
- package/README.md +5 -0
- package/dist/build-server-3r0JYcI7.js +2098 -0
- package/dist/build-server.d.ts +18 -16
- package/dist/build-server.js +2 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +25 -26
- package/package.json +6 -5
- package/dist/chunk-V5AAF2RJ.js +0 -1513
|
@@ -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 };
|