Skip to main content
Glama
server.ts14.5 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { request } from "undici"; import { pathToFileURL } from "url"; // ---- Config ---- const API_BASE = process.env.POEDITOR_API_BASE || "https://api.poeditor.com/v2"; const API_TOKEN = process.env.POEDITOR_API_TOKEN; // required const PROJECT_ID = process.env.POEDITOR_PROJECT_ID; // optional default at server-level if (!API_TOKEN) { console.error("POEDITOR_API_TOKEN is required (see .env.example)"); process.exit(1); } // Helpers export async function poeditor(endpoint: string, form: Record<string, string>) { const body = new URLSearchParams({ api_token: API_TOKEN!, ...form }); const { body: resBody } = await request(`${API_BASE}/${endpoint}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() }); const text = await resBody.text(); let json: any; try { json = JSON.parse(text); } catch (e) { throw new Error(`POEditor: invalid JSON response: ${text}`); } const status = json?.response?.status; if (status !== "success") { const code = json?.response?.code; const message = json?.response?.message || "Unknown POEditor error"; throw new Error(`POEditor API error ${code ?? ""}: ${message}`); } return json; } function requireProjectId(argProjectId?: number | null) { const id = argProjectId ?? (PROJECT_ID ? Number(PROJECT_ID) : null); if (!id) throw new Error("project_id is required (either pass it to the tool or set POEDITOR_PROJECT_ID)"); return id; } // ---- Tool Schemas ---- const TranslationsInput = z.object({ project_id: z.number().int().positive().optional(), language: z.string().min(2), items: z.array(z.object({ term: z.string().min(1), context: z.string().optional(), content: z.string().default(""), fuzzy: z.boolean().optional(), plural: z.object({ one: z.string().optional(), few: z.string().optional(), many: z.string().optional(), other: z.string().optional() }).partial().optional() })).min(1) }); const ListTermsInput = z.object({ project_id: z.number().int().positive().optional(), language: z.string().optional(), limit: z.number().int().positive().optional(), search: z.string().optional(), count_only: z.boolean().optional(), fields: z.array(z.enum(["term", "context", "translation"])).optional() }); const ListLanguagesInput = z.object({ project_id: z.number().int().positive().optional() }); const AddLanguageInput = z.object({ project_id: z.number().int().positive().optional(), language: z.string().min(2) }); const AddTermsWithTranslationsInput = z.object({ project_id: z.number().int().positive().optional(), language: z.string().min(2), items: z.array(z.object({ term: z.string().min(1), context: z.string().optional(), reference: z.string().optional(), tags: z.array(z.string()).optional(), translation: z.object({ content: z.string().default(""), fuzzy: z.boolean().optional(), plural: z.object({ one: z.string().optional(), few: z.string().optional(), many: z.string().optional(), other: z.string().optional() }).partial().optional() }) })).min(1) }); const ProjectDetailsInput = z.object({ project_id: z.number().int().positive().optional() }); const DeleteTermsInput = z.object({ project_id: z.number().int().positive().optional(), items: z.array(z.object({ term: z.string().min(1), context: z.string().optional() })).min(1) }); const UpdateTermsInput = z.object({ project_id: z.number().int().positive().optional(), items: z.array(z.object({ term: z.string().min(1), context: z.string().optional(), new_term: z.string().optional(), new_context: z.string().optional(), reference: z.string().optional(), comment: z.string().optional(), tags: z.array(z.string()).optional(), untranslatable: z.boolean().optional() })).min(1) }); const DeleteTranslationsInput = z.object({ project_id: z.number().int().positive().optional(), language: z.string().min(2), items: z.array(z.object({ term: z.string().min(1), context: z.string().optional() })).min(1) }); // ---- Server Setup ---- function registerTools(server: McpServer) { server.tool( "project_details", "Retrieve project metadata such as name, terms count, and last activity. Useful before performing other operations on the project.", ProjectDetailsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const res = await poeditor("projects/view", { id: String(id) }); const project = res.result?.project ?? res.result ?? {}; return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] }; } ); server.tool( "add_translations", "Add translations for EXISTING terms in a language (does not overwrite). Use this only when terms already exist. If you need to create new terms AND add their translations, prefer using add_terms_with_translations instead. Important: if a term was created with a context, you must provide the same context value to match that term.", TranslationsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const payload = args.items.map((i) => ({ term: i.term, context: i.context ?? "", translation: i.plural ? { plural: i.plural } : { content: i.content, fuzzy: i.fuzzy ? 1 : 0 } })); const data = JSON.stringify(payload); const res = await poeditor("translations/add", { id: String(id), language: args.language, data }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "update_translations", "Update/overwrite translations for a language. Important: if a term was created with a context, you must provide the same context value to match that term.", TranslationsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const payload = args.items.map((i) => ({ term: i.term, context: i.context ?? "", translation: i.plural ? { plural: i.plural } : { content: i.content, fuzzy: i.fuzzy ? 1 : 0 } })); const data = JSON.stringify(payload); const res = await poeditor("translations/update", { id: String(id), language: args.language, data }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "list_terms", "List all project terms (optionally include translations for a specific language). Returns only term names, contexts, and translation content to minimize response size. Use limit, search, count_only, and fields parameters to reduce token usage.", ListTermsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const form: Record<string, string> = { id: String(id) }; if (args.language) form.language = args.language; const res = await poeditor("terms/list", form); // Extract term and translation content to reduce response size let terms = res.result?.terms?.map((t: any) => ({ term: t.term, context: t.context || undefined, translation: t.translation?.content || undefined })) ?? []; // Apply search filter (case-insensitive substring match on term, context, translation) if (args.search) { const searchLower = args.search.toLowerCase(); terms = terms.filter((t: any) => t.term?.toLowerCase().includes(searchLower) || t.context?.toLowerCase().includes(searchLower) || t.translation?.toLowerCase().includes(searchLower) ); } // Apply fields selection if (args.fields && args.fields.length > 0) { const fieldSet = new Set(args.fields); terms = terms.map((t: any) => { const filtered: any = {}; if (fieldSet.has("term")) filtered.term = t.term; if (fieldSet.has("context")) filtered.context = t.context; if (fieldSet.has("translation")) filtered.translation = t.translation; return filtered; }); } const total = terms.length; // Return count only if requested if (args.count_only) { return { content: [{ type: "text", text: JSON.stringify({ total }) }] }; } // Apply limit if (args.limit && args.limit < terms.length) { terms = terms.slice(0, args.limit); } const result = { terms, total }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "delete_terms", "Remove one or more terms from the project. Provide the exact term and context combination to delete.", DeleteTermsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const payload = args.items.map((item) => ({ term: item.term, context: item.context ?? "" })); const data = JSON.stringify(payload); const res = await poeditor("terms/delete", { id: String(id), data }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "update_terms", "Update term metadata such as the display text, context, references, or tags. Identify each term by its current term/context values.", UpdateTermsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const payload = args.items.map((item) => { const termData: Record<string, any> = { term: item.term, context: item.context ?? "" }; if (item.new_term) termData.new_term = item.new_term; if (item.new_context) termData.new_context = item.new_context; if (item.reference) termData.reference = item.reference; if (item.comment) termData.comment = item.comment; if (item.tags) termData.tags = item.tags; if (item.untranslatable !== undefined) { termData.untranslatable = item.untranslatable ? 1 : 0; } return termData; }); const data = JSON.stringify(payload); const res = await poeditor("terms/update", { id: String(id), data }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "list_languages", "List languages in the project.", ListLanguagesInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const res = await poeditor("languages/list", { id: String(id) }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "list_available_languages", "List all available languages that POEditor supports (not project-specific, but all possible language codes).", {}, async () => { const res = await poeditor("languages/available", {}); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "add_language", "Add a new language to the project. Provide the language code (e.g., 'en', 'de', 'fr').", AddLanguageInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const res = await poeditor("languages/add", { id: String(id), language: args.language }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); server.tool( "add_terms_with_translations", "PREFERRED METHOD: Create multiple new terms and add their translations in one operation. Use this instead of calling add_terms followed by add_translations separately. This ensures terms and translations are properly linked (especially important when using context).", AddTermsWithTranslationsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); // Step 1: Add all terms const termData = JSON.stringify(args.items.map(item => ({ term: item.term, context: item.context, reference: item.reference, tags: item.tags }))); const termRes = await poeditor("terms/add", { id: String(id), data: termData }); // Step 2: Add all translations const translationPayload = args.items.map(item => ({ term: item.term, context: item.context ?? "", translation: item.translation.plural ? { plural: item.translation.plural } : { content: item.translation.content, fuzzy: item.translation.fuzzy ? 1 : 0 } })); const translationData = JSON.stringify(translationPayload); const translationRes = await poeditor("translations/add", { id: String(id), language: args.language, data: translationData }); // Return combined result const result = { terms_added: termRes.result?.terms ?? termRes.result, translations_added: translationRes.result ?? {} }; return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } ); server.tool( "delete_translations", "Delete translations for specific terms in a language. Only remove translations you are certain are obsolete.", DeleteTranslationsInput.shape, async (args) => { const id = requireProjectId(args.project_id ?? null); const payload = args.items.map((item) => ({ term: item.term, context: item.context ?? "" })); const data = JSON.stringify(payload); const res = await poeditor("translations/delete", { id: String(id), language: args.language, data }); return { content: [{ type: "text", text: JSON.stringify(res.result ?? {}, null, 2) }] }; } ); } export function createServer() { const server = new McpServer( { name: "poeditor-mcp", version: "0.1.0" }, { capabilities: { tools: {} } } ); registerTools(server); return server; } async function main() { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((err) => { console.error(err); process.exit(1); });

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ryan-shaw/poeditor-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server