@i18n-agent/mcp-client
Version:
đ i18n-agent MCP Client - 48 languages, AI-powered translation for Claude, Claude Code, Cursor, VS Code, Codex. Get API key at https://app.i18nagent.ai
1,177 lines (1,060 loc) ⢠73.1 kB
JavaScript
#!/usr/bin/env node
/**
* MCP Client for i18n-agent Translation Service
* Integrates with Claude Code CLI to provide translation capabilities
*/
const MCP_CLIENT_VERSION = '1.8.260';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import { detectNamespaceFromPath, generateNamespaceSuggestions, getNamespaceSuggestionText } from './namespace-detector.js';
const server = new Server(
{
name: 'i18n-agent',
version: MCP_CLIENT_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
// Configuration
if (!process.env.MCP_SERVER_URL) {
throw new Error('MCP_SERVER_URL environment variable is required');
}
if (!process.env.API_KEY) {
throw new Error('API_KEY environment variable is required');
}
const MCP_SERVER_URL = process.env.MCP_SERVER_URL;
const API_KEY = process.env.API_KEY;
// Available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'translate_text',
description: 'â ď¸ CRITICAL: For multi-language translation, use targetLanguages parameter (not targetLanguage). Translate text content with cultural adaptation using AI subagents. Supports both single and multi-language translation. For large requests (>100 texts or >50,000 characters), returns a jobId for async processing. Use check_translation_status to monitor progress and download results. Set pseudoTranslation=true for testing i18n implementations without AI cost.',
inputSchema: {
type: 'object',
properties: {
texts: {
type: 'array',
items: { type: 'string' },
description: 'Array of source texts to translate (any language)',
},
targetLanguages: {
description: 'â ď¸ REQUIRED: Target language(s) - can be a single string (e.g., "es") OR an array of strings (e.g., ["es", "fr", "zh-CN"]) for multi-language translation',
oneOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } }
]
},
sourceLanguage: {
type: 'string',
description: 'Source language code (auto-detected if not provided)',
},
targetAudience: {
type: 'string',
description: 'Target audience for the content (e.g., "software developers", "marketing professionals")',
},
industry: {
type: 'string',
description: 'Industry context (e.g., "technology", "healthcare", "finance")',
},
region: {
type: 'string',
description: 'Specific region for localization (e.g., "Spain", "Mexico", "Brazil")',
},
context: {
type: 'string',
description: 'Optional additional context or instructions for the translation (e.g., "Keep technical terms in English", "Use formal tone")',
},
pseudoTranslation: {
type: 'boolean',
description: 'Enable pseudo-translation mode for testing i18n implementations (bypasses AI translation, no credit cost)',
},
pseudoOptions: {
type: 'object',
properties: {
addCJK: {
type: 'boolean',
description: 'Add CJK characters to test wide character support',
},
expansionRatio: {
type: 'number',
description: 'Length expansion ratio (1.0 = no expansion, 1.3 = 30% longer, 2.0 = double length)',
},
addSpecialChars: {
type: 'boolean',
description: 'Add special characters to test encoding/escaping',
},
addBrackets: {
type: 'boolean',
description: 'Wrap strings with brackets to identify untranslated content',
},
addAccents: {
type: 'boolean',
description: 'Replace Latin characters with accented equivalents',
},
},
description: 'Configuration options for pseudo-translation',
},
namespace: {
type: 'string',
description: 'Optional namespace identifier for backend tracking and project organization (recommended for file-based workflows)',
},
skipWarnings: {
type: 'boolean',
description: 'â ď¸ Skip source text quality warnings and proceed with translation (default: false). WARNING: Enabling this may hurt translation quality as it bypasses source file analysis that identifies potential issues. Only use when you are confident about your source content quality or in automated workflows where warnings would block progress.',
default: false,
},
},
required: ['texts', 'targetLanguages'],
},
},
{
name: 'list_supported_languages',
description: 'Get list of supported languages with quality ratings',
inputSchema: {
type: 'object',
properties: {
includeQuality: {
type: 'boolean',
description: 'Include quality ratings for each language',
default: true,
},
},
},
},
{
name: 'translate_file',
description: 'â ď¸ CRITICAL: For multi-language translation, use targetLanguages parameter (not targetLanguage). Translate file content while preserving structure and format. Supports both single and multi-language translation. Supports JSON, YAML, XML, CSV, TXT, MD, and other text files. For large files (>100KB), returns a jobId for async processing. Use check_translation_status to monitor progress and download results. Set pseudoTranslation=true for testing i18n implementations without AI cost.',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'Path to the file to translate (required if fileContent is not provided)',
},
fileContent: {
type: 'string',
description: 'File content as string (required if filePath is not provided)',
},
fileType: {
type: 'string',
description: 'File type: json, yaml, yml, xml, csv, txt, md, html, properties (Java), pdf, docx, doc, po (gettext), pot (gettext), mo (gettext), auto',
enum: ['json', 'yaml', 'yml', 'xml', 'csv', 'txt', 'md', 'html', 'properties', 'pdf', 'docx', 'doc', 'po', 'pot', 'mo', 'auto'],
default: 'auto',
},
targetLanguages: {
description: 'â ď¸ REQUIRED: Target language(s) - can be a single string (e.g., "es") OR an array of strings (e.g., ["es", "fr", "zh-CN"]) for multi-language translation',
oneOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } }
]
},
targetAudience: {
type: 'string',
description: 'Target audience',
default: 'general',
},
industry: {
type: 'string',
description: 'Industry context',
default: 'technology',
},
preserveKeys: {
type: 'boolean',
description: 'Whether to preserve keys/structure (for structured files)',
default: true,
},
outputFormat: {
type: 'string',
description: 'Output format: same, json, yaml, txt',
default: 'same',
},
sourceLanguage: {
type: 'string',
description: 'Source language code (auto-detected if not provided)',
},
region: {
type: 'string',
description: 'Specific region for localization (e.g., "Spain", "Mexico", "Brazil")',
},
context: {
type: 'string',
description: 'Optional additional context or instructions for the translation (e.g., "Keep technical terms in English", "Use formal tone")',
},
pseudoTranslation: {
type: 'boolean',
description: 'Enable pseudo-translation mode for testing i18n implementations (bypasses AI translation, no credit cost)',
},
pseudoOptions: {
type: 'object',
properties: {
addCJK: {
type: 'boolean',
description: 'Add CJK characters to test wide character support',
},
expansionRatio: {
type: 'number',
description: 'Length expansion ratio (1.0 = no expansion, 1.3 = 30% longer, 2.0 = double length)',
},
addSpecialChars: {
type: 'boolean',
description: 'Add special characters to test encoding/escaping',
},
addBrackets: {
type: 'boolean',
description: 'Wrap strings with brackets to identify untranslated content',
},
addAccents: {
type: 'boolean',
description: 'Replace Latin characters with accented equivalents',
},
},
description: 'Configuration options for pseudo-translation',
},
namespace: {
type: 'string',
description: 'Unique namespace identifier for backend tracking and project organization (required for production use)',
},
skipWarnings: {
type: 'boolean',
description: 'â ď¸ Skip source text quality warnings and proceed with translation (default: false). WARNING: Enabling this may hurt translation quality as it bypasses source file analysis that identifies potential issues. Only use when you are confident about your source content quality or in automated workflows where warnings would block progress.',
default: false,
},
},
required: ['targetLanguages', 'namespace'],
},
},
{
name: 'analyze_content',
description: 'Analyze content for translation readiness and get improvement suggestions. Returns detailed analysis including content type, quality score, and specific recommendations. Costs the same credits as translation.',
inputSchema: {
type: 'object',
properties: {
content: {
type: ['string', 'array', 'object'],
description: 'Content to analyze (text string, array of texts, or structured object)',
},
fileType: {
type: 'string',
description: 'Optional file type if content is from a file (json, yaml, xml, etc.)',
},
sourceLanguage: {
type: 'string',
description: 'Source language code (auto-detected if not provided)',
},
targetLanguage: {
type: 'string',
description: 'Target language code for translation',
},
industry: {
type: 'string',
description: 'Industry context (e.g., "technology", "healthcare", "finance")',
default: 'general',
},
targetAudience: {
type: 'string',
description: 'Target audience (e.g., "general", "technical", "professional")',
default: 'general',
},
region: {
type: 'string',
description: 'Specific region for localization (e.g., "Spain", "Mexico", "Brazil")',
},
},
required: ['content', 'targetLanguage'],
},
},
{
name: 'get_credits',
description: 'Get remaining credits for the user and approximate word count available at 0.001 credits per word',
inputSchema: {
type: 'object',
properties: {
apiKey: {
type: 'string',
description: 'API key to get credits for (optional, will use environment variable if not provided)',
},
},
required: [],
},
},
/*
* ====================================================================
* TOKEN USAGE TOOLS - RESTRICTED FROM MCP CLIENT ACCESS
* ====================================================================
*
* HARD LIMIT POLICY: Token usage analytics tools are NOT available
* through MCP client interfaces for security and privacy reasons.
*
* Restricted Tools:
* - get_token_usage_stats
* - get_token_usage_by_translation
* - get_token_usage_by_api_key
*
* These tools contain sensitive usage data and billing information
* that should only be accessible through authenticated web interfaces,
* not through programmatic MCP access.
*
* If you need token usage data, please use:
* - Web dashboard at https://app.i18nagent.ai
* - Direct API calls with proper authentication
* - Admin interfaces (for internal use only)
*
* This restriction is enforced at the service level and cannot be
* bypassed through client modifications.
* ====================================================================
*/
{
name: 'check_translation_status',
description: 'Check the status and progress of an async translation job. Returns progress percentage, elapsed time, and downloads completed translation results when finished. Supports cursor-based pagination for download URLs to reduce token usage when checking jobs with many languages (recommended for jobs with >10 languages).',
inputSchema: {
type: 'object',
properties: {
jobId: {
type: 'string',
description: 'The job ID returned from translate_text (>100 texts or >50,000 chars) or translate_file (>100KB) for async processing',
},
languageCursor: {
type: 'string',
description: 'Optional: Starting language code for pagination (e.g., "de", "es"). Omit to start from the beginning. Use the nextCursor value from the previous response to get the next page of languages.',
},
pageSize: {
type: 'number',
description: 'Optional: Number of languages to return per page (default: 10). Recommended for jobs with many languages to reduce token usage. Without pagination, responses for 32-language jobs can be ~11k tokens.',
},
},
required: ['jobId'],
},
},
{
name: 'resume_translation',
description: 'Resume a failed or interrupted async translation job from its last checkpoint. This allows you to continue processing from where it stopped instead of starting over.',
inputSchema: {
type: 'object',
properties: {
jobId: {
type: 'string',
description: 'The job ID of the translation job to resume',
},
},
required: ['jobId'],
},
},
{
name: 'download_translations',
description: 'Download completed translations by writing them to /tmp/i18n-translations-{jobId}/. Returns metadata with file paths instead of large translation content to avoid token bloat. Consumer can then read or copy files as needed.',
inputSchema: {
type: 'object',
properties: {
jobId: {
type: 'string',
description: 'The job ID of the completed translation',
},
},
required: ['jobId'],
},
},
],
};
});
// Tool execution handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'translate_text':
return await handleTranslateText(args);
case 'list_supported_languages':
return await handleListLanguages(args);
case 'translate_file':
return await handleTranslateFile(args);
case 'analyze_content':
return await handleAnalyzeContent(args);
case 'get_credits':
return await handleGetCredits(args);
/*
* TOKEN USAGE TOOLS - BLOCKED FOR SECURITY
* These cases are intentionally removed to prevent access to sensitive analytics data
* through MCP interfaces. See tool definition comments above for details.
*/
case 'check_translation_status':
return await handleCheckTranslationStatus(args);
case 'resume_translation':
return await handleResumeTranslation(args);
case 'download_translations':
return await handleDownloadTranslations(args);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
} catch (error) {
console.error(`Error executing tool ${name}:`, error);
// Check if error is about API key or credit issues
const errorMsg = error.message || '';
const isAuthError = errorMsg.toLowerCase().includes('api key') ||
errorMsg.toLowerCase().includes('api_key') ||
errorMsg.toLowerCase().includes('unauthorized') ||
errorMsg.includes('(401)');
const isCreditError = errorMsg.toLowerCase().includes('credit') ||
errorMsg.toLowerCase().includes('quota') ||
errorMsg.toLowerCase().includes('limit exceeded') ||
errorMsg.includes('(402)');
// Check if error is already descriptive (validation errors, specific errors with clear messages)
const hasDescriptiveError = errorMsg.includes('Invalid language code') ||
errorMsg.includes('is a multilingual region') ||
errorMsg.includes('not found') ||
errorMsg.includes('timed out') ||
errorMsg.includes('Timeout') ||
errorMsg.includes('Required') ||
errorMsg.includes('must be') ||
errorMsg.includes('is required') ||
errorMsg.length > 200; // Long errors are likely already detailed
let finalErrorMsg = error.message;
// Only add retry guidance if:
// 1. It's not an auth/credit error
// 2. It's a content-based tool
// 3. The error is NOT already descriptive
const contentBasedTools = ['translate_text', 'translate_file'];
if (!isAuthError && !isCreditError && !hasDescriptiveError && contentBasedTools.includes(name)) {
finalErrorMsg = `${error.message}. Please retry with smaller chunks or split the content into multiple requests.`;
}
throw new McpError(
ErrorCode.InternalError,
`Tool execution failed: ${finalErrorMsg}`
);
}
});
async function handleTranslateText(args) {
const { texts, targetLanguages: rawTargetLanguages, sourceLanguage, targetAudience = 'general', industry = 'technology', region, context, pseudoTranslation, pseudoOptions, namespace, skipWarnings = false } = args;
if (!texts || !Array.isArray(texts) || texts.length === 0) {
throw new Error('texts must be a non-empty array');
}
// Namespace is optional for text translation, but recommended for organizational tracking
// Normalize targetLanguages - accept both string and array
let targetLanguages = rawTargetLanguages;
let targetLanguage = undefined;
if (typeof rawTargetLanguages === 'string') {
// Single language provided as string - convert to array for internal processing
targetLanguages = [rawTargetLanguages];
targetLanguage = rawTargetLanguages;
} else if (Array.isArray(rawTargetLanguages) && rawTargetLanguages.length === 1) {
// Single language provided as array - extract for backward compatibility
targetLanguage = rawTargetLanguages[0];
}
if (!targetLanguages?.length) {
throw new Error('targetLanguages parameter is required (can be a string for single language or array for multiple languages)');
}
// Check if this is a large translation request
const totalChars = texts.reduce((sum, text) => sum + text.length, 0);
const isLargeRequest = texts.length > 100 || totalChars > 50000;
// Use MCP JSON-RPC protocol for translate_text
const mcpRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: 'translate_text',
arguments: {
apiKey: API_KEY,
texts: texts,
targetLanguage: targetLanguage,
targetLanguages: targetLanguages,
sourceLanguage: sourceLanguage && sourceLanguage !== 'auto' ? sourceLanguage : undefined,
targetAudience: targetAudience,
industry: industry,
region: region,
context: context,
pseudoTranslation: pseudoTranslation,
pseudoOptions: pseudoOptions,
namespace: namespace,
skipWarnings: skipWarnings,
}
}
};
try {
const response = await axios.post(MCP_SERVER_URL, mcpRequest, {
headers: {
'Content-Type': 'application/json',
},
timeout: isLargeRequest ? 600000 : 300000, // 10 minutes for large requests, 5 minutes for normal
});
if (response.data.error) {
const errorMsg = response.data.error.message || response.data.error;
const isAuthError = errorMsg.toString().toLowerCase().includes('api key') ||
errorMsg.toString().toLowerCase().includes('api_key') ||
errorMsg.toString().toLowerCase().includes('unauthorized');
const isCreditError = errorMsg.toString().toLowerCase().includes('credit') ||
errorMsg.toString().toLowerCase().includes('quota') ||
errorMsg.toString().toLowerCase().includes('limit exceeded');
let finalErrorMsg = `Translation service error: ${errorMsg}`;
if (!isAuthError && !isCreditError) {
finalErrorMsg += `. Please retry with a smaller text chunk or split the content into multiple smaller requests.`;
}
throw new Error(finalErrorMsg);
}
// Check if we got an async job response
const result = response.data.result;
if (result && result.content && result.content[0]) {
const textContent = result.content[0].text;
// Try to parse as JSON to check for job ID
try {
const parsed = JSON.parse(textContent);
if (parsed.status === 'processing' && parsed.jobId) {
// Async job started - poll for status
const jobResult = await pollTranslationJob(parsed.jobId, parsed.estimatedTime);
// Extract the actual translation result from the job result
if (jobResult && jobResult.content && jobResult.content[0]) {
const translationData = JSON.parse(jobResult.content[0].text);
return formatTranslationResult(translationData, texts, targetLanguage, sourceLanguage, targetAudience, industry, region);
}
return jobResult;
} else {
// Regular synchronous result
return formatTranslationResult(parsed, texts, targetLanguage, sourceLanguage, targetAudience, industry, region);
}
} catch {
// Not JSON or error parsing - return as-is
return result;
}
}
return result;
} catch (error) {
if (error.code === 'ECONNABORTED') {
return {
content: [
{
type: 'text',
text: `â ď¸ Translation Timeout\n\n` +
`The translation is taking longer than expected.\n` +
`This is normal for requests with 100+ texts or over 50KB of content.\n\n` +
`What's happening:\n` +
`⢠The translation is still processing on the server\n` +
`⢠Large requests are processed with optimized pipeline\n` +
`⢠Each batch ensures quality and consistency\n\n` +
`Recommendations:\n` +
`1. Try splitting into smaller batches (50-100 texts)\n` +
`2. Use shorter texts when possible\n` +
`3. Contact support if this persists\n\n` +
`Request size: ${texts.length} texts, ${totalChars} characters`
}
]
};
}
// Handle 401 unauthorized - invalid API key
if (error.response?.status === 401) {
const authErrorDetails = error.response.data?.message || error.response.data?.result?.content?.[0]?.text || error.message;
throw new Error(`â Invalid API key (401)\nDetails: ${authErrorDetails}\nPlease check your API key at https://app.i18nagent.ai\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_text]`);
}
// Handle 402 payment required with user-friendly message
if (error.response?.status === 402) {
const creditErrorDetails = error.response.data?.message || error.response.data?.result?.content?.[0]?.text || error.message;
throw new Error(`â ď¸ Insufficient credits (402)\nDetails: ${creditErrorDetails}\nPlease top up at https://app.i18nagent.ai\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_text]`);
}
// Check if it's a large content issue
const totalChars = texts.reduce((sum, text) => sum + text.length, 0);
if (error.response?.status === 413 ||
(error.response?.status === 503 && totalChars > 50000)) {
const sizeErrorDetails = error.response?.data?.message || error.response?.data?.result?.content?.[0]?.text || error.message;
const errorMsg = `Content too large (${totalChars} characters, ${texts.length} texts)\nStatus: ${error.response?.status}\nDetails: ${sizeErrorDetails}\n\nPlease break into smaller batches:\n⢠Split into batches of 50-100 texts\n⢠Keep total size under 50KB per request\n⢠Process sequentially to avoid overload\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_text]`;
throw new Error(errorMsg);
}
// Check if it's actually a service unavailable error (only for real infrastructure issues)
if (error.response?.status === 503) {
throw new Error(`i18n-agent encountered unexpected problem, and we are working on it, try again later.`);
}
if (error.code === 'ECONNREFUSED' ||
error.code === 'ETIMEDOUT' ||
error.code === 'ENOTFOUND' ||
error.response?.status === 502 ||
error.response?.status === 504) {
const serviceErrorDetails = error.response?.data?.result?.content?.[0]?.text ||
error.response?.data?.error?.message ||
error.message;
const debugInfo = `Code: ${error.code || 'N/A'}\nStatus: ${error.response?.status || 'N/A'}\nStatusText: ${error.response?.statusText || 'N/A'}\nDetails: ${serviceErrorDetails}\nURL: ${error.config?.url || 'N/A'}\nTimestamp: ${new Date().toISOString()}`;
throw new Error(`Translation service error\n${debugInfo}\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_text]`);
}
// For other errors, include all debug info in the error message
const generalErrorDetails = error.response?.data?.result?.content?.[0]?.text ||
error.response?.data?.error?.message ||
error.message;
const debugInfo = `Status: ${error.response?.status || 'N/A'}\nStatusText: ${error.response?.statusText || 'N/A'}\nDetails: ${generalErrorDetails}\nTimestamp: ${new Date().toISOString()}`;
throw new Error(`Error\n${debugInfo}\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_text]`);
}
}
async function handleListLanguages(args) {
const { includeQuality = true } = args;
// Use MCP JSON-RPC protocol for list_supported_languages
const mcpRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: 'list_supported_languages',
arguments: { includeQuality }
}
};
try {
const response = await axios.post(MCP_SERVER_URL, mcpRequest, {
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
});
if (response.data.error) {
const errorMsg = response.data.error.message || response.data.error;
throw new Error(`Languages service error: ${errorMsg}`);
}
const result = response.data.result;
if (result && result.content && result.content[0]) {
const textContent = result.content[0].text;
// Try to parse as JSON for structured data
try {
const parsed = JSON.parse(textContent);
// Format the language data nicely
let content = 'đ Supported Languages\n';
content += '===================\n\n';
if (parsed.languages && Array.isArray(parsed.languages)) {
if (includeQuality) {
// Group by quality levels
const highQuality = parsed.languages.filter(lang => lang.quality === 'high');
const mediumQuality = parsed.languages.filter(lang => lang.quality === 'medium');
if (highQuality.length > 0) {
content += '## High Quality (Recommended for Production)\n';
highQuality.forEach(lang => {
content += `- \`${lang.code}\`: ${lang.name}\n`;
});
content += '\n';
}
if (mediumQuality.length > 0) {
content += '## Medium Quality (Good with Review)\n';
mediumQuality.forEach(lang => {
content += `- \`${lang.code}\`: ${lang.name}\n`;
});
content += '\n';
}
} else {
parsed.languages.forEach(lang => {
content += `- \`${lang.code}\`: ${lang.name}\n`;
});
content += '\n';
}
content += `đ **Total Languages**: ${parsed.total || parsed.languages.length}\n\n`;
if (parsed.qualityLevels) {
content += `Quality Breakdown:\n`;
content += `⢠High Quality: ${parsed.qualityLevels.high} languages\n`;
content += `⢠Medium Quality: ${parsed.qualityLevels.medium} languages\n\n`;
}
}
content += 'đĄ Usage Tips:\n';
content += '- Use language codes (e.g., "es") or full names (e.g., "Spanish")\n';
content += '- High quality languages are recommended for production use\n';
content += '- Medium quality languages work well with human review\n';
return {
content: [
{
type: 'text',
text: content,
},
],
};
} catch {
// Return raw text if not JSON
return result;
}
}
return result;
} catch (error) {
console.error('List languages error:', error);
// Fallback to basic language list if service is unavailable
const fallbackContent = `đ Supported Languages (Fallback)\n` +
`==================================\n\n` +
`Service temporarily unavailable. Here are the main supported languages:\n\n` +
`⢠\`es\`: Spanish\n⢠\`fr\`: French\n⢠\`de\`: German\n⢠\`it\`: Italian\n` +
`⢠\`pt\`: Portuguese\n⢠\`ja\`: Japanese\n⢠\`ko\`: Korean\n⢠\`zh\`: Chinese\n` +
`⢠\`ru\`: Russian\n⢠\`ar\`: Arabic\n⢠\`hi\`: Hindi\n⢠\`nl\`: Dutch\n\n` +
`Error: ${error.message}`;
return {
content: [
{
type: 'text',
text: fallbackContent,
},
],
};
}
}
async function handleTranslateFile(args) {
// DEBUG: Log ALL args received from Claude Code
console.error('đ [MCP CLIENT] handleTranslateFile received args:', JSON.stringify(Object.keys(args)));
console.error('đ [MCP CLIENT] targetLanguages value:', args.targetLanguages);
console.error('đ [MCP CLIENT] Full args:', JSON.stringify(args).substring(0, 500));
const {
filePath,
fileContent,
fileType = 'auto',
targetLanguages: rawTargetLanguages,
targetAudience = 'general',
industry = 'technology',
preserveKeys = true,
outputFormat = 'same',
sourceLanguage,
region,
context,
pseudoTranslation,
pseudoOptions,
namespace,
skipWarnings = false
} = args;
if (!filePath && !fileContent) {
throw new Error('Either filePath or fileContent must be provided');
}
// Auto-detect namespace if not provided and filePath is available
let finalNamespace = namespace;
let detectionInfo = null;
if (!namespace && filePath) {
const detection = detectNamespaceFromPath(filePath);
if (detection.suggestion && detection.confidence > 0.5) {
finalNamespace = detection.suggestion;
detectionInfo = detection;
console.error(`đŻ [MCP CLIENT] Auto-detected namespace: "${finalNamespace}" (confidence: ${Math.round(detection.confidence * 100)}%, source: ${detection.source})`);
}
}
if (!finalNamespace) {
// Provide helpful suggestions when namespace is missing
const suggestionText = filePath
? getNamespaceSuggestionText(filePath, path.basename(filePath))
: getNamespaceSuggestionText(null, null);
throw new Error(`namespace is required for translation tracking and project organization.\n\n${suggestionText}`);
}
// Normalize targetLanguages - accept both string and array
let targetLanguages = rawTargetLanguages;
let targetLanguage = undefined;
if (typeof rawTargetLanguages === 'string') {
// Single language provided as string - convert to array for internal processing
targetLanguages = [rawTargetLanguages];
targetLanguage = rawTargetLanguages;
} else if (Array.isArray(rawTargetLanguages) && rawTargetLanguages.length === 1) {
// Single language provided as array - extract for backward compatibility
targetLanguage = rawTargetLanguages[0];
}
if (!targetLanguages?.length) {
throw new Error('targetLanguages parameter is required (can be a string for single language or array for multiple languages)');
}
// Read file content if path provided and no content given
let content = fileContent;
if (filePath && !fileContent) {
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`Failed to read file: ${error.message}`);
}
}
// Check if this is a large file that might need async processing
const isLargeFile = content.length > 50000; // > 50KB
// Build arguments object, filtering out undefined values (they get stripped by JSON.stringify)
const requestArgs = {
apiKey: API_KEY,
filePath,
fileContent: content,
fileType,
sourceLanguage,
targetAudience,
industry,
preserveKeys,
outputFormat,
namespace: finalNamespace
};
// Add optional parameters only if defined
if (targetLanguage !== undefined) requestArgs.targetLanguage = targetLanguage;
if (targetLanguages !== undefined) requestArgs.targetLanguages = targetLanguages;
if (region !== undefined) requestArgs.region = region;
if (context !== undefined) requestArgs.context = context;
if (pseudoTranslation !== undefined) requestArgs.pseudoTranslation = pseudoTranslation;
if (pseudoOptions !== undefined) requestArgs.pseudoOptions = pseudoOptions;
if (skipWarnings !== undefined) requestArgs.skipWarnings = skipWarnings;
// Use MCP JSON-RPC protocol for translate_file
const mcpRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: 'translate_file',
arguments: requestArgs
}
};
try {
const response = await axios.post(MCP_SERVER_URL, mcpRequest, {
headers: {
'Content-Type': 'application/json',
},
timeout: isLargeFile ? 600000 : 300000, // 10 minutes for large files, 5 minutes for normal
});
if (response.data.error) {
const errorMsg = response.data.error.message || response.data.error;
const isAuthError = errorMsg.toString().toLowerCase().includes('api key') ||
errorMsg.toString().toLowerCase().includes('api_key') ||
errorMsg.toString().toLowerCase().includes('unauthorized');
const isCreditError = errorMsg.toString().toLowerCase().includes('credit') ||
errorMsg.toString().toLowerCase().includes('quota') ||
errorMsg.toString().toLowerCase().includes('limit exceeded');
let finalErrorMsg = `Translation service error: ${errorMsg}`;
if (!isAuthError && !isCreditError) {
finalErrorMsg += `. Please retry with a smaller text chunk or split the content into multiple smaller requests.`;
}
throw new Error(finalErrorMsg);
}
// Check if we got an async job response
const result = response.data.result;
if (result && result.content && result.content[0]) {
const textContent = result.content[0].text;
// Try to parse as JSON to check for job ID
try {
const parsed = JSON.parse(textContent);
if (parsed.status === 'processing' && parsed.jobId) {
// Async job started - poll for status
return await pollTranslationJob(parsed.jobId, parsed.estimatedTime);
}
} catch {
// Not JSON or not an async response, return as-is
}
}
return result;
} catch (error) {
// Debug info will be included in error messages for visibility
if (error.code === 'ECONNABORTED') {
return {
content: [
{
type: 'text',
text: `â ď¸ Translation Timeout\n\n` +
`The file is large and taking longer than expected to translate.\n` +
`This is normal for files over 50KB or with 100+ strings.\n\n` +
`What's happening:\n` +
`⢠The translation is still processing on the server\n` +
`⢠Large files are chunked and processed with full 8-step pipeline\n` +
`⢠Each chunk ensures terminology consistency\n\n` +
`Recommendations:\n` +
`1. Try splitting the file into smaller parts\n` +
`2. Use the translate_text tool for smaller batches\n` +
`3. Contact support if this persists\n\n` +
`File size: ${content.length} characters`
}
]
};
}
// Handle 401 unauthorized - invalid API key
if (error.response?.status === 401) {
const fileAuthErrorDetails = error.response.data?.message || error.response.data?.result?.content?.[0]?.text || error.message;
throw new Error(`â Invalid API key (401)\nDetails: ${fileAuthErrorDetails}\nPlease check your API key at https://app.i18nagent.ai\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_file]`);
}
// Handle 402 payment required with user-friendly message
if (error.response?.status === 402) {
const fileCreditErrorDetails = error.response.data?.message || error.response.data?.result?.content?.[0]?.text || error.message;
throw new Error(`â ď¸ Insufficient credits (402)\nDetails: ${fileCreditErrorDetails}\nPlease top up at https://app.i18nagent.ai\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_file]`);
}
// Check if it's a timeout issue (45-second server timeout) or large file issue
const timeoutErrorDetails = error.response?.data?.result?.content?.[0]?.text ||
error.response?.data?.error?.message ||
error.message;
if (error.response?.status === 413 ||
(error.response?.status === 503 && content.length > 50000) ||
(error.response?.status === 503 && timeoutErrorDetails.includes('timeout after 45 seconds'))) {
const errorMsg = `File too large or complex (${content.length} characters)\n\nThe server has a 45-second timeout. Your file requires more processing time.\n\nPlease break into smaller chunks:\n⢠Split files over 50KB into multiple parts\n⢠Translate sections separately (e.g., split by top-level keys for JSON)\n⢠Use translate_text for batches of 50-100 strings\n⢠Each chunk should process in under 45 seconds\n\nAlternatively, wait for async job support (coming soon).\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_file]`;
throw new Error(errorMsg);
}
// Check if it's actually a service unavailable error (only for real infrastructure issues)
if (error.response?.status === 503 && content.length <= 50000) {
throw new Error(`i18n-agent encountered unexpected problem, and we are working on it, try again later.`);
}
if (error.code === 'ECONNREFUSED' ||
error.code === 'ETIMEDOUT' ||
error.code === 'ENOTFOUND' ||
error.response?.status === 502 ||
error.response?.status === 504) {
const serviceErrorDetails = error.response?.data?.result?.content?.[0]?.text ||
error.response?.data?.error?.message ||
error.message;
const debugInfo = `Code: ${error.code || 'N/A'}\nStatus: ${error.response?.status || 'N/A'}\nStatusText: ${error.response?.statusText || 'N/A'}\nDetails: ${serviceErrorDetails}\nURL: ${error.config?.url || 'N/A'}\nTimestamp: ${new Date().toISOString()}`;
throw new Error(`Translation service error\n${debugInfo}\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_file]`);
}
// For other errors, include all debug info in the error message
const finalErrorDetails = error.response?.data?.result?.content?.[0]?.text ||
error.response?.data?.error?.message ||
error.message;
const debugInfo = `Status: ${error.response?.status || 'N/A'}\nStatusText: ${error.response?.statusText || 'N/A'}\nDetails: ${finalErrorDetails}\nTimestamp: ${new Date().toISOString()}`;
throw new Error(`Error\n${debugInfo}\n[MCP v${MCP_CLIENT_VERSION}/STDIO/translate_file]`);
}
}
// Format translation result for consistent output
function formatTranslationResult(parsedResult, texts, targetLanguage, sourceLanguage, targetAudience, industry, region) {
return {
translatedTexts: parsedResult?.translatedTexts || [],
content: [
{
type: 'text',
text: `Translation Results:\n\n` +
`đ ${parsedResult?.sourceLanguage || sourceLanguage || 'Auto-detected'} â ${parsedResult?.targetLanguage || targetLanguage}\n` +
`đĽ Audience: ${parsedResult?.targetAudience || targetAudience}\n` +
`đ Industry: ${parsedResult?.industry || industry}\n` +
`${parsedResult?.region || region ? `đ Region: ${parsedResult?.region || region}\n` : ''}` +
`âąď¸ Processing Time: ${parsedResult?.processingTimeMs || 'N/A'}ms\n` +
`â
Valid: ${parsedResult?.isValid !== undefined ? parsedResult.isValid : 'N/A'}\n\n` +
`đ Translations:\n` +
(parsedResult?.translatedTexts || []).map((text, index) =>
`${index + 1}. "${(parsedResult?.originalTexts || texts)[index]}" â "${text}"`
).join('\n'),
},
],
};
}
// Poll for async translation job status
async function pollTranslationJob(jobId, estimatedTime) {
const maxPolls = 60; // Max 10 minutes of polling
const pollInterval = 10000; // Poll every 10 seconds
for (let i = 0; i < maxPolls; i++) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
const statusRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: 'check_translation_status',
arguments: { jobId }
}
};
const response = await axios.post(MCP_SERVER_URL, statusRequest, {
headers: { 'Content-Type': 'application/json' },
timeout: 30000
});
if (response.data.error) {
const errorMsg = response.data.error.message || response.data.error;
const isAuthError = errorMsg.toString().toLowerCase().includes('api key') ||
errorMsg.toString().toLowerCase().includes('api_key') ||
errorMsg.toString().toLowerCase().includes('unauthorized');
const isCreditError = errorMsg.toString().toLowerCase().includes('credit') ||
errorMsg.toString().toLowerCase().includes('quota') ||
errorMsg.toString().toLowerCase().includes('limit exceeded');
let finalErrorMsg = `Status check error: ${errorMsg}`;
if (!isAuthError && !isCreditError) {
finalErrorMsg += `. Please retry with a smaller chunk or split the content into multiple requests.`;
}
throw new Error(finalErrorMsg);
}
const result = response.data.result;
if (result && result.content && result.content[0]) {
const status = JSON.parse(result.content[0].text);
if (status.status === 'completed') {
return status.result;
} else if (status.status === 'failed') {
const errorMsg = status.error;
const isAuthError = errorMsg.toString().toLowerCase().includes('api key') ||
errorMsg.toString().toLowerCase().includes('api_key') ||
errorMsg.toString().toLowerCase().includes('unauthorized');
const isCreditError = errorMsg.toString().toLowerCase().includes('credit') ||
errorMsg.toString().toLowerCase().includes('quota') ||
errorMsg.toString().toLowerCase().includes('limit exceeded');
let finalErrorMsg = `Translation failed: ${errorMsg}`;
if (!isAuthError && !isCreditError) {
finalErrorMsg += `. Please retry with a smaller chunk or split the content into multiple requests.`;
}
throw new Error(finalErrorMsg);
}
// Still processing - continue polling
console.error(`Translation progress: ${status.progress}% (${status.message})`);
}
} catch (error) {
console.error(`Error polling job status: ${error.message}`);
// Continue polling even if status check fails
}
}
throw new Error(`Translation job ${jobId} timed out after ${maxPolls * pollInterval / 1000} seconds. Please retry with a smaller chunk or split the content into multiple requests.`);
}
async function handleAnalyzeContent(args) {
const {
content,
fileType,
sourceLanguage,
targetLanguage,
industry = 'general',
targetAudience = 'general',
region
} = args;
if (!content) {
throw new Error('content is required');
}
if (!targetLanguage) {
throw new Error('targetLanguage is required');
}
// Use MCP JSON-RPC protocol for analyze_content
const mcpRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: 'analyze_content',
arguments: {
apiKey: API_KEY,
content,
fileType,
sourceLanguage,
targetLanguage,
industry,
targetAudience,
region
}
}
};
try {
const response = await axios.post(MCP_SERVER_URL, mcpRequest, {
headers: {
'Content-Type': 'application/json',
},
timeout: 60000, // 1 minute timeout for analysis
});
if (response.data.error) {
const errorMsg = response.data.error.message || response.data.error;
throw new Error(`Content analysis error: ${errorMsg}`);
}
return response.data.result;
} catch (error) {
// Handle 401 unauthorized
if (error.response?.status === 401) {
const authErrorDetails = error.response.data?.message || error.response.data?.result?.content?.[0]?.text || error.message;
throw new Error(`â Invalid API key (401)\nDetails: ${authErrorDetails}\nPlease check your API key at https://app.i18nagent.ai\n[MCP v${MCP_CLIENT_VERSION}/STDIO/analyze_content]`);
}
// Handle 402 payment required
if (error.response?.status === 402) {
const creditErrorDetails = error.response.data?.message || error.response.data?.result?.content?.[0]?.text || error.message;
throw new Error(`â ď¸ Insufficient credits (402)\nDetails: ${creditErrorDetails}\nPlease top up at https://app.i18nagent.ai\n[MCP v${MCP_CLIENT_VERSION}/STDIO/analyze_content]`);
}
// Handle 503 service unavailable
if (error.response?.status === 503) {
throw new Error(`i18n-agent encountered unexpected problem, and we are working on it, try again later.`);
}
console.error('Content analysis error:', error);
throw new Error(`Unable to analyze content: ${error.message}`);
}
}
async function handleGetCredits(args) {
const { apiKey } = args;
const creditsApiKey = apiKey || API_KEY;
// Use MCP JSON-RPC protocol for get_credits
const mcpRequest = {
jsonrpc: '2.0',
id: