lokalise-mcp
Version:
The Lokalise MCP Server brings Lokalise's localization power to Claude and AI assistants—manage projects, keys, and translations by chat.
445 lines (378 loc) • 13.2 kB
JavaScript
/**
* Generate manifest.json with auto-discovered tools from the codebase
* This script reads all domain tools and generates the tools array for the DXT manifest
*/
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
// Get script directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(fileURLToPath(new URL(".", import.meta.url)));
const PROJECT_ROOT = join(__dirname, "..");
// Paths
const MANIFEST_PATH = join(PROJECT_ROOT, "manifest.json");
const MANIFEST_TEMPLATE_PATH = join(
PROJECT_ROOT,
"scripts/templates/manifest.template.json",
);
const DOMAINS_PATH = join(PROJECT_ROOT, "src/domains");
const PACKAGE_PATH = join(PROJECT_ROOT, "package.json");
/**
* Extract tool registrations from a tool file
*/
async function extractToolsFromFile(filePath) {
try {
const content = await readFile(filePath, "utf-8");
const tools = [];
// Match server.tool() calls with better multiline support
// This regex captures the tool name and the full description string
const toolRegex =
/server\.tool\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]((?:[^"'`\\]|\\.)*)["'`]/gs;
let match;
// biome-ignore lint/suspicious/noAssignInExpressions: YOLO
while ((match = toolRegex.exec(content)) !== null) {
const name = match[1] ? match[1].trim() : "";
const description = match[2] ? match[2].trim() : "";
tools.push({
name,
description,
});
}
return tools;
} catch (error) {
console.error(chalk.red(`Error reading ${filePath}:`), error.message);
return [];
}
}
/**
* Discover all domain tool files
*/
async function discoverToolFiles() {
const { readdir } = await import("node:fs/promises");
const toolFiles = [];
try {
// Read all domain directories
const domains = await readdir(DOMAINS_PATH, { withFileTypes: true });
for (const domain of domains) {
if (domain.isDirectory() && !domain.name.startsWith(".")) {
const domainPath = join(DOMAINS_PATH, domain.name);
const files = await readdir(domainPath);
// Find tool files
const toolFile = files.find(
(f) => f.endsWith(".tool.ts") || f.endsWith(".tool.js"),
);
if (toolFile) {
toolFiles.push(join(domainPath, toolFile));
}
}
}
} catch (error) {
console.error(chalk.red("Error discovering domains:"), error.message);
}
return toolFiles;
}
/**
* Extract all prompts from the codebase (future enhancement)
*/
async function extractPrompts() {
// TODO: Implement prompt extraction from domains
// For now, return empty array or read from existing manifest
try {
const existingManifest = JSON.parse(await readFile(MANIFEST_PATH, "utf-8"));
return existingManifest.prompts || [];
} catch {
return [];
}
}
/**
* Load package.json data
*/
async function loadPackageJson() {
try {
const content = await readFile(PACKAGE_PATH, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error(chalk.red("Error loading package.json:"), error.message);
return {};
}
}
/**
* Load existing manifest or template
*/
async function loadManifestBase() {
try {
// Try to load existing manifest first
const content = await readFile(MANIFEST_PATH, "utf-8");
return JSON.parse(content);
} catch {
try {
// Fall back to template
const content = await readFile(MANIFEST_TEMPLATE_PATH, "utf-8");
return JSON.parse(content);
} catch {
// Return minimal structure
return {
dxt_version: "0.1",
name: "lokalise-mcp",
display_name: "Lokalise MCP",
description: "Lokalise integration for AI assistants",
};
}
}
}
/**
* Generate compelling long description based on available tools
*/
function generateLongDescription(tools, domainCounts) {
// Count tools by operation type
const operationCounts = {};
tools.forEach((tool) => {
const operation = tool.name.split("_").slice(-1)[0]; // Get last part (create, list, update, etc.)
operationCounts[operation] = (operationCounts[operation] || 0) + 1;
});
// Calculate domain statistics
const totalDomains = Object.keys(domainCounts).length;
const totalTools = tools.length;
// Build the description
let description = `# Lokalise MCP Server
> **Disclaimer**: This is an independent project and not affiliated with Lokalise Inc. It uses the publicly available Lokalise Node.js SDK to provide MCP integration.
## Transform Your Localization Workflow with Conversational AI
**Stop clicking, start commanding.** With ${totalTools} intelligent tools across ${totalDomains} domains, this MCP server transforms Lokalise from a complex UI into your personal localization assistant. Simply describe what you need in natural language, and watch as your AI orchestrates sophisticated API workflows in seconds.
Instead of clicking through multiple screens, just chat with your AI assistant:
- *"Show me all projects with less than 80% translation progress"*
- *"Create a new mobile app project with German, French, and Spanish support"*
- *"Find all untranslated keys in the iOS app and assign them to the German team"*
- *"Archive all keys tagged 'v1_deprecated' across all projects"*
### ${totalTools} Production-Ready Tools Across ${totalDomains} Domains
`;
// Group tools by actual domain (not operation)
const actualDomainCounts = {};
tools.forEach((tool) => {
// Extract domain name from the tool name (e.g., lokalise_list_projects -> projects)
const parts = tool.name.split("_");
let domain = parts[parts.length - 1]; // Get last part as initial guess
// Special cases where domain is in a different position
if (tool.name.includes("_project_")) {
domain = "projects";
} else if (tool.name.includes("_key_") || tool.name.includes("_keys")) {
domain = "keys";
} else if (
tool.name.includes("_language") ||
tool.name.includes("_project_languages")
) {
domain = "languages";
} else if (tool.name.includes("_task")) {
domain = "tasks";
} else if (tool.name.includes("_translation")) {
domain = "translations";
} else if (tool.name.includes("_contributor")) {
domain = "contributors";
} else if (tool.name.includes("_glossary")) {
domain = "glossary";
} else if (tool.name.includes("_comment")) {
domain = "comments";
}
// Normalize plural to singular for consistency
if (domain.endsWith("s") && domain !== "glossary") {
const singular = domain.slice(0, -1);
if (
[
"project",
"key",
"language",
"task",
"translation",
"contributor",
"comment",
].includes(singular)
) {
// Keep plural for display
}
}
actualDomainCounts[domain] = (actualDomainCounts[domain] || 0) + 1;
});
// Add domain breakdown
const sortedDomains = Object.entries(actualDomainCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 8); // Show up to 8 domains
sortedDomains.forEach(([domain, count]) => {
const domainTitle = domain.charAt(0).toUpperCase() + domain.slice(1);
description += `**${domainTitle} (${count} tools)** - `;
// Add domain-specific value prop
switch (domain) {
case "projects":
description +=
"Complete project lifecycle management from creation to archival\n";
break;
case "keys":
description +=
"Intelligent key management with bulk operations and smart filtering\n";
break;
case "languages":
description += "Multi-language orchestration with progress tracking\n";
break;
case "tasks":
description += "Workflow automation and deadline management\n";
break;
case "translations":
description += "Direct translation management with review workflows\n";
break;
case "contributors":
description += "Team collaboration and permission management\n";
break;
case "glossary":
description += "Terminology consistency and brand voice enforcement\n";
break;
case "comments":
description += "Contextual discussions and translator guidance\n";
break;
default:
description += `Comprehensive ${domain} management capabilities\n`;
}
});
description += `
### Real-World Workflows That Save Hours
**For Localization Managers:**
- *Morning standup in 10 seconds*: "Give me the health check for all active projects. Who's behind schedule?"
- *Instant team scaling*: "Add marie@company.com as a French reviewer to all e-commerce projects"
- *Proactive monitoring*: "Alert me when any project drops below 70% completion"
**For Developers:**
- *Zero-friction deployment*: "Sync the feature/new-login branch to Lokalise and create tasks for Spanish and German"
- *Instant validation*: "Check if all keys in the Android app have iOS equivalents"
- *Automated QA*: "Find all keys with unbalanced brackets and create a cleanup task"
**For Marketing Teams:**
- *Campaign launch in one command*: "Create a project from this Google Doc, translate to our tier-1 markets, use informal tone"
- *Brand consistency*: "Add 'QuantumLeap' to the glossary as non-translatable across all projects"
- *Content updates*: "Update all homepage headlines with the new tagline across all languages"
**Traditional UI Workflow:**
- Navigate through multiple screens
- Click through complex forms
- Context switch between tools
- Time: 10-15 minutes per task
**Conversational MCP Workflow:**
- Express intent in natural language
- AI orchestrates API calls
- Results delivered instantly
- Time: 10-30 seconds per task
### Get Started in 30 Seconds
1. Install the MCP server
2. Add your Lokalise API token
3. Start commanding your localization workflow
`;
return description;
}
/**
* Generate the manifest
*/
async function generateManifest() {
console.log(chalk.cyan.bold("\n🔧 Generating manifest.json...\n"));
// Load base manifest
const manifest = await loadManifestBase();
// Load package.json for metadata
const packageJson = await loadPackageJson();
// Update version from package.json
if (packageJson.version) {
manifest.version = packageJson.version;
}
// Update author info
if (packageJson.author) {
manifest.author = packageJson.author;
}
// Update repository
if (packageJson.repository) {
manifest.repository = packageJson.repository;
}
// Discover and extract tools
console.log(chalk.yellow("📂 Discovering domain tools..."));
const toolFiles = await discoverToolFiles();
console.log(chalk.gray(`Found ${toolFiles.length} domain tool files`));
const allTools = [];
for (const file of toolFiles) {
const domainName = file.split("/").slice(-2)[0];
console.log(chalk.gray(` - Extracting from ${domainName} domain...`));
const tools = await extractToolsFromFile(file);
allTools.push(...tools);
}
console.log(chalk.green(`✅ Extracted ${allTools.length} tools`));
// Sort tools alphabetically
allTools.sort((a, b) => a.name.localeCompare(b.name));
// Calculate domain counts
const domainCounts = {};
for (const tool of allTools) {
const domain = tool.name.split("_")[1]; // Extract domain from tool name
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
}
// Update manifest
manifest.tools = allTools;
manifest.tools_generated = true;
// Generate long description
console.log(chalk.yellow("📝 Generating long description..."));
manifest.long_description = generateLongDescription(allTools, domainCounts);
// Extract prompts (if implemented)
const prompts = await extractPrompts();
if (prompts.length > 0) {
manifest.prompts = prompts;
manifest.prompts_generated = false; // Set to true when auto-generation is implemented
}
// Write the manifest
const manifestContent = JSON.stringify(manifest, null, "\t");
await writeFile(MANIFEST_PATH, manifestContent);
console.log(
chalk.green.bold(
`\n✅ Generated manifest.json with ${allTools.length} tools\n`,
),
);
// Display summary
console.log(chalk.cyan("📋 Tool Summary:"));
Object.entries(domainCounts).forEach(([domain, count]) => {
console.log(chalk.gray(` - ${domain}: ${count} tools`));
});
console.log(chalk.gray(`\nManifest saved to: ${MANIFEST_PATH}`));
}
/**
* Validate the generated manifest against DXT schema
*/
async function validateManifest() {
// TODO: Implement validation against DXT schema
// For now, just check required fields
try {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf-8"));
const required = [
"dxt_version",
"name",
"version",
"description",
"author",
"server",
];
const missing = required.filter((field) => !manifest[field]);
if (missing.length > 0) {
console.log(
chalk.yellow(`\n⚠️ Missing required fields: ${missing.join(", ")}`),
);
return false;
}
console.log(chalk.green("\n✅ Manifest validation passed"));
return true;
} catch (error) {
console.error(chalk.red("\n❌ Manifest validation failed:"), error.message);
return false;
}
}
// Main execution
async function main() {
try {
await generateManifest();
await validateManifest();
} catch (error) {
console.error(chalk.red("\n❌ Error generating manifest:"), error.message);
process.exit(1);
}
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
export { generateManifest, validateManifest };