DEV Community

Cover image for I Built an MCP Server for My Translation Tool - Here's How (and Why)
Łukasz Nawrocki
Łukasz Nawrocki

Posted on

I Built an MCP Server for My Translation Tool - Here's How (and Why)

Outside of my usual 9-5 mobile dev work (but due to issues found at it), I've decided to build verba.dev, a light and fully automated translation management system for small businesses and indie devs. As usual, one idea and project, once started, made my brain generate tons of new ideas and potential projects.

One of them being: what if developers could manage their translations without ever leaving their AI coding assistant?

No dashboard tabs. No copy-pasting keys. Just tell Claude Code or Cursor: "Add a French translation for the checkout button" and it happens.

That's what MCP lets you do. And I'm open-sourcing the server.
Repo: github.com/verbadev/mcp-server

What is MCP, quickly

Model Context Protocol is an open standard (originally from Anthropic, now under the Linux Foundation) that lets AI assistants connect to external tools.

If you've used Claude Desktop, Claude Code, Cursor, or Windsurf, you've probably seen MCP servers in action. The GitHub MCP server lets Claude read your repos. The Slack MCP server lets it search messages. The filesystem MCP server lets it read and write files.

The pattern is simple: you build a server that exposes "tools" (functions the AI can call), and any MCP-compatible client can use them.

Why I built one for a TMS

With most translation tools, the developer workflow looks like this:

  1. Write code
  2. Switch to browser
  3. Open translation dashboard
  4. Find the right project
  5. Add a key
  6. Type the default value
  7. Click "translate"
  8. Switch back to editor
  9. Repeat 47 times

Verba already made this much better. With our SDK, you just write verba.t('checkout.success', 'Payment successful!') in your code and the key gets created automatically with AI translations to all your configured languages. No dashboard, no context switching. You stay in your editor.

But there's still a gap. What about checking which keys are missing translations? Reviewing what's been translated? Adding a new language to the project? Cleaning up unused keys? For those tasks, you still had to open the dashboard.

The MCP server closes that gap entirely. Now the workflow is:

  1. Write code
  2. When you need anything translation-related, just ask your AI assistant

"Show me all untranslated French keys." "Add Japanese to the project." "Delete all keys that start with old." - all from your editor, zero context switching, zero dashboard.

The architecture

The MCP server is intentionally simple - it's a thin TypeScript wrapper around the Verba REST API. No database access, no business logic, no secrets baked in. Just HTTP calls.

Developer <-> AI Assistant (Claude/Cursor) <-> MCP Server <-> Verba API
Enter fullscreen mode Exit fullscreen mode

The server runs locally via stdio transport. Your AI assistant spawns it as a subprocess and communicates over stdin/stdout using JSON-RPC. Your Verba API key stays in your local environment.

Building it

Setup

mkdir verba-mcp-server && cd verba-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D @types/node typescript
Enter fullscreen mode Exit fullscreen mode

The whole server lives in a single file: src/index.ts.

The API helper

Every tool needs to call the Verba API, so I wrote a small wrapper:

const API_KEY = process.env.VERBA_API_KEY;
const API_URL = (process.env.VERBA_API_URL || "https://verba.dev").replace(
  /\/$/,
  ""
);

if (!API_KEY) {
  console.error("VERBA_API_KEY environment variable is required");
  process.exit(1);
}

async function verbaApi(
  method: string,
  path: string,
  body?: unknown
): Promise<{ ok: boolean; status: number; data: unknown }> {
  const url = `${API_URL}${path}`;
  const options: RequestInit = {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
  };
  if (body) {
    options.body = JSON.stringify(body);
  }

  const response = await fetch(url, options);
  const data = await response.json();
  return { ok: response.ok, status: response.status, data };
}
Enter fullscreen mode Exit fullscreen mode

Instead of throwing on errors, the helper always returns { ok, status, data } so each tool can decide how to surface failures to the LLM.

Registering tools

The SDK makes this clean. Each tool gets a name, description (this is what the LLM reads to decide when to use it), a Zod schema for inputs, and a handler:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "verba",
  version: "0.1.0",
});

server.tool(
  "list_keys",
  "List translation keys with optional search, locale filter, and pagination",
  {
    projectId: z.string().describe("The project ID"),
    search: z.string().optional().describe("Search in key names and values"),
    locale: z.string().optional().describe("Filter by locale"),
    untranslated: z
      .boolean()
      .optional()
      .describe("Only show keys with missing translations"),
    page: z.number().optional().describe("Page number (default 1)"),
    pageSize: z
      .number()
      .optional()
      .describe("Results per page (default 50, max 100)"),
  },
  async ({ projectId, search, locale, untranslated, page, pageSize }) => {
    try {
      const params = new URLSearchParams();
      if (search) params.set("search", search);
      if (locale) params.set("locale", locale);
      if (untranslated) params.set("untranslated", "true");
      if (page) params.set("page", String(page));
      if (pageSize) params.set("pageSize", String(pageSize));

      const qs = params.toString();
      const { ok, data } = await verbaApi(
        "GET",
        `/api/v1/projects/${projectId}/keys${qs ? `?${qs}` : ""}`
      );
      if (!ok) {
        return {
          content: [{ type: "text", text: `Error: ${JSON.stringify(data)}` }],
          isError: true,
        };
      }
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    } catch (error) {
      return {
        content: [
          { type: "text", text: `Error: ${(error as Error).message}` },
        ],
        isError: true,
      };
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

The pattern is identical for every tool. Here's the one that triggers AI translation - probably the most powerful one:

server.tool(
  "translate",
  "AI-translate one or more keys to target locales. Max 20 keys per request.",
  {
    projectId: z.string().describe("The project ID"),
    keys: z.array(z.string()).describe("Array of key names to translate"),
    targetLocales: z
      .array(z.string())
      .optional()
      .describe(
        "Specific locales to translate to (defaults to all non-default locales)"
      ),
  },
  async ({ projectId, keys, targetLocales }) => {
    try {
      const { ok, data } = await verbaApi(
        "POST",
        `/api/v1/projects/${projectId}/translate`,
        { keys, targetLocales }
      );
      if (!ok) {
        return {
          content: [{ type: "text", text: `Error: ${JSON.stringify(data)}` }],
          isError: true,
        };
      }
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    } catch (error) {
      return {
        content: [
          { type: "text", text: `Error: ${(error as Error).message}` },
        ],
        isError: true,
      };
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Connect the transport

Last step - wire it up to stdio:

const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

That's the entire server. Build it with tsc and you're done.

The 9 tools

Here's what the server exposes:

Tool What it does
list_projects List all your Verba projects
get_project Get project details (locales, key count)
list_keys List translation keys with search, filter, pagination
add_key Add a new key with auto AI-translation
set_translation Set a translation for a key + locale
translate AI-translate keys to target locales
list_untranslated Find keys missing translations
add_locale Add a new locale to a project
delete_key Delete a translation key

Testing with MCP Inspector

Before connecting to Claude, test your tools in isolation:

VERBA_API_KEY=sk_your-key npx @modelcontextprotocol/inspector build/index.js
Enter fullscreen mode Exit fullscreen mode

This opens a browser UI where you can click any tool, fill in parameters, and see the raw response. Invaluable for debugging.

Using it in Claude Desktop

Add this to your claude_desktop_config.json:

{
  "mcpServers": {
    "verba": {
      "command": "node",
      "args": ["/path/to/mcp-server/build/index.js"],
      "env": {
        "VERBA_API_KEY": "sk_your-secret-key"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart Claude Desktop, and you'll see the hammer icon showing 9 available tools.

Using it in Claude Code

claude mcp add verba -- node /path/to/mcp-server/build/index.js
Enter fullscreen mode Exit fullscreen mode

Then set the environment variable:

export VERBA_API_KEY=sk_your_key_here
Enter fullscreen mode Exit fullscreen mode

Now you can say things like:

  • "Show me all untranslated keys for French in my project"
  • "Add a key 'checkout.success' with value 'Payment successful!' and translate it to all languages"
  • "What languages does my app support? Add Japanese."

The AI figures out which tool to call, extracts the parameters from your natural language, and executes it. You never touch a dashboard.

Three things I learned building this

1. Tool descriptions are prompts. The LLM reads your tool descriptions to decide which tool to use. Write them like you're explaining the tool to a junior developer. Be specific about what each parameter does.

2. Return structured data, not prose. The LLM will format the output for the user. Give it clean JSON or structured text, not sentences. Let the AI do the presentation.

3. Error handling matters more than usual. When a tool fails, the LLM needs to understand why so it can either retry or explain the issue to the user. Return clear error messages, not stack traces.

Why open source it?

The MCP server contains zero business logic. It's just an HTTP client with tool definitions. The value is in the Verba platform - the AI translations, OTA delivery, and dashboard. Open-sourcing the MCP server lowers friction for developers and lets the community contribute tool improvements.

If you're building a developer tool, I'd encourage you to do the same. MCP servers are the new API clients. They're how developers will interact with your product in 2026.

What's next

I'm working on adding the MCP server to the official MCP Registry so it shows up in MCP-compatible clients by default. I'm also exploring adding resources (read-only data the LLM can pull from) and prompts (templates for common translation workflows).

If you're building a SaaS product for developers, consider this: the next generation of developers won't visit your dashboard. They'll talk to their AI assistant, and their AI assistant will talk to your MCP server. Build for that future.


Verba (verba.dev) is a translation management system built for small businesses and indie developers. Three lines of code, AI-powered translations, OTA delivery. $29/month for unlimited everything.

MCP Server: github.com/verbadev/mcp-server (open source, MIT)

If you found this useful, I'm @verbadotdev on X.

Top comments (0)