deepl-mcp-server
Version:
MCP server for DeepL translation API
678 lines (591 loc) • 23 kB
JavaScript
/*--------------------------------------------------------------------
* Imports and constants
*-------------------------------------------------------------------*/
import { createRequire } from "node:module";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as deepl from "deepl-node";
const { version: packageVersion } = createRequire(import.meta.url)("../package.json");
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (!DEEPL_API_KEY) {
console.error(
"DEEPL_API_KEY is not set. Create an API key at https://www.deepl.com/pro-api and provide it to this server as the DEEPL_API_KEY environment variable.",
);
process.exit(1);
}
const deeplClientOptions = {
appInfo: {
appName: "DeepL-MCP",
appVersion: packageVersion,
},
};
// Descriptive text for reuse in our tools
const languageCodeDescription =
"language code, in standard ISO-639-1 format (e.g. 'en-US', 'de', 'fr')";
const sourceLanguageCodeDescription =
"language code, in standard ISO-639-1 format (e.g. 'en', 'de', 'fr')";
const glossaryLanguageCodeDescription =
"language code, in standard ISO-639-1 format without a regional variant (e.g. 'en', 'de', 'fr')";
const glossaryEntriesGuidance =
"This does not fetch any glossary entries. Use the get-glossary-dictionary-entries tool to fetch entries.";
const styleRuleDescription =
"Style rule ID to apply. Use the list-style-rules tool to discover available style rules.";
/*--------------------------------------------------------------------
* Set up DeepL things
*-------------------------------------------------------------------*/
const deeplClient = new deepl.DeepLClient(DEEPL_API_KEY, deeplClientOptions);
// Import WritingStyle and WritingTone enums from DeepL, and transform each to arrays of strings
const writingStyles = /** @type {[string, ...string[]]} */ (Object.values(deepl.WritingStyle));
const writingTones = /** @type {[string, ...string[]]} */ (Object.values(deepl.WritingTone));
const formalityTypes = /** @type {const} */ ([
"less",
"more",
"default",
"prefer_less",
"prefer_more",
]);
/**
* Text inputs take a single string or an array of strings, each handled independently
* @param {string} verb
*/
function textInput(verb) {
return z
.union([z.string(), z.array(z.string())])
.describe(`Text to ${verb}, as a single string or an array of strings handled independently`);
}
/**
* Class to handle a list of languages and associated ISO-639 codes.
* We normalize all language codes to lowercase
* so that lowercase/uppercase differences don't inspire mistakes.
*
* @property {Array<{name: string, code: string}>} list
* @property {string} codesList - Comma-separated list of all language codes
*/
class LanguagesList {
static countryDefaults = {
en: "en-US",
pt: "pt-BR",
zh: "zh-Hans",
};
static #pending = new Map();
constructor(list, direction = null) {
this.list = list;
this.codesList = list.map((lang) => lang.code).join(", ");
this.direction = direction;
}
static async create(direction) {
if (direction != "source" && direction !== "target") {
throw new Error('LanguagesList needs to be called with "target" or "source"');
}
const method = direction === "source" ? "getSourceLanguages" : "getTargetLanguages";
const langs = await deeplClient[method]();
const lowerCaseLangs = langs.map(({ name, code }) => ({ name, code: code.toLowerCase() }));
const instance = new LanguagesList(lowerCaseLangs, direction);
return instance;
}
static async get(direction) {
let pending = LanguagesList.#pending.get(direction);
if (!pending) {
pending = LanguagesList.create(direction).catch((error) => {
LanguagesList.#pending.delete(direction);
throw error;
});
LanguagesList.#pending.set(direction, pending);
}
return pending;
}
/**
* Given an ISO-639 language code, throw an error if it's not in our codes list
* @param {string} code
*
* At present, our client libraries don't accept two-letter language codes for target_lang
* for cases where we support _locales_ - a language code plus country code, like "en-US".
* For example, if you specify `target_lang="en"`, you'll get an error. We want "en-US" or "en-UK".
* But in this server we don't want to reject such `target_lang`'s, because AI clients
* often want to send them.
*
* So we're changing the `validate()` method to `normalize()`. We will still throw an error if
* we're passed an invalid code. But if we're passed a code that requires a country code as well,
* like "pt", we'll return the default, like "pt-BR".
*/
normalize(code) {
const lowerCode = code.toLowerCase();
let countryDefault;
// For target languages, if a language requires a country code (like pt-BR), return that
if (
this.direction === "target" &&
(countryDefault = LanguagesList.countryDefaults[lowerCode])
) {
return countryDefault;
}
if (this.list.some((lang) => lang.code === lowerCode)) {
return lowerCode;
}
const baseCode = lowerCode.split("-")[0];
if (this.direction === "source" && this.list.some((lang) => lang.code === baseCode)) {
return baseCode;
}
throw new Error(`Invalid language code: ${lowerCode}. Available codes: ${this.codesList}`);
}
}
/*--------------------------------------------------------------------
* Create MCP server
*-------------------------------------------------------------------*/
const server = new McpServer({
name: "deepl",
version: packageVersion,
});
/*--------------------------------------------------------------------
* Server tools
*-------------------------------------------------------------------*/
server.tool(
"get-source-languages",
"Get list of available source languages for translation",
getSourceLanguages,
);
server.tool(
"get-target-languages",
"Get list of available target languages for translation",
getTargetLanguages,
);
server.tool(
"translate-text",
"Translate text to a target language using DeepL API. Review all available optional parameters and use those applicable to your scenario for best results. When the translation includes a glossary, you must specify the source language as well as the target language. If the user requests a glossary by name instead of by id, you can use the list-glossaries tool to get a name for each id.",
{
text: textInput("translate"),
sourceLangCode: z
.string()
.optional()
.describe(`source ${sourceLanguageCodeDescription}, or leave empty for auto-detection`),
targetLangCode: z.string().describe("target " + languageCodeDescription),
formality: z
.enum(formalityTypes)
.optional()
.describe(
"Controls formality: 'less' for informal, 'more' for formal/polite, 'prefer_less'/'prefer_more' to prefer but fall back to default",
),
glossaryId: z
.string()
.optional()
.describe("Glossary ID to ensure consistent terminology translation"),
styleId: z.string().optional().describe(styleRuleDescription),
context: z
.string()
.optional()
.describe(
"Recommended: describe what this text is about (e.g., 'Technical documentation for a software API'). Improves translation accuracy but is not itself translated.",
),
preserveFormatting: z
.boolean()
.optional()
.describe(
"Set to true to preserve original formatting - recommended for markdown, code blocks, HTML, or any structured text",
),
splitSentences: z
.enum(["0", "1", "nonewlines"])
.optional()
.describe(
"Sentence splitting: '0' disables, '1' (default) splits on punctuation and newlines, 'nonewlines' preserves line breaks",
),
customInstructions: z
.array(z.string())
.optional()
.describe(
"Array of custom instructions to guide translation style (max 10 instructions, 300 chars each)",
),
},
translateText,
);
server.tool(
"get-writing-styles",
"Get list of writing styles the DeepL API can use while rephrasing text",
getWritingStyles,
);
server.tool(
"get-writing-tones",
"Get list of writing tones the DeepL API can use while rephrasing text",
getWritingTones,
);
server.tool(
"rephrase-text",
"Rephrase text in the same language, or into a different language, using DeepL API",
{
text: textInput("rephrase"),
targetLangCode: z
.string()
.optional()
.describe(
`target ${languageCodeDescription} to rephrase into a different language, or leave empty to keep the original language`,
),
style: z.enum(writingStyles).optional().describe("Writing style for rephrasing"),
tone: z.enum(writingTones).optional().describe("Writing tone for rephrasing"),
},
rephraseText,
);
server.tool(
"translate-document",
"Translate a document file using DeepL API",
{
inputFile: z.string().describe("Path to the input document file to translate"),
outputFile: z
.string()
.optional()
.describe(
"Path where the translated document will be saved (if not provided, will be auto-generated)",
),
sourceLangCode: z
.string()
.optional()
.describe(`source ${sourceLanguageCodeDescription}, or leave empty for auto-detection`),
targetLangCode: z.string().describe("target " + languageCodeDescription),
formality: z
.enum(["less", "more", "default", "prefer_less", "prefer_more"])
.optional()
.describe("Controls whether translations should lean toward informal or formal language"),
glossaryId: z.string().optional().describe("ID of glossary to use for translation"),
styleId: z.string().optional().describe(styleRuleDescription),
outputFormat: z
.string()
.optional()
.describe(
"Desired output file format (e.g. 'pdf'), or leave empty to keep the input format. Only some conversions are supported.",
),
},
translateDocument,
);
server.tool(
"list-glossaries",
"Get a list of all glossaries with metadata for each - name, dictionaries available, and creation time. " +
glossaryEntriesGuidance,
listGlossaries,
);
server.tool(
"get-glossary-info",
"Given an id, get metadata about the glossary with that id - its name, available dictionaries, and creation time. " +
glossaryEntriesGuidance,
{
glossaryId: z.string().describe("The unique identifier of the glossary"),
},
getGlossary,
);
server.tool(
"get-glossary-dictionary-entries",
"Retrieve all the entries from a given glossary dictionary. (A glossary consists one of one or more dictionaries, each of which contains entries for a specific language pair, in one direction. For example, one dictionary could contain entries for translations from German to English, and another dictionary could contain entries for translations from English to German.) To retrieve all entries for a glossary with multiple dictionaries, use the get-glossary-info or list-glossaries tool to find out what dictionaries it contains, then use this tool for each dictionary.",
{
glossaryId: z.string().describe("The unique identifier of the glossary"),
sourceLangCode: z.string().describe(`source ${glossaryLanguageCodeDescription}`),
targetLangCode: z.string().describe(`target ${glossaryLanguageCodeDescription}`),
},
getGlossaryDictionaryEntries,
);
server.tool(
"list-style-rules",
"Get a list of all style rules with metadata for each - id, name, language, and timestamps. Style rules can be applied when translating text or documents. Use the get-style-rule tool to fetch the configured rules and custom instructions of a single style rule.",
{
page: z.number().int().min(0).optional().describe("Page number, 0-based"),
pageSize: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe("Number of style rules per page (max 10)"),
detailed: z
.boolean()
.optional()
.describe("Set to true to include the configured rules and custom instructions of each rule"),
},
listStyleRules,
);
server.tool(
"get-style-rule",
"Given an id, get a single style rule with its full detail - configured rules and custom instructions.",
{
styleId: z.string().describe("The unique identifier of the style rule"),
},
getStyleRule,
);
server.tool(
"get-custom-instruction",
"Get a single custom instruction belonging to a style rule. Use the get-style-rule tool to find out which custom instructions a style rule contains.",
{
styleId: z.string().describe("The unique identifier of the style rule"),
instructionId: z.string().describe("The unique identifier of the custom instruction"),
},
getCustomInstruction,
);
/*--------------------------------------------------------------------
* Server tool callback functions
*-------------------------------------------------------------------*/
async function getSourceLanguages() {
try {
const sourceLanguages = await LanguagesList.get("source");
return mcpContentifyText(sourceLanguages.list.map((lang) => JSON.stringify(lang)));
} catch (error) {
throw new Error(`Failed to get source languages: ${error.message}`, { cause: error });
}
}
async function getTargetLanguages() {
try {
const targetLanguages = await LanguagesList.get("target");
return mcpContentifyText(targetLanguages.list.map((lang) => JSON.stringify(lang)));
} catch (error) {
throw new Error(`Failed to get target languages: ${error.message}`, { cause: error });
}
}
async function translateText({
text,
sourceLangCode = null,
targetLangCode,
formality,
glossaryId,
styleId,
context,
preserveFormatting,
splitSentences,
customInstructions,
}) {
if (sourceLangCode) {
const sourceLanguages = await LanguagesList.get("source");
sourceLangCode = sourceLanguages.normalize(sourceLangCode);
}
const targetLanguages = await LanguagesList.get("target");
targetLangCode = targetLanguages.normalize(targetLangCode);
try {
const options = { formality };
if (glossaryId) {
options.glossary = glossaryId;
}
if (styleId) options.styleRule = styleId;
if (context) options.context = context;
if (preserveFormatting !== undefined) options.preserveFormatting = preserveFormatting;
if (splitSentences) options.splitSentences = splitSentences;
if (customInstructions) options.customInstructions = customInstructions;
const result = await deeplClient.translateText(text, sourceLangCode, targetLangCode, options);
const translations = /** @type {import('deepl-node').TextResult[]} */ (
Array.isArray(result) ? result : [result]
);
const detectedSourceLangs = [...new Set(translations.map((t) => t.detectedSourceLang))];
return mcpContentifyText([
...translations.map((translation) => translation.text),
`Detected source language${detectedSourceLangs.length > 1 ? "s" : ""}: ${detectedSourceLangs.join(", ")}`,
`Target language used: ${targetLangCode}`,
]);
} catch (error) {
throw new Error(`Translation failed: ${error.message}`, { cause: error });
}
}
async function rephraseText({ text, targetLangCode, style, tone }) {
if (targetLangCode) {
const targetLanguages = await LanguagesList.get("target");
targetLangCode = standardizeLangCase(targetLanguages.normalize(targetLangCode));
}
try {
const result = await deeplClient.rephraseText(text, targetLangCode ?? null, style, tone);
const rephrasings = /** @type {import('deepl-node').WriteResult[]} */ (
Array.isArray(result) ? result : [result]
);
return mcpContentifyText(rephrasings.map((rephrasing) => rephrasing.text));
} catch (error) {
throw new Error(`Rephrasing failed: ${error.message}`, { cause: error });
}
}
async function getWritingStyles() {
return mcpContentifyText(writingStyles);
}
async function getWritingTones() {
return mcpContentifyText(writingTones);
}
async function translateDocument({
inputFile,
outputFile,
sourceLangCode,
targetLangCode,
formality,
glossaryId,
styleId,
outputFormat,
}) {
if (sourceLangCode) {
const sourceLanguages = await LanguagesList.get("source");
sourceLangCode = sourceLanguages.normalize(sourceLangCode);
}
const targetLanguages = await LanguagesList.get("target");
targetLangCode = targetLanguages.normalize(targetLangCode);
// Generate output file name if not provided
if (!outputFile) {
const path = await import("path");
const parsedPath = path.parse(inputFile);
const extension = outputFormat ? `.${outputFormat.toLowerCase()}` : parsedPath.ext;
outputFile = path.join(parsedPath.dir, `${parsedPath.name}_${targetLangCode}${extension}`);
}
try {
const options = { formality };
if (glossaryId) {
options.glossary = glossaryId;
}
// The client library ignores styleRule for documents, so both extras go via extra parameters
const extraRequestParameters = {};
if (styleId) extraRequestParameters.style_id = styleId;
if (outputFormat) extraRequestParameters.output_format = outputFormat;
if (Object.keys(extraRequestParameters).length > 0) {
options.extraRequestParameters = extraRequestParameters;
}
const result = await deeplClient.translateDocument(
inputFile,
outputFile,
sourceLangCode
? /** @type {import('deepl-node').SourceLanguageCode} */ (sourceLangCode)
: null,
/** @type {import('deepl-node').TargetLanguageCode} */ (targetLangCode),
options,
);
return mcpContentifyText([
`Document translated successfully! Status: ${result.status}`,
`Target language used: ${targetLangCode}`,
`Characters billed: ${result.billedCharacters}`,
`Output file: ${outputFile}`,
]);
} catch (error) {
throw new Error(`Document translation failed: ${error.message}`, { cause: error });
}
}
async function listGlossaries() {
try {
const glossaries = await deeplClient.listMultilingualGlossaries();
if (glossaries.length === 0) {
return mcpContentifyText("No glossaries found");
}
const results = glossaries.map((glossary) =>
JSON.stringify(
{
id: glossary.glossaryId,
name: glossary.name,
dictionaries: glossary.dictionaries,
creationTime: glossary.creationTime,
},
null,
2,
),
);
return mcpContentifyText(results);
} catch (error) {
throw new Error(`Failed to list glossaries: ${error.message}`, { cause: error });
}
}
async function getGlossary({ glossaryId }) {
try {
const glossary = await deeplClient.getMultilingualGlossary(glossaryId);
const result = {
id: glossary.glossaryId,
name: glossary.name,
dictionaries: glossary.dictionaries,
creationTime: glossary.creationTime,
};
return mcpContentifyText(JSON.stringify(result, null, 2));
} catch (error) {
throw new Error(`Failed to get glossary: ${error.message}`, { cause: error });
}
}
async function getGlossaryDictionaryEntries({ glossaryId, sourceLangCode, targetLangCode }) {
try {
if (!sourceLangCode || !targetLangCode) {
throw new Error(
"To access a glossary dictionary, you must specify its source and target languages",
);
}
const dictionarySourceLang = sourceLangCode.split("-")[0].toLowerCase();
const dictionaryTargetLang = targetLangCode.split("-")[0].toLowerCase();
const entriesResult = await deeplClient.getMultilingualGlossaryDictionaryEntries(
glossaryId,
dictionarySourceLang,
dictionaryTargetLang,
);
const results = [
`Language pair: ${dictionarySourceLang} → ${dictionaryTargetLang}`,
"",
"Entries:",
JSON.stringify(entriesResult.entries.entries(), null, 2),
];
return mcpContentifyText(results);
} catch (error) {
throw new Error(`Failed to get glossary dictionary entries: ${error.message}`, {
cause: error,
});
}
}
async function listStyleRules({ page, pageSize, detailed }) {
try {
const styleRules = await deeplClient.getAllStyleRules(page, pageSize, detailed);
if (styleRules.length === 0) {
return mcpContentifyText("No style rules found");
}
return mcpContentifyText(styleRules.map((styleRule) => JSON.stringify(styleRule, null, 2)));
} catch (error) {
throw new Error(`Failed to list style rules: ${error.message}`, { cause: error });
}
}
async function getStyleRule({ styleId }) {
try {
const styleRule = await deeplClient.getStyleRule(styleId);
return mcpContentifyText(JSON.stringify(styleRule, null, 2));
} catch (error) {
throw new Error(`Failed to get style rule: ${error.message}`, { cause: error });
}
}
async function getCustomInstruction({ styleId, instructionId }) {
try {
const instruction = await deeplClient.getStyleRuleCustomInstruction(styleId, instructionId);
return mcpContentifyText(JSON.stringify(instruction, null, 2));
} catch (error) {
throw new Error(`Failed to get custom instruction: ${error.message}`, { cause: error });
}
}
/*--------------------------------------------------------------------
* Helper functions
*-------------------------------------------------------------------*/
/**
* Cast a language code the way the API wants it, like `en-US`. The client library does this itself
* for translations, but not for rephrasing
* @param {string} code
*/
function standardizeLangCase(code) {
const [lang, region] = code.split("-", 2);
return region === undefined
? lang.toLowerCase()
: `${lang.toLowerCase()}-${region.toUpperCase()}`;
}
/**
* Helper function which wraps a string or strings in the object structure MCP expects
* @param {string | string[]} param
*/
function mcpContentifyText(param) {
if (typeof param != "string" && !Array.isArray(param)) {
throw new Error("mcpContentifyText() expects a string or an array of strings");
}
const strings = typeof param === "string" ? [param] : param;
const contentObjects = strings.map(
(str) =>
/** @type {const} */ ({
type: "text",
text: str,
}),
);
return {
content: contentObjects,
};
}
/*--------------------------------------------------------------------
* Main MCP functionality
*-------------------------------------------------------------------*/
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("DeepL MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});