@l10nmonster/mcp 3.0.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ import { z } from 'zod';
2
+ import { McpTool, McpInputError, McpNotFoundError, McpProviderError, McpToolError } from './mcpTool.js';
3
+ import { TU } from '@l10nmonster/core';
4
+
5
+ async function createJobForProvider(mm, jobRequest, provider, sourceTUsCount, guids) {
6
+ let assignedJobs;
7
+ try {
8
+ assignedJobs = await mm.dispatcher.createJobs(
9
+ jobRequest,
10
+ { providerList: [provider], skipGroupCheck: true, skipQualityCheck: true }
11
+ );
12
+ } catch (error) {
13
+ throw new McpProviderError(`Provider "${provider}" rejected the job request`, {
14
+ hints: [
15
+ 'Verify the provider supports the requested language pair and content type.',
16
+ 'Ensure provider credentials/configuration are valid.'
17
+ ],
18
+ details: { jobRequestSize: sourceTUsCount },
19
+ cause: error
20
+ });
21
+ }
22
+
23
+ const job = assignedJobs.find(j => j.translationProvider === provider);
24
+
25
+ if (!job) {
26
+ const rejectedJob = assignedJobs.find(j => !j.translationProvider);
27
+ if (rejectedJob && rejectedJob.tus.length > 0) {
28
+ throw new McpProviderError(`Provider "${provider}" did not accept any of the ${guids.length} translation units`, {
29
+ hints: [
30
+ 'Double-check language pair support.',
31
+ 'Try a different provider or smaller batch size.'
32
+ ],
33
+ details: { rejectedGuids: rejectedJob.tus.map(tu => tu.guid) }
34
+ });
35
+ }
36
+ throw new McpProviderError(`Failed to create job with provider "${provider}"`, {
37
+ details: { assignedJobs: assignedJobs.length }
38
+ });
39
+ }
40
+
41
+ return job;
42
+ }
43
+
44
+ function getTranslatedTUs(completedJob) {
45
+ const inflightGuidsSet = new Set(completedJob.inflight || []);
46
+ return completedJob.tus
47
+ .filter(tu => tu.ntgt && !inflightGuidsSet.has(tu.guid))
48
+ .map(tu => ({
49
+ guid: tu.guid,
50
+ rid: tu.rid,
51
+ sid: tu.sid,
52
+ nsrc: tu.nsrc,
53
+ ntgt: tu.ntgt,
54
+ q: tu.q,
55
+ ts: tu.ts,
56
+ translationProvider: tu.translationProvider || completedJob.translationProvider,
57
+ jobGuid: completedJob.jobGuid
58
+ }));
59
+ }
60
+
61
+ /**
62
+ * MCP tool for translating source translation units by their GUIDs.
63
+ *
64
+ * This tool creates a translation job, assigns it to a provider,
65
+ * starts the job, and returns the translated TUs.
66
+ */
67
+ export class TranslateTool extends McpTool {
68
+ static metadata = {
69
+ name: 'translate',
70
+ description: `Translate source translation units (TUs) by their GUIDs using a specified provider.
71
+
72
+ This tool:
73
+ 1. Fetches source TUs from the channel using the provided GUIDs
74
+ 2. Creates a translation job with the fetched source TUs
75
+ 3. Assigns the job to the specified translation provider
76
+ 4. Starts the job (executes the translation)
77
+ 5. Returns the translated TUs with their target text
78
+
79
+ The tool returns translated TUs with:
80
+ - guid: Same as source TU
81
+ - ntgt: Normalized target strings (array of strings)
82
+ - q: Quality score
83
+ - ts: Timestamp`,
84
+ inputSchema: z.object({
85
+ sourceLang: z.string()
86
+ .describe('Source language code (e.g., "en-US")'),
87
+ targetLang: z.string()
88
+ .describe('Target language code (e.g., "es-419")'),
89
+ channelId: z.string()
90
+ .describe('Channel ID to fetch source TUs from'),
91
+ provider: z.string()
92
+ .describe('Translation provider ID to use for translation'),
93
+ guids: z.array(z.string())
94
+ .min(1)
95
+ .describe('Array of TU GUIDs to translate'),
96
+ instructions: z.string()
97
+ .optional()
98
+ .describe('Optional instructions for the translation provider')
99
+ })
100
+ };
101
+
102
+ static async execute(mm, args) {
103
+ const { sourceLang, targetLang, channelId, provider, guids, instructions } = args;
104
+
105
+ const availableProviders = mm.dispatcher.providers?.map(p => p.id) ?? [];
106
+ if (!availableProviders.includes(provider)) {
107
+ throw new McpInputError(`Unknown translation provider "${provider}"`, {
108
+ hints: [`Available providers: ${availableProviders.join(', ') || 'none registered'}`]
109
+ });
110
+ }
111
+
112
+ let tm;
113
+ try {
114
+ tm = mm.tmm.getTM(sourceLang, targetLang);
115
+ } catch (error) {
116
+ throw new McpInputError(`Language pair ${sourceLang}→${targetLang} is not configured`, {
117
+ hints: ['Check translation_status include=["coverage"] to list available language pairs.'],
118
+ cause: error
119
+ });
120
+ }
121
+
122
+ let fetchedTUs;
123
+ try {
124
+ fetchedTUs = await tm.queryByGuids(guids, channelId);
125
+ } catch (error) {
126
+ throw new McpToolError('Failed to fetch translation units by GUID', {
127
+ code: 'FETCH_TUS_FAILED',
128
+ details: { channelId, guidsCount: guids.length },
129
+ cause: error
130
+ });
131
+ }
132
+
133
+ if (!fetchedTUs || fetchedTUs.length === 0) {
134
+ throw new McpNotFoundError(`No translation units found for the provided GUIDs in channel "${channelId}"`, {
135
+ hints: [
136
+ 'Ensure the GUIDs belong to the specified channel.',
137
+ 'Call source_query with a WHERE clause on guid to inspect source content.'
138
+ ],
139
+ details: { missingGuids: guids }
140
+ });
141
+ }
142
+
143
+ const sourceTUs = fetchedTUs.map(tu => {
144
+ try {
145
+ return TU.asSource(tu);
146
+ } catch (error) {
147
+ throw new McpToolError(`Failed to create source TU for guid ${tu.guid}`, {
148
+ code: 'SOURCE_TU_CONVERSION_FAILED',
149
+ details: { guid: tu.guid },
150
+ cause: error
151
+ });
152
+ }
153
+ });
154
+
155
+ // Create job with the source TUs
156
+ const jobRequest = {
157
+ sourceLang,
158
+ targetLang,
159
+ tus: sourceTUs
160
+ };
161
+
162
+ // Create jobs and assign to provider
163
+ const job = await createJobForProvider(mm, jobRequest, provider, sourceTUs.length, guids);
164
+
165
+ // Start the job
166
+ let jobStatuses;
167
+ try {
168
+ jobStatuses = await mm.dispatcher.startJobs(
169
+ [job],
170
+ { instructions }
171
+ );
172
+ } catch (error) {
173
+ throw new McpProviderError(`Failed to start job ${job.jobGuid}`, {
174
+ hints: [
175
+ 'Inspect provider logs or credentials.',
176
+ 'Retry without instructions to rule out formatting issues.'
177
+ ],
178
+ details: { jobGuid: job.jobGuid },
179
+ cause: error,
180
+ retryable: true
181
+ });
182
+ }
183
+
184
+ const jobStatus = jobStatuses.find(s => s.jobGuid === job.jobGuid);
185
+ if (!jobStatus) {
186
+ throw new McpProviderError(`Provider "${provider}" did not report status for job ${job.jobGuid}`, {
187
+ retryable: true
188
+ });
189
+ }
190
+
191
+ // Retrieve the job to get translated TUs
192
+ let completedJob;
193
+ try {
194
+ completedJob = await mm.tmm.getJob(job.jobGuid);
195
+ } catch (error) {
196
+ throw new McpToolError(`Failed to load job ${job.jobGuid} after start`, {
197
+ code: 'JOB_LOOKUP_FAILED',
198
+ cause: error,
199
+ retryable: true
200
+ });
201
+ }
202
+
203
+ if (!completedJob) {
204
+ throw new McpToolError(`Job ${job.jobGuid} not found after completion`, {
205
+ code: 'JOB_MISSING',
206
+ retryable: true
207
+ });
208
+ }
209
+
210
+ // Extract translated TUs (those with ntgt, excluding inflight ones)
211
+ // Use the inflight array from the job response to filter
212
+ const translatedTUs = getTranslatedTUs(completedJob);
213
+
214
+ return {
215
+ jobGuid: completedJob.jobGuid,
216
+ sourceLang: completedJob.sourceLang,
217
+ targetLang: completedJob.targetLang,
218
+ translationProvider: completedJob.translationProvider,
219
+ status: completedJob.status,
220
+ translatedCount: translatedTUs.length,
221
+ inflightCount: completedJob.inflight?.length || 0,
222
+ translatedTUs,
223
+ inflightGuids: completedJob.inflight && completedJob.inflight.length > 0 ? completedJob.inflight : undefined
224
+ };
225
+ }
226
+ }
227
+