@syntholoc/mcp 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,34 @@ never passed as a tool argument.
22
22
  |---|---|---|
23
23
  | `SYNTHOLOC_API_KEY` | – | Machine API key (`x-api-key`). Required for most tools. |
24
24
  | `SYNTHOLOC_API_URL` | `https://api.syntholoc.com/graphql` | GraphQL endpoint. Override to target a self-hosted or local instance. |
25
+ | `SYNTHOLOC_TOOL_SETS` | *(all)* | Comma-separated tool sets to load (`projects,keys,translations,qa,glossary,styleguides,tm,git,providers,jobs,analytics,locales,config`). Fewer tools = a more focused agent. |
26
+ | `SYNTHOLOC_READ_ONLY` | `false` | Set `true` to expose only read-only tools (list/get) — safe exploration and onboarding. |
27
+ | `SYNTHOLOC_TOOL_FILTER` | *(none)* | Comma-separated wildcard patterns matched against tool names (e.g. `list_*,get_*`). |
28
+
29
+ > The remote (HTTP) transport accepts the same controls as request headers:
30
+ > `syntholoc-tool-sets`, `syntholoc-read-only`, `syntholoc-tool-filter`. The hosted
31
+ > endpoint is versioned at `/mcp/v1` (the legacy `/mcp` path still works).
32
+
33
+ ### Choosing an organization (hosted / OAuth)
34
+
35
+ An OAuth token is per-user and may belong to several organizations. By default the
36
+ earliest one is used. To pin a specific org, send the `syntholoc-org-id` header
37
+ (verified against your membership). List the ids with the `list_organizations` tool
38
+ or the `syntholoc://organizations` resource, then add it to your client config, e.g.:
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "syntholoc": {
44
+ "type": "http",
45
+ "url": "https://api.syntholoc.com/mcp/v1",
46
+ "headers": { "syntholoc-org-id": "<org-id>" }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ API-key (stdio) clients don't need this — the key is already scoped to one org.
25
53
 
26
54
  ## What you can do
27
55
 
@@ -1,11 +1,33 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { GraphQLClient } from '@syntholoc/sdk';
3
3
 
4
+ /**
5
+ * Server-side tool-filtering options, mirroring Crowdin's `crowdin-tool-sets` /
6
+ * `crowdin-read-only` / `crowdin-tool-filter` headers. Applied before the tool
7
+ * list is returned so the agent only sees a focused surface.
8
+ */
9
+ interface ToolFilterOptions {
10
+ /** Tool-set names to load (see TOOL_SET_NAMES). Empty / undefined / ['all'] loads everything. */
11
+ toolSets?: string[];
12
+ /** When true, only read-only tools (annotations.readOnlyHint) are exposed. */
13
+ readOnly?: boolean;
14
+ /** Wildcard patterns matched against tool names (e.g. "list_*", "project*"). */
15
+ toolFilter?: string[];
16
+ }
17
+ /** Parses a comma-separated header/env value into a trimmed, non-empty string list. */
18
+ declare function parseList(value: string | undefined | null): string[] | undefined;
19
+
20
+ /** The valid tool-set names clients may request. */
21
+ declare const TOOL_SET_NAMES: string[];
22
+
4
23
  /**
5
24
  * Builds a fully-configured Syntholoc MCP server (tools + resources + prompts)
6
25
  * bound to the given GraphQL client. Shared by the stdio entrypoint and the
7
26
  * NestJS remote `/mcp` (Streamable HTTP) endpoint.
27
+ *
28
+ * `opts` optionally narrows the exposed tool surface (tool sets, read-only mode,
29
+ * name-pattern filter) so the agent sees only what a given client needs.
8
30
  */
9
- declare function buildMcpServer(gql: GraphQLClient): McpServer;
31
+ declare function buildMcpServer(gql: GraphQLClient, opts?: ToolFilterOptions): McpServer;
10
32
 
11
- export { buildMcpServer };
33
+ export { TOOL_SET_NAMES, type ToolFilterOptions, buildMcpServer, parseList };
@@ -1,6 +1,10 @@
1
1
  import {
2
- buildMcpServer
3
- } from "./chunk-RJ5GG3TT.js";
2
+ TOOL_SET_NAMES,
3
+ buildMcpServer,
4
+ parseList
5
+ } from "./chunk-DZKWHV2O.js";
4
6
  export {
5
- buildMcpServer
7
+ TOOL_SET_NAMES,
8
+ buildMcpServer,
9
+ parseList
6
10
  };
@@ -1,6 +1,35 @@
1
1
  // src/build-server.ts
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
 
4
+ // src/filter.ts
5
+ function wildcardToRegExp(pattern) {
6
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
7
+ return new RegExp(`^${escaped}$`);
8
+ }
9
+ function matchesAnyPattern(name, patterns) {
10
+ return patterns.some((re) => re.test(name));
11
+ }
12
+ function applyToolFilter(server, opts) {
13
+ const patterns = (opts.toolFilter ?? []).filter(Boolean).map(wildcardToRegExp);
14
+ if (!opts.readOnly && patterns.length === 0) return server;
15
+ const original = server.registerTool.bind(server);
16
+ const filtered = (name, config, handler) => {
17
+ const readOnly = config.annotations?.readOnlyHint;
18
+ if (opts.readOnly && !readOnly) return void 0;
19
+ if (patterns.length > 0 && !matchesAnyPattern(name, patterns)) {
20
+ return void 0;
21
+ }
22
+ return original(name, config, handler);
23
+ };
24
+ server.registerTool = filtered;
25
+ return server;
26
+ }
27
+ function parseList(value) {
28
+ if (!value) return void 0;
29
+ const items = value.split(",").map((s) => s.trim()).filter(Boolean);
30
+ return items.length > 0 ? items : void 0;
31
+ }
32
+
4
33
  // src/prompts.ts
5
34
  import { z } from "zod";
6
35
  function userMessage(text) {
@@ -20,10 +49,15 @@ function registerPrompts(server) {
20
49
  "1. Read `.syntholoc/config.json` for the source file(s) and project.",
21
50
  projectId ? ` (Use project ${projectId} if config is missing.)` : "",
22
51
  "2. From `git diff` on the source file(s), collect added/changed keys and `upsert_keys` (dryRun first to preview).",
52
+ " - MULTI-FILE / namespaced project (a file per namespace, e.g. messages/en/home.json): import each",
53
+ " changed file with `import_source_strings` passing `keyPrefix` = the namespace (the filename",
54
+ ' without extension, e.g. "home"). This keeps overlapping keys from different files distinct.',
23
55
  "3. Call `get_config` for `maxBulkSize`. For EACH target locale check `locale_progress` \u2192 `untranslated`.",
24
56
  " - If untranslated >= maxBulkSize: `translate_locale` (scope UNTRANSLATED) and `wait_for_job`.",
25
57
  " - If below the threshold: tell me the counts and ASK whether to translate now or accumulate.",
26
58
  "4. `export_locale` for each translated locale and write the result back into the mapped local files.",
59
+ " - Namespaced project: call `export_locale` once per namespace with the same `keyPrefix` (the prefix",
60
+ " is stripped on export) and write each result to its file, e.g. messages/{locale}/home.json.",
27
61
  "5. Summarize keys added, locales translated, and the cost from the job usage."
28
62
  ].filter(Boolean).join("\n")
29
63
  )
@@ -55,10 +89,21 @@ function registerPrompts(server) {
55
89
  () => userMessage(
56
90
  [
57
91
  "Set up Syntholoc for this repository:",
58
- "1. Find the i18n source files (e.g. messages/en.json, locales/en/*.json) and infer the source locale.",
59
- "2. `create_project`, then `seed_starter_locales` (or `add_locale`) for the target languages.",
60
- "3. `import_source_strings` from the source file(s).",
61
- "4. Write `.syntholoc/config.json` mapping source \u2192 translation file patterns and the project id.",
92
+ "1. Find the i18n source files and infer the source locale. Note the LAYOUT:",
93
+ " - Single file per locale (e.g. messages/en.json) \u2192 one mapping, no namespace.",
94
+ " - A directory per locale with many namespace files (e.g. messages/en/home.json, common.json) \u2192",
95
+ " each file is a NAMESPACE. Syntholoc stores a flat key space, so import each file under a",
96
+ " `keyPrefix` = its namespace to avoid collisions between files.",
97
+ "2. `create_project`, then `seed_starter_locales` (or `add_locale`) for the target languages. Use valid",
98
+ ' BCP-47 codes (e.g. "zh-CN", not "cn"); `add_locale` accepts `code` or `locale`.',
99
+ "3. Import the source strings:",
100
+ " - Single file \u2192 `import_source_strings` once.",
101
+ " - Namespaced \u2192 `import_source_strings` once PER FILE with `keyPrefix` set to the namespace",
102
+ " (filename without extension). Keys become e.g. `home.title`, `common.title`.",
103
+ "4. Write `.syntholoc/config.json` (schema https://syntholoc.com/schemas/config.json): the `projectId` and",
104
+ " a Crowdin-style `files` array of `{ source, translation }` patterns with a `{locale}` placeholder,",
105
+ ' e.g. { "source": "messages/en/home.json", "translation": "messages/{locale}/home.json", "namespace": "home" }.',
106
+ ' Do NOT invent fields like `i18nDir`, `format: "nested-dirs"`, or `localeMapping` \u2014 they are not read.',
62
107
  "5. Offer to run the localize_changed flow next."
63
108
  ].join("\n")
64
109
  )
@@ -67,7 +112,20 @@ function registerPrompts(server) {
67
112
 
68
113
  // src/resources.ts
69
114
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
70
- import { CONFIG_RESOURCE, PROJECTS_RESOURCE, PROJECT_RESOURCE } from "@syntholoc/sdk";
115
+ import {
116
+ CONFIG_RESOURCE,
117
+ LIST_GLOSSARY,
118
+ LIST_JOBS,
119
+ LIST_KEYS,
120
+ LIST_LOCALES,
121
+ LIST_QA_ISSUES,
122
+ LIST_STYLE_GUIDES,
123
+ MY_ORGANIZATIONS,
124
+ PROJECT_ANALYTICS,
125
+ TM_STATS,
126
+ PROJECT_RESOURCE,
127
+ PROJECTS_RESOURCE
128
+ } from "@syntholoc/sdk";
71
129
  function json(uri, data) {
72
130
  return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
73
131
  }
@@ -82,6 +140,16 @@ function registerResources(server, gql) {
82
140
  },
83
141
  async (uri) => json(uri, (await gql.request(CONFIG_RESOURCE)).appInfo)
84
142
  );
143
+ server.registerResource(
144
+ "organizations",
145
+ "syntholoc://organizations",
146
+ {
147
+ title: "Organizations",
148
+ description: "Organizations you belong to (id, name, slug, your role). Pin one for a request with the `syntholoc-org-id` header; otherwise the earliest org is used.",
149
+ mimeType: "application/json"
150
+ },
151
+ async (uri) => json(uri, (await gql.request(MY_ORGANIZATIONS)).myProjects.map((g) => ({ ...g.organization, viewerRole: g.viewerRole })))
152
+ );
85
153
  server.registerResource(
86
154
  "projects",
87
155
  "syntholoc://projects",
@@ -102,11 +170,99 @@ function registerResources(server, gql) {
102
170
  },
103
171
  async (uri, { projectId }) => json(uri, await gql.request(PROJECT_RESOURCE, { id: String(projectId) }))
104
172
  );
173
+ server.registerResource(
174
+ "project-keys",
175
+ new ResourceTemplate("syntholoc://project/{projectId}/keys", { list: void 0 }),
176
+ {
177
+ title: "Project source keys",
178
+ description: "Source keys in a project (first 100).",
179
+ mimeType: "application/json"
180
+ },
181
+ async (uri, { projectId }) => json(uri, (await gql.request(LIST_KEYS, { projectId: String(projectId), skip: 0, take: 100 })).keys)
182
+ );
183
+ server.registerResource(
184
+ "project-locales",
185
+ new ResourceTemplate("syntholoc://project/{projectId}/locales", { list: void 0 }),
186
+ {
187
+ title: "Project locales",
188
+ description: "Target locales configured for a project.",
189
+ mimeType: "application/json"
190
+ },
191
+ async (uri, { projectId }) => json(uri, (await gql.request(LIST_LOCALES, { projectId: String(projectId) })).locales)
192
+ );
193
+ server.registerResource(
194
+ "project-progress",
195
+ new ResourceTemplate("syntholoc://project/{projectId}/progress", { list: void 0 }),
196
+ {
197
+ title: "Project progress",
198
+ description: "Per-locale translation progress and open QA issues for a project.",
199
+ mimeType: "application/json"
200
+ },
201
+ async (uri, { projectId }) => json(uri, (await gql.request(PROJECT_ANALYTICS, { projectId: String(projectId) })).projectAnalytics)
202
+ );
203
+ server.registerResource(
204
+ "project-jobs",
205
+ new ResourceTemplate("syntholoc://project/{projectId}/jobs", { list: void 0 }),
206
+ {
207
+ title: "Project jobs",
208
+ description: "Recent background jobs (translate / QA / git sync) for a project.",
209
+ mimeType: "application/json"
210
+ },
211
+ async (uri, { projectId }) => json(uri, (await gql.request(LIST_JOBS, { projectId: String(projectId), skip: 0, take: 25 })).jobs)
212
+ );
213
+ server.registerResource(
214
+ "project-qa-issues",
215
+ new ResourceTemplate("syntholoc://project/{projectId}/locale/{localeId}/qa-issues", { list: void 0 }),
216
+ {
217
+ title: "Locale QA issues",
218
+ description: "Open QA issues for a locale, with issue-type facets.",
219
+ mimeType: "application/json"
220
+ },
221
+ async (uri, { projectId, localeId }) => json(
222
+ uri,
223
+ await gql.request(LIST_QA_ISSUES, {
224
+ projectId: String(projectId),
225
+ localeId: String(localeId),
226
+ skip: 0,
227
+ take: 25
228
+ })
229
+ )
230
+ );
231
+ server.registerResource(
232
+ "glossary",
233
+ "syntholoc://glossary",
234
+ {
235
+ title: "Glossary",
236
+ description: "Organization-wide glossary terms (first 100).",
237
+ mimeType: "application/json"
238
+ },
239
+ async (uri) => json(uri, (await gql.request(LIST_GLOSSARY, { skip: 0, take: 100 })).glossaryTerms)
240
+ );
241
+ server.registerResource(
242
+ "style-guides",
243
+ "syntholoc://style-guides",
244
+ {
245
+ title: "Style guides",
246
+ description: "Organization-wide style guides.",
247
+ mimeType: "application/json"
248
+ },
249
+ async (uri) => json(uri, (await gql.request(LIST_STYLE_GUIDES, {})).styleGuides)
250
+ );
251
+ server.registerResource(
252
+ "tm",
253
+ "syntholoc://tm",
254
+ {
255
+ title: "Translation memory",
256
+ description: "Organization-wide Translation Memory coverage: entry counts per target locale.",
257
+ mimeType: "application/json"
258
+ },
259
+ async (uri) => json(uri, (await gql.request(TM_STATS, {})).tmStats)
260
+ );
105
261
  }
106
262
 
107
263
  // src/tools/analytics.ts
108
264
  import { z as z2 } from "zod";
109
- import { COST_ANALYTICS, LOCALE_PROGRESS, PROJECT_ANALYTICS, USAGE } from "@syntholoc/sdk";
265
+ import { COST_ANALYTICS, LOCALE_PROGRESS, PROJECT_ANALYTICS as PROJECT_ANALYTICS2, USAGE } from "@syntholoc/sdk";
110
266
 
111
267
  // src/util.ts
112
268
  function jsonResult(data) {
@@ -123,7 +279,7 @@ function registerAnalyticsTools(server, gql) {
123
279
  inputSchema: { projectId: z2.string() },
124
280
  annotations: { readOnlyHint: true, openWorldHint: true }
125
281
  },
126
- async ({ projectId }) => jsonResult((await gql.request(PROJECT_ANALYTICS, { projectId })).projectAnalytics)
282
+ async ({ projectId }) => jsonResult((await gql.request(PROJECT_ANALYTICS2, { projectId })).projectAnalytics)
127
283
  );
128
284
  server.registerTool(
129
285
  "cost_analytics",
@@ -238,7 +394,7 @@ import { z as z4 } from "zod";
238
394
  import {
239
395
  CREATE_GLOSSARY_TERM,
240
396
  DELETE_GLOSSARY_TERM,
241
- LIST_GLOSSARY,
397
+ LIST_GLOSSARY as LIST_GLOSSARY2,
242
398
  SUGGEST_GLOSSARY_TERMS,
243
399
  UPDATE_GLOSSARY_TERM
244
400
  } from "@syntholoc/sdk";
@@ -255,7 +411,7 @@ function registerGlossaryTools(server, gql) {
255
411
  },
256
412
  annotations: { readOnlyHint: true, openWorldHint: true }
257
413
  },
258
- async (vars) => jsonResult((await gql.request(LIST_GLOSSARY, vars)).glossaryTerms)
414
+ async (vars) => jsonResult((await gql.request(LIST_GLOSSARY2, vars)).glossaryTerms)
259
415
  );
260
416
  server.registerTool(
261
417
  "upsert_glossary_term",
@@ -310,7 +466,7 @@ function registerGlossaryTools(server, gql) {
310
466
 
311
467
  // src/tools/jobs.ts
312
468
  import { z as z5 } from "zod";
313
- import { GET_JOB, LIST_JOBS, waitForJob } from "@syntholoc/sdk";
469
+ import { GET_JOB, LIST_JOBS as LIST_JOBS2, waitForJob } from "@syntholoc/sdk";
314
470
  function registerJobTools(server, gql) {
315
471
  server.registerTool(
316
472
  "get_job",
@@ -334,7 +490,7 @@ function registerJobTools(server, gql) {
334
490
  },
335
491
  annotations: { readOnlyHint: true, openWorldHint: true }
336
492
  },
337
- async (vars) => jsonResult((await gql.request(LIST_JOBS, vars)).jobs)
493
+ async (vars) => jsonResult((await gql.request(LIST_JOBS2, vars)).jobs)
338
494
  );
339
495
  server.registerTool(
340
496
  "wait_for_job",
@@ -361,7 +517,7 @@ import { z as z6 } from "zod";
361
517
  import {
362
518
  DELETE_KEY,
363
519
  IMPORT_SOURCE_STRINGS,
364
- LIST_KEYS,
520
+ LIST_KEYS as LIST_KEYS2,
365
521
  UPDATE_KEY,
366
522
  UPSERT_KEYS
367
523
  } from "@syntholoc/sdk";
@@ -382,7 +538,7 @@ function registerKeyTools(server, gql) {
382
538
  },
383
539
  annotations: { readOnlyHint: true, openWorldHint: true }
384
540
  },
385
- async (vars) => jsonResult((await gql.request(LIST_KEYS, vars)).keys)
541
+ async (vars) => jsonResult((await gql.request(LIST_KEYS2, vars)).keys)
386
542
  );
387
543
  server.registerTool(
388
544
  "upsert_keys",
@@ -435,24 +591,29 @@ function registerKeyTools(server, gql) {
435
591
  "import_source_strings",
436
592
  {
437
593
  title: "Import source strings",
438
- description: "Imports source strings from a file (paste the raw content) and returns the diff summary. Use `dryRun` to preview. For a structured delta prefer upsert_keys.",
594
+ description: 'Imports source strings from ONE file (paste the raw content) and returns the diff summary. For a multi-file / namespaced project (a file per namespace, e.g. messages/en/home.json), pass `keyPrefix` (the namespace, e.g. "home") so keys become `home.title` and files with overlapping top-level keys do not collide \u2014 call once per file. Use `dryRun` to preview.',
439
595
  inputSchema: {
440
596
  projectId: z6.string(),
441
597
  content: z6.string(),
442
598
  format: z6.enum(FORMATS),
599
+ keyPrefix: z6.string().optional().describe('Namespace prefix, e.g. "home" (per-file, multi-file projects).'),
600
+ sourceFile: z6.string().optional().describe('Real source file path, e.g. "messages/en/home.json" \u2014 stored for the file tree + reliable export.'),
443
601
  dryRun: z6.boolean().default(false)
444
602
  },
445
603
  annotations: { idempotentHint: true, openWorldHint: true }
446
604
  },
447
- async ({ projectId, content, format, dryRun }) => jsonResult(
448
- (await gql.request(IMPORT_SOURCE_STRINGS, { projectId, input: { content, format, dryRun } })).importSourceStrings
605
+ async ({ projectId, content, format, keyPrefix, sourceFile, dryRun }) => jsonResult(
606
+ (await gql.request(IMPORT_SOURCE_STRINGS, {
607
+ projectId,
608
+ input: { content, format, keyPrefix, sourceFile, dryRun }
609
+ })).importSourceStrings
449
610
  )
450
611
  );
451
612
  }
452
613
 
453
614
  // src/tools/locales.ts
454
615
  import { z as z7 } from "zod";
455
- import { ADD_LOCALE, LIST_LOCALES, REMOVE_LOCALE, SEED_STARTER_LOCALES } from "@syntholoc/sdk";
616
+ import { ADD_LOCALE, LIST_LOCALES as LIST_LOCALES2, REMOVE_LOCALE, SEED_STARTER_LOCALES } from "@syntholoc/sdk";
456
617
  function registerLocaleTools(server, gql) {
457
618
  server.registerTool(
458
619
  "list_locales",
@@ -462,16 +623,19 @@ function registerLocaleTools(server, gql) {
462
623
  inputSchema: { projectId: z7.string() },
463
624
  annotations: { readOnlyHint: true, openWorldHint: true }
464
625
  },
465
- async ({ projectId }) => jsonResult((await gql.request(LIST_LOCALES, { projectId })).locales)
626
+ async ({ projectId }) => jsonResult((await gql.request(LIST_LOCALES2, { projectId })).locales)
466
627
  );
467
628
  server.registerTool(
468
629
  "add_locale",
469
630
  {
470
631
  title: "Add locale",
471
- description: "Adds a target locale (BCP-47 code) to a project.",
632
+ description: "Adds ONE target locale to a project. To add several at once, prefer seed_starter_locales (one call for the whole list).",
472
633
  inputSchema: {
473
634
  projectId: z7.string(),
474
- code: z7.string().describe('BCP-47 code, e.g. "de-DE"'),
635
+ // `code` and `locale` are aliases — accept either so the natural
636
+ // `locale: "de-DE"` form works as well as `code`.
637
+ code: z7.string().optional().describe('BCP-47 code, e.g. "de-DE" or "ru". Alias: `locale`.'),
638
+ locale: z7.string().optional().describe("Alias for `code`."),
475
639
  name: z7.string().optional(),
476
640
  formality: z7.string().optional(),
477
641
  gender: z7.string().optional(),
@@ -479,14 +643,26 @@ function registerLocaleTools(server, gql) {
479
643
  },
480
644
  annotations: { openWorldHint: true }
481
645
  },
482
- async ({ projectId, ...input }) => jsonResult((await gql.request(ADD_LOCALE, { projectId, input })).addLocale)
646
+ async ({ projectId, code, locale, name, formality, gender, variant }) => {
647
+ const resolvedCode = code ?? locale;
648
+ if (!resolvedCode) {
649
+ return jsonResult({
650
+ error: 'Missing locale code. Pass `code` (BCP-47, e.g. "de-DE" or "ru"). To add several locales at once, use seed_starter_locales with a `codes` array.'
651
+ });
652
+ }
653
+ const input = { code: resolvedCode, name, formality, gender, variant };
654
+ return jsonResult((await gql.request(ADD_LOCALE, { projectId, input })).addLocale);
655
+ }
483
656
  );
484
657
  server.registerTool(
485
658
  "seed_starter_locales",
486
659
  {
487
660
  title: "Seed starter locales",
488
- description: "Adds a set of common starter locales to a project (omit codes for the defaults).",
489
- inputSchema: { projectId: z7.string(), codes: z7.array(z7.string()).optional() },
661
+ description: 'Adds MULTIPLE target locales in one call \u2014 pass `codes` (BCP-47, e.g. ["ru","de-DE","fr"]), or omit for a common starter set. Prefer this over repeated add_locale calls.',
662
+ inputSchema: {
663
+ projectId: z7.string(),
664
+ codes: z7.array(z7.string()).optional().describe('BCP-47 codes to add, e.g. ["ru","de-DE","fr"].')
665
+ },
490
666
  annotations: { openWorldHint: true }
491
667
  },
492
668
  async ({ projectId, codes }) => jsonResult((await gql.request(SEED_STARTER_LOCALES, { projectId, codes })).seedStarterLocales)
@@ -505,8 +681,29 @@ function registerLocaleTools(server, gql) {
505
681
 
506
682
  // src/tools/projects.ts
507
683
  import { z as z8 } from "zod";
508
- import { CREATE_PROJECT, GET_PROJECT, LIST_PROJECTS, UPDATE_PROJECT } from "@syntholoc/sdk";
684
+ import {
685
+ CREATE_PROJECT,
686
+ GET_PROJECT,
687
+ LIST_PROJECTS,
688
+ MY_ORGANIZATIONS as MY_ORGANIZATIONS2,
689
+ UPDATE_PROJECT
690
+ } from "@syntholoc/sdk";
509
691
  function registerProjectTools(server, gql) {
692
+ server.registerTool(
693
+ "list_organizations",
694
+ {
695
+ title: "List organizations",
696
+ 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.",
697
+ inputSchema: {},
698
+ annotations: { readOnlyHint: true, openWorldHint: true }
699
+ },
700
+ async () => jsonResult(
701
+ (await gql.request(MY_ORGANIZATIONS2)).myProjects.map((g) => ({
702
+ ...g.organization,
703
+ viewerRole: g.viewerRole
704
+ }))
705
+ )
706
+ );
510
707
  server.registerTool(
511
708
  "list_projects",
512
709
  {
@@ -604,7 +801,7 @@ function registerProviderTools(server, gql) {
604
801
 
605
802
  // src/tools/qa.ts
606
803
  import { z as z10 } from "zod";
607
- import { LIST_QA_ISSUES, RUN_LOCALE_QA, RUN_QA } from "@syntholoc/sdk";
804
+ import { LIST_QA_ISSUES as LIST_QA_ISSUES2, RUN_LOCALE_QA, RUN_QA } from "@syntholoc/sdk";
608
805
  function registerQaTools(server, gql) {
609
806
  server.registerTool(
610
807
  "run_locale_qa",
@@ -640,13 +837,13 @@ function registerQaTools(server, gql) {
640
837
  },
641
838
  annotations: { readOnlyHint: true, openWorldHint: true }
642
839
  },
643
- async (vars) => jsonResult(await gql.request(LIST_QA_ISSUES, vars))
840
+ async (vars) => jsonResult(await gql.request(LIST_QA_ISSUES2, vars))
644
841
  );
645
842
  }
646
843
 
647
844
  // src/tools/styleguides.ts
648
845
  import { z as z11 } from "zod";
649
- import { CREATE_STYLE_GUIDE, GENERATE_STYLE_GUIDE, LIST_STYLE_GUIDES } from "@syntholoc/sdk";
846
+ import { CREATE_STYLE_GUIDE, GENERATE_STYLE_GUIDE, LIST_STYLE_GUIDES as LIST_STYLE_GUIDES2 } from "@syntholoc/sdk";
650
847
  function registerStyleGuideTools(server, gql) {
651
848
  server.registerTool(
652
849
  "list_style_guides",
@@ -656,7 +853,7 @@ function registerStyleGuideTools(server, gql) {
656
853
  inputSchema: { projectId: z11.string().optional() },
657
854
  annotations: { readOnlyHint: true, openWorldHint: true }
658
855
  },
659
- async ({ projectId }) => jsonResult((await gql.request(LIST_STYLE_GUIDES, { projectId })).styleGuides)
856
+ async ({ projectId }) => jsonResult((await gql.request(LIST_STYLE_GUIDES2, { projectId })).styleGuides)
660
857
  );
661
858
  server.registerTool(
662
859
  "generate_style_guide",
@@ -687,8 +884,54 @@ function registerStyleGuideTools(server, gql) {
687
884
  );
688
885
  }
689
886
 
690
- // src/tools/translations.ts
887
+ // src/tools/tm.ts
691
888
  import { z as z12 } from "zod";
889
+ import { TM_ENTRIES, TM_SEARCH, TM_STATS as TM_STATS2 } from "@syntholoc/sdk";
890
+ function registerTmTools(server, gql) {
891
+ server.registerTool(
892
+ "tm_search",
893
+ {
894
+ title: "Search translation memory",
895
+ description: "Concordance search \u2014 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.",
896
+ inputSchema: {
897
+ sourceText: z12.string(),
898
+ targetLocale: z12.string(),
899
+ projectId: z12.string().optional(),
900
+ limit: z12.number().int().min(1).max(20).default(5)
901
+ },
902
+ annotations: { readOnlyHint: true, openWorldHint: true }
903
+ },
904
+ async ({ sourceText, targetLocale, projectId, limit }) => jsonResult((await gql.request(TM_SEARCH, { sourceText, targetLocale, projectId, limit })).tmSearch)
905
+ );
906
+ server.registerTool(
907
+ "tm_stats",
908
+ {
909
+ title: "Translation memory stats",
910
+ description: "Entry counts per target locale (org-wide, or for a project with `projectId`).",
911
+ inputSchema: { projectId: z12.string().optional() },
912
+ annotations: { readOnlyHint: true, openWorldHint: true }
913
+ },
914
+ async ({ projectId }) => jsonResult((await gql.request(TM_STATS2, { projectId })).tmStats)
915
+ );
916
+ server.registerTool(
917
+ "list_tm",
918
+ {
919
+ title: "Browse translation memory",
920
+ description: "Lists stored TM entries (paginated, newest first), optionally filtered by project and locale.",
921
+ inputSchema: {
922
+ projectId: z12.string().optional(),
923
+ targetLocale: z12.string().optional(),
924
+ skip: z12.number().int().min(0).default(0),
925
+ take: z12.number().int().min(1).max(100).default(25)
926
+ },
927
+ annotations: { readOnlyHint: true, openWorldHint: true }
928
+ },
929
+ async (vars) => jsonResult((await gql.request(TM_ENTRIES, vars)).tmEntries)
930
+ );
931
+ }
932
+
933
+ // src/tools/translations.ts
934
+ import { z as z13 } from "zod";
692
935
  import {
693
936
  APPROVE_TRANSLATION,
694
937
  EXPORT_LOCALE,
@@ -707,9 +950,9 @@ function registerTranslationTools(server, gql) {
707
950
  title: "Translate locale",
708
951
  description: "Queues AI translation of a locale (scope ALL | UNAPPROVED | UNTRANSLATED) and returns the tracking `jobIds`. Executes immediately and unconditionally \u2014 the batch threshold is a client-side policy for PROACTIVE syncs, not for this tool.",
709
952
  inputSchema: {
710
- projectId: z12.string(),
711
- localeId: z12.string(),
712
- scope: z12.enum(["ALL", "UNAPPROVED", "UNTRANSLATED"]).default("UNTRANSLATED")
953
+ projectId: z13.string(),
954
+ localeId: z13.string(),
955
+ scope: z13.enum(["ALL", "UNAPPROVED", "UNTRANSLATED"]).default("UNTRANSLATED")
713
956
  },
714
957
  annotations: { openWorldHint: true }
715
958
  },
@@ -720,7 +963,7 @@ function registerTranslationTools(server, gql) {
720
963
  {
721
964
  title: "Translate key",
722
965
  description: "Queues AI translation of a single key into a locale. Returns tracking `jobIds`.",
723
- inputSchema: { projectId: z12.string(), keyId: z12.string(), localeId: z12.string() },
966
+ inputSchema: { projectId: z13.string(), keyId: z13.string(), localeId: z13.string() },
724
967
  annotations: { openWorldHint: true }
725
968
  },
726
969
  async (vars) => jsonResult((await gql.request(TRANSLATE_KEY, vars)).translateKey)
@@ -731,11 +974,11 @@ function registerTranslationTools(server, gql) {
731
974
  title: "Set translation",
732
975
  description: "Manually sets the translation text for a key in a locale.",
733
976
  inputSchema: {
734
- projectId: z12.string(),
735
- keyId: z12.string(),
736
- localeId: z12.string(),
737
- text: z12.string(),
738
- origin: z12.string().optional()
977
+ projectId: z13.string(),
978
+ keyId: z13.string(),
979
+ localeId: z13.string(),
980
+ text: z13.string(),
981
+ origin: z13.string().optional()
739
982
  },
740
983
  annotations: { idempotentHint: true, openWorldHint: true }
741
984
  },
@@ -749,10 +992,10 @@ function registerTranslationTools(server, gql) {
749
992
  title: "List translations",
750
993
  description: "Lists translations for a locale with status, origin, model and QA score.",
751
994
  inputSchema: {
752
- projectId: z12.string(),
753
- localeId: z12.string(),
754
- skip: z12.number().int().min(0).default(0),
755
- take: z12.number().int().min(1).max(200).default(25)
995
+ projectId: z13.string(),
996
+ localeId: z13.string(),
997
+ skip: z13.number().int().min(0).default(0),
998
+ take: z13.number().int().min(1).max(200).default(25)
756
999
  },
757
1000
  annotations: { readOnlyHint: true, openWorldHint: true }
758
1001
  },
@@ -763,7 +1006,7 @@ function registerTranslationTools(server, gql) {
763
1006
  {
764
1007
  title: "Approve translation",
765
1008
  description: "Marks a translation APPROVED.",
766
- inputSchema: { projectId: z12.string(), translationId: z12.string() },
1009
+ inputSchema: { projectId: z13.string(), translationId: z13.string() },
767
1010
  annotations: { idempotentHint: true, openWorldHint: true }
768
1011
  },
769
1012
  async ({ projectId, translationId }) => jsonResult((await gql.request(APPROVE_TRANSLATION, { projectId, id: translationId })).approveTranslation)
@@ -773,7 +1016,7 @@ function registerTranslationTools(server, gql) {
773
1016
  {
774
1017
  title: "Reject translation",
775
1018
  description: "Marks a translation REJECTED (queued for re-translation).",
776
- inputSchema: { projectId: z12.string(), translationId: z12.string() },
1019
+ inputSchema: { projectId: z13.string(), translationId: z13.string() },
777
1020
  annotations: { idempotentHint: true, openWorldHint: true }
778
1021
  },
779
1022
  async ({ projectId, translationId }) => jsonResult((await gql.request(REJECT_TRANSLATION, { projectId, id: translationId })).rejectTranslation)
@@ -782,12 +1025,14 @@ function registerTranslationTools(server, gql) {
782
1025
  "export_locale",
783
1026
  {
784
1027
  title: "Export locale",
785
- description: "Exports a locale to a file string in the chosen format \u2014 the basis for writing translations back into local files. Set `onlyApproved` to exclude drafts.",
1028
+ description: 'Exports a locale to a file string in the chosen format \u2014 the basis for writing translations back into local files. Set `onlyApproved` to exclude drafts. For a multi-file project prefer `sourceFile` (the real path, e.g. "messages/en/home.json") to export exactly that file with file-local key names \u2014 reliable. `keyPrefix` is the legacy name-prefix heuristic.',
786
1029
  inputSchema: {
787
- projectId: z12.string(),
788
- localeId: z12.string(),
789
- format: z12.enum(FORMATS2).default("JSON"),
790
- onlyApproved: z12.boolean().optional()
1030
+ projectId: z13.string(),
1031
+ localeId: z13.string(),
1032
+ format: z13.enum(FORMATS2).default("JSON"),
1033
+ onlyApproved: z13.boolean().optional(),
1034
+ sourceFile: z13.string().optional().describe('Real source file path to export, e.g. "messages/en/home.json" (reliable, preferred).'),
1035
+ keyPrefix: z13.string().optional().describe('Legacy namespace prefix, e.g. "home".')
791
1036
  },
792
1037
  annotations: { readOnlyHint: true, openWorldHint: true }
793
1038
  },
@@ -797,53 +1042,68 @@ function registerTranslationTools(server, gql) {
797
1042
  "import_translations",
798
1043
  {
799
1044
  title: "Import translations",
800
- 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.",
1045
+ 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.",
801
1046
  inputSchema: {
802
- projectId: z12.string(),
803
- localeId: z12.string(),
804
- content: z12.string(),
805
- format: z12.enum(FORMATS2),
806
- autoApprove: z12.boolean().default(false),
807
- conflict: z12.enum(["OVERWRITE", "SKIP_EXISTING", "KEEP_APPROVED"]).default("OVERWRITE"),
808
- dryRun: z12.boolean().default(false)
1047
+ projectId: z13.string(),
1048
+ localeId: z13.string(),
1049
+ content: z13.string(),
1050
+ format: z13.enum(FORMATS2),
1051
+ keyPrefix: z13.string().optional().describe('Namespace prefix, e.g. "home" (per-file, multi-file projects).'),
1052
+ autoApprove: z13.boolean().default(false),
1053
+ conflict: z13.enum(["OVERWRITE", "SKIP_EXISTING", "KEEP_APPROVED"]).default("OVERWRITE"),
1054
+ dryRun: z13.boolean().default(false)
809
1055
  },
810
1056
  annotations: { idempotentHint: true, openWorldHint: true }
811
1057
  },
812
- async ({ projectId, localeId, content, format, autoApprove, conflict, dryRun }) => jsonResult(
1058
+ async ({ projectId, localeId, content, format, keyPrefix, autoApprove, conflict, dryRun }) => jsonResult(
813
1059
  (await gql.request(IMPORT_TRANSLATIONS, {
814
1060
  projectId,
815
1061
  localeId,
816
- input: { content, format, autoApprove, conflict, dryRun }
1062
+ input: { content, format, keyPrefix, autoApprove, conflict, dryRun }
817
1063
  })).importTranslations
818
1064
  )
819
1065
  );
820
1066
  }
821
1067
 
822
1068
  // src/tools/index.ts
823
- function registerAllTools(server, gql) {
824
- registerConfigTools(server, gql);
825
- registerProjectTools(server, gql);
826
- registerLocaleTools(server, gql);
827
- registerKeyTools(server, gql);
828
- registerTranslationTools(server, gql);
829
- registerQaTools(server, gql);
830
- registerGlossaryTools(server, gql);
831
- registerStyleGuideTools(server, gql);
832
- registerGitTools(server, gql);
833
- registerProviderTools(server, gql);
834
- registerJobTools(server, gql);
835
- registerAnalyticsTools(server, gql);
1069
+ var TOOL_SETS = {
1070
+ config: registerConfigTools,
1071
+ projects: registerProjectTools,
1072
+ locales: registerLocaleTools,
1073
+ keys: registerKeyTools,
1074
+ translations: registerTranslationTools,
1075
+ qa: registerQaTools,
1076
+ glossary: registerGlossaryTools,
1077
+ styleguides: registerStyleGuideTools,
1078
+ tm: registerTmTools,
1079
+ git: registerGitTools,
1080
+ providers: registerProviderTools,
1081
+ jobs: registerJobTools,
1082
+ analytics: registerAnalyticsTools
1083
+ };
1084
+ var TOOL_SET_NAMES = Object.keys(TOOL_SETS);
1085
+ function selectSets(requested) {
1086
+ if (!requested || requested.length === 0 || requested.includes("all")) return TOOL_SET_NAMES;
1087
+ return TOOL_SET_NAMES.filter((name) => requested.includes(name));
1088
+ }
1089
+ function registerAllTools(server, gql, opts = {}) {
1090
+ const target = applyToolFilter(server, opts);
1091
+ for (const name of selectSets(opts.toolSets)) {
1092
+ TOOL_SETS[name]?.(target, gql);
1093
+ }
836
1094
  }
837
1095
 
838
1096
  // src/build-server.ts
839
- function buildMcpServer(gql) {
1097
+ function buildMcpServer(gql, opts = {}) {
840
1098
  const server = new McpServer({ name: "syntholoc", version: "0.1.0" });
841
- registerAllTools(server, gql);
1099
+ registerAllTools(server, gql, opts);
842
1100
  registerResources(server, gql);
843
1101
  registerPrompts(server);
844
1102
  return server;
845
1103
  }
846
1104
 
847
1105
  export {
1106
+ parseList,
1107
+ TOOL_SET_NAMES,
848
1108
  buildMcpServer
849
1109
  };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- buildMcpServer
4
- } from "./chunk-RJ5GG3TT.js";
3
+ buildMcpServer,
4
+ parseList
5
+ } from "./chunk-DZKWHV2O.js";
5
6
 
6
7
  // src/index.ts
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -14,12 +15,19 @@ function loadConfig(env = process.env) {
14
15
  apiKey: env.SYNTHOLOC_API_KEY
15
16
  };
16
17
  }
18
+ function loadToolOptions(env = process.env) {
19
+ return {
20
+ toolSets: parseList(env.SYNTHOLOC_TOOL_SETS),
21
+ readOnly: env.SYNTHOLOC_READ_ONLY === "true",
22
+ toolFilter: parseList(env.SYNTHOLOC_TOOL_FILTER)
23
+ };
24
+ }
17
25
 
18
26
  // src/index.ts
19
27
  async function main() {
20
28
  const config = loadConfig();
21
29
  const gql = new GraphQLClient(config);
22
- const server = buildMcpServer(gql);
30
+ const server = buildMcpServer(gql, loadToolOptions());
23
31
  await server.connect(new StdioServerTransport());
24
32
  console.error(`[syntholoc-mcp] ready (api: ${config.apiUrl}${config.apiKey ? "" : ", no API key set"})`);
25
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntholoc/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Syntholoc MCP server — manage AI localization and translation from Claude Code.",
5
5
  "homepage": "https://syntholoc.com",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "1.22.0",
49
- "@syntholoc/sdk": "0.1.0",
49
+ "@syntholoc/sdk": "0.1.1",
50
50
  "zod": "3.25.76"
51
51
  },
52
52
  "devDependencies": {