feat(translate-mcp): scaffold per-tenant translation MCP (CF-3122)

Node.js MCP server exposing translate/search_tm/upsert_glossary/record_correction
over StreamableHTTP on :9222. Routes translation calls to gemini-2.5-flash via
LiteLLM, augmented with per-tenant TM + glossary + tone profile from the
SmartTranslate pgvector DB.

Schema migration in sql/001_schema.sql already applied to smarttranslate DB.
Fleet registration in Infrastructure/litellm/config.yaml.

Refs: CF-3122 CF-3123 CF-3124 CF-3125
This commit is contained in:
Christian Gick
2026-04-14 16:27:44 +03:00
commit 8c9e5ee91d
8 changed files with 694 additions and 0 deletions

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src/ src/
EXPOSE 9222
CMD ["node", "src/http-server.js"]

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# translate-mcp
Per-tenant translation MCP. Routes translations to `gemini-2.5-flash` via LiteLLM,
augmented with per-tenant translation memory, glossary, and tone profile stored in
the shared SmartTranslate Postgres (pgvector).
- Epic: **CF-3122**
- Scaffold: CF-3123 (this repo)
- Schema migration: CF-3124 (see `sql/001_schema.sql`)
- Gateway registration: CF-3125
- Claude routing (CLAUDE.md + PreToolUse hook): CF-3126
- Tenant profile seeding from memory: CF-3127
## Tools
| Tool | Purpose |
|---|---|
| `translate(text, tenant, target_lang, source_lang?)` | Main translation call; uses TM + glossary + tone. |
| `search_tm(query, tenant, target_lang, ...)` | Vector TM lookup. |
| `upsert_glossary(...)` | Manage per-tenant glossary. |
| `record_correction(tm_id, corrected_target, tenant)` | Human-in-the-loop correction. |
| `list_tenants()` / `get_tenant_profile(tenant)` | Inspect profiles. |
## Environment
```
TRANSLATE_DB_DSN=postgresql://translate_mcp_rw:***@postgres.agiliton.internal:5432/smarttranslate
LITELLM_URL=http://llm:4000
LITELLM_API_KEY=sk-litellm-master-key
TRANSLATE_MODEL=gemini-2.5-flash # override for cost tests
TRANSLATE_EMBED_MODEL=mxbai-embed-large
SENTRY_DSN=... # optional
```
## Run locally (dev)
```
uv pip install -e .
export TRANSLATE_DB_DSN=postgresql://...
python -m src.server
```
## Deploy
Via LiteLLM MCP gateway: add block in `Infrastructure/litellm/config.yaml`
(see CF-3125) and `agiliton-deploy litellm`.

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "translate-mcp",
"version": "0.1.0",
"type": "module",
"description": "Per-tenant translation MCP (TM + glossary + tone) routed to gemini-2.5-flash via LiteLLM",
"main": "src/server.js",
"scripts": {
"start": "node src/http-server.js",
"start:http": "node src/http-server.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@sentry/node": "^10.39.0",
"express": "^4.19.2",
"pg": "^8.13.1",
"pgvector": "^0.2.0"
}
}

83
sql/001_schema.sql Normal file
View File

@@ -0,0 +1,83 @@
-- CF-3124: add tenant discriminator + tenant_profile to SmartTranslate DB
-- Safe to run multiple times; migrations wrapped in IF NOT EXISTS.
-- Rollback at bottom of file (commented out).
BEGIN;
CREATE EXTENSION IF NOT EXISTS vector;
-- translation_memory: tenant column
ALTER TABLE translation_memory
ADD COLUMN IF NOT EXISTS tenant VARCHAR(64);
CREATE INDEX IF NOT EXISTS ix_tm_tenant_lang
ON translation_memory (tenant, source_lang, target_lang);
-- glossary_terms: tenant column
ALTER TABLE glossary_terms
ADD COLUMN IF NOT EXISTS tenant VARCHAR(64);
CREATE INDEX IF NOT EXISTS ix_glossary_tenant_src
ON glossary_terms (tenant, LOWER(source_term), target_lang);
-- Per-tenant uniqueness. The existing uq_glossary_term_ci_global (WHERE customer_id
-- IS NULL) would collide across tenants for NULL-customer rows — redefine to exclude
-- tenant'd rows, and add a parallel partial unique index for tenant'd rows.
DROP INDEX IF EXISTS uq_glossary_term_ci_global;
CREATE UNIQUE INDEX IF NOT EXISTS uq_glossary_term_ci_global
ON glossary_terms (LOWER(source_term), target_lang, COALESCE(context_hint, ''))
WHERE customer_id IS NULL AND tenant IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_glossary_term_tenant
ON glossary_terms (tenant, LOWER(source_term), target_lang, COALESCE(context_hint, ''))
WHERE tenant IS NOT NULL;
-- tenant_profile: new table
CREATE TABLE IF NOT EXISTS tenant_profile (
tenant VARCHAR(64) PRIMARY KEY,
tone VARCHAR(32), -- professional / friendly / technical / marketing / editorial
formality VARCHAR(16), -- formal / informal / neutral
industry VARCHAR(64),
base_rules TEXT, -- free-form system prompt fragment
forbidden_terms TEXT[] DEFAULT '{}', -- e.g. {'AdGuardHome','Hetzner'} for vpn-marketing
preferred_author_voice TEXT, -- e.g. "wir/nous (author voice ok, never Sie/du/vous)"
direct_address_allowed BOOLEAN DEFAULT TRUE,
default_source_lang VARCHAR(8),
default_target_langs VARCHAR(8)[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Seed known tenants (idempotent upsert on tenant PK)
INSERT INTO tenant_profile (tenant, tone, formality, industry, base_rules, forbidden_terms, preferred_author_voice, direct_address_allowed, default_source_lang, default_target_langs)
VALUES
('ifk', 'editorial', 'neutral', 'kids-education',
'Neutral, parent-facing tone. Refer to "Eltern", "das Kind", "les parents", "parents" rather than addressing the reader. Author voice "wir" / "nous" is OK. Do not append sales/CTA blocks; inline mentions only.',
'{}'::text[], 'wir/nous (author voice only)', false, 'en', ARRAY['de','fr']),
('vpn-marketing', 'marketing', 'neutral', 'consumer-vpn',
'Consumer privacy/VPN marketing. Describe blocklists qualitatively (curated HaGeZi/OISD/Cloudflare-top-10k allowlist, ~15-30k DE-optimized entries). Use "Cloud-Server in Deutschland" instead of naming the hoster.',
ARRAY['AdGuardHome','Hetzner'], NULL, true, 'en', ARRAY['de','fr']),
('clicksports.de', 'professional', 'formal', 'hosting-msp',
'B2B hosting/MSP tone. Always "Service" never "Support". Update-safe WHMCS context; avoid internal ticket jargon in customer-facing strings.',
ARRAY['Support'], NULL, true, 'de', ARRAY['en']),
('etoro', 'professional', 'neutral', 'retail-trading',
'Retail trading/copy-trading tone. Factual, compliance-aware. No exaggerated performance claims. Use official instrument names.',
'{}'::text[], NULL, true, 'en', ARRAY['de','fr','es','it']),
('matrixhost', 'friendly', 'neutral', 'matrix-hosting',
'Matrix chat hosting SaaS tone. Technical-but-approachable; family-first angle.',
'{}'::text[], NULL, true, 'en', ARRAY['de']),
('agiliton', 'professional', 'formal', 'msp',
'Agiliton corporate voice. Short sentences, German engineering precision.',
'{}'::text[], NULL, true, 'de', ARRAY['en'])
ON CONFLICT (tenant) DO NOTHING;
COMMIT;
-- ROLLBACK (run manually if needed):
-- BEGIN;
-- DROP TABLE IF EXISTS tenant_profile;
-- DROP INDEX IF EXISTS ix_glossary_tenant_src;
-- DROP INDEX IF EXISTS ix_tm_tenant_lang;
-- ALTER TABLE glossary_terms DROP COLUMN IF EXISTS tenant;
-- ALTER TABLE translation_memory DROP COLUMN IF EXISTS tenant;
-- COMMIT;

138
src/db.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* Postgres access for translate-mcp.
* Connects to the SmartTranslate DB where translation_memory, glossary_terms,
* and tenant_profile live (CF-3124).
*/
import pg from 'pg';
import { createHash } from 'crypto';
import pgvector from 'pgvector/pg';
const { Pool } = pg;
let pool = null;
export async function getPool() {
if (pool) return pool;
const dsn = process.env.TRANSLATE_DB_DSN;
if (!dsn) throw new Error('TRANSLATE_DB_DSN not set');
pool = new Pool({ connectionString: dsn, max: 5 });
pool.on('connect', async (client) => {
await pgvector.registerType(client);
});
return pool;
}
export function hashSource(text, sourceLang, targetLang) {
return createHash('sha256')
.update(`${sourceLang}|${targetLang}|${text}`)
.digest('hex');
}
export async function getTenantProfile(tenant) {
const p = await getPool();
const r = await p.query(
`SELECT tenant, tone, formality, industry, base_rules,
forbidden_terms, preferred_author_voice, direct_address_allowed,
default_source_lang, default_target_langs
FROM tenant_profile WHERE tenant = $1`,
[tenant]
);
return r.rows[0] ?? null;
}
export async function listTenants() {
const p = await getPool();
const r = await p.query('SELECT tenant FROM tenant_profile ORDER BY tenant');
return r.rows.map((x) => x.tenant);
}
export async function tmExactHit(text, sourceLang, targetLang, tenant) {
const p = await getPool();
const sha = hashSource(text, sourceLang, targetLang);
const r = await p.query(
`SELECT id, target_text, is_correction, correction_weight, query_count
FROM translation_memory
WHERE source_text_hash = $1 AND tenant = $2
ORDER BY is_correction DESC, correction_weight DESC
LIMIT 1`,
[sha, tenant]
);
if (r.rows[0]) {
await p.query(
'UPDATE translation_memory SET query_count = query_count + 1, last_queried = now() WHERE id = $1',
[r.rows[0].id]
);
}
return r.rows[0] ?? null;
}
export async function tmVectorHits(embedding, tenant, sourceLang, targetLang, topK = 5) {
const p = await getPool();
const vec = pgvector.toSql(embedding);
const r = await p.query(
`SELECT id, source_text, target_text, is_correction, correction_weight,
1 - (embedding <=> $1::vector) AS similarity
FROM translation_memory
WHERE tenant = $2 AND source_lang = $3 AND target_lang = $4
AND embedding IS NOT NULL
ORDER BY is_correction DESC, embedding <=> $1::vector
LIMIT $5`,
[vec, tenant, sourceLang, targetLang, topK]
);
return r.rows;
}
export async function glossaryHits(text, tenant, sourceLang, targetLang) {
const p = await getPool();
const r = await p.query(
`SELECT source_term, target_term, is_dnt, context_hint, notes
FROM glossary_terms
WHERE tenant = $1 AND source_lang = $2 AND target_lang = $3`,
[tenant, sourceLang, targetLang]
);
const lower = text.toLowerCase();
return r.rows.filter((g) => lower.includes(String(g.source_term).toLowerCase()));
}
export async function tmInsert({ text, translated, sourceLang, targetLang, tenant, embedding, modelUsed, tier = 'mcp' }) {
const p = await getPool();
const sha = hashSource(text, sourceLang, targetLang);
const vec = pgvector.toSql(embedding);
const r = await p.query(
`INSERT INTO translation_memory
(id, source_text, target_text, source_lang, target_lang, source_text_hash,
embedding, is_correction, correction_weight, query_count,
tenant, model_used, tier, created_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6::vector, false, 1.0, 1, $7, $8, $9, now())
RETURNING id`,
[text, translated, sourceLang, targetLang, sha, vec, tenant, modelUsed, tier]
);
return r.rows[0].id;
}
export async function upsertGlossary({ tenant, sourceTerm, targetTerm, sourceLang, targetLang, isDnt = false, notes = null }) {
const p = await getPool();
await p.query(
`INSERT INTO glossary_terms
(id, customer_id, tenant, source_term, target_term, source_lang, target_lang, is_dnt, notes, created_at)
VALUES (gen_random_uuid(), NULL, $1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (tenant, LOWER(source_term), target_lang, COALESCE(context_hint, ''))
WHERE tenant IS NOT NULL
DO UPDATE SET target_term = EXCLUDED.target_term, is_dnt = EXCLUDED.is_dnt,
notes = EXCLUDED.notes, updated_at = now()`,
[tenant, sourceTerm, targetTerm, sourceLang, targetLang, isDnt, notes]
);
}
export async function recordCorrection(tmId, correctedTarget, tenant) {
const p = await getPool();
await p.query(
`UPDATE translation_memory
SET target_text = $1,
is_correction = true,
correction_weight = COALESCE(correction_weight, 1.0) + 1.0,
last_queried = now()
WHERE id = $2 AND tenant = $3`,
[correctedTarget, tmId, tenant]
);
}

70
src/http-server.js Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* HTTP wrapper for translate-mcp using StreamableHTTPServerTransport.
* Matches network-mcp / learning-mcp fleet pattern.
*/
import express from 'express';
import { randomUUID } from 'crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createServer } from './server.js';
const PORT = parseInt(process.env.MCP_HTTP_PORT ?? '9222');
const HOST = process.env.MCP_HTTP_HOST ?? '0.0.0.0';
const transports = new Map();
const sessionServers = new Map();
const app = express();
app.use(express.json({ limit: '2mb' }));
app.post('/mcp', async (req, res) => {
try {
const sid0 = req.headers['mcp-session-id'];
if (sid0 && transports.has(sid0)) {
await transports.get(sid0).handleRequest(req, res, req.body);
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => transports.set(sid, transport),
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
transports.delete(sid);
sessionServers.delete(sid);
}
};
const ss = createServer();
await ss.connect(transport);
const sid = transport.sessionId;
if (sid) sessionServers.set(sid, ss);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[translate-mcp]', err);
if (!res.headersSent) res.status(500).json({ error: 'Internal' });
}
});
app.get('/mcp', async (req, res) => {
const sid = req.headers['mcp-session-id'];
if (!sid || !transports.has(sid)) { res.status(400).json({ error: 'bad' }); return; }
await transports.get(sid).handleRequest(req, res);
});
app.delete('/mcp', async (req, res) => {
const sid = req.headers['mcp-session-id'];
if (!sid || !transports.has(sid)) { res.status(400).json({ error: 'bad' }); return; }
await transports.get(sid).handleRequest(req, res);
});
app.get('/health', (_req, res) =>
res.json({ status: 'ok', server: 'translate-mcp', activeSessions: transports.size })
);
app.listen(PORT, HOST, () =>
console.error(`translate-mcp: HTTP on http://${HOST}:${PORT}/mcp`)
);
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));

84
src/llm.js Normal file
View File

@@ -0,0 +1,84 @@
/**
* LiteLLM client: embeddings + gemini-2.5-flash chat completions.
*/
const LITELLM_URL = process.env.LITELLM_URL ?? 'http://llm:4000';
const LITELLM_KEY = process.env.LITELLM_API_KEY ?? '';
const TRANSLATE_MODEL = process.env.TRANSLATE_MODEL ?? 'gemini-2.5-flash';
const EMBED_MODEL = process.env.TRANSLATE_EMBED_MODEL ?? 'mxbai-embed-large';
function headers() {
const h = { 'Content-Type': 'application/json' };
if (LITELLM_KEY) h['x-litellm-api-key'] = LITELLM_KEY;
return h;
}
export async function embed(text) {
const r = await fetch(`${LITELLM_URL}/v1/embeddings`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ model: EMBED_MODEL, input: text }),
});
if (!r.ok) throw new Error(`embed ${r.status}: ${await r.text()}`);
const j = await r.json();
return j.data[0].embedding;
}
export async function translateWithGemini({ systemPrompt, text, sourceLang, targetLang }) {
const userMsg = `Translate the following text from ${sourceLang} to ${targetLang}. Return ONLY the translation, no preamble, no quotes.\n\n${text}`;
const r = await fetch(`${LITELLM_URL}/v1/chat/completions`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({
model: TRANSLATE_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMsg },
],
temperature: 0.2,
}),
});
if (!r.ok) throw new Error(`translate ${r.status}: ${await r.text()}`);
const j = await r.json();
return {
translated: (j.choices[0].message.content ?? '').trim(),
model: j.model ?? TRANSLATE_MODEL,
usage: j.usage ?? {},
};
}
/**
* Assemble tenant-aware system prompt.
*/
export function buildSystemPrompt(profile, glossary, tmExamples) {
const parts = [];
parts.push(
`You are a professional translator for tenant '${profile.tenant}'. ` +
'Preserve meaning, formatting, and inline HTML/Markdown exactly.'
);
if (profile.tone || profile.formality) {
parts.push(`Tone: ${profile.tone ?? 'neutral'}. Formality: ${profile.formality ?? 'neutral'}.`);
}
if (profile.base_rules) parts.push(`Tenant rules: ${profile.base_rules}`);
if (profile.preferred_author_voice) parts.push(`Author voice: ${profile.preferred_author_voice}.`);
if (profile.direct_address_allowed === false) {
parts.push('Do NOT address the reader directly (no Sie/du/vous/you). Rephrase with third-person references.');
}
if (Array.isArray(profile.forbidden_terms) && profile.forbidden_terms.length) {
parts.push(`Forbidden terms (rewrite around them): ${profile.forbidden_terms.join(', ')}.`);
}
if (glossary.length) {
const lines = glossary.map((g) => {
const tag = g.is_dnt ? ' [DO NOT TRANSLATE]' : '';
const hint = g.context_hint ? ` (${g.context_hint})` : '';
return `- ${g.source_term}${g.target_term}${tag}${hint}`;
});
parts.push('Glossary (must use):\n' + lines.join('\n'));
}
const corrections = tmExamples.filter((e) => e.is_correction).slice(0, 3);
if (corrections.length) {
const ex = corrections.map((e) => ` SRC: "${e.source_text}"\n TGT: "${e.target_text}"`);
parts.push('Prior human-corrected examples (match this style):\n' + ex.join('\n'));
}
return parts.join('\n\n');
}

243
src/server.js Normal file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* translate-mcp — per-tenant translation MCP.
*
* Tools:
* translate — main translation call (TM + glossary + tone → gemini-2.5-flash)
* search_tm — vector TM lookup
* upsert_glossary — manage per-tenant glossary
* record_correction — human-in-the-loop correction (boosts correction_weight)
* list_tenants — list configured tenants
* get_tenant_profile — inspect tone/rules for a tenant
*
* Runs in the LiteLLM MCP fleet (CF-3122). Registered in
* Infrastructure/litellm/config.yaml as translate_mcp.
*
* Env:
* TRANSLATE_DB_DSN postgresql://... (SmartTranslate DB with tenant columns)
* LITELLM_URL default http://llm:4000
* LITELLM_API_KEY optional
* TRANSLATE_MODEL default gemini-2.5-flash
* TRANSLATE_EMBED_MODEL default mxbai-embed-large
* SENTRY_DSN optional
*/
import * as Sentry from '@sentry/node';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as db from './db.js';
import * as llm from './llm.js';
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.SENTRY_ENVIRONMENT ?? 'production',
tracesSampleRate: 0.1,
});
}
const TOOLS = [
{
name: 'translate',
description:
"Translate text for a known tenant using per-tenant TM, glossary, and tone rules. " +
"Routes to gemini-2.5-flash via LiteLLM. Prefer this over inline translation for tenant content.",
inputSchema: {
type: 'object',
required: ['text', 'tenant', 'target_lang'],
properties: {
text: { type: 'string' },
tenant: { type: 'string', description: 'e.g. ifk, vpn-marketing, clicksports.de, etoro, matrixhost, agiliton' },
target_lang: { type: 'string', description: 'ISO 639-1 (de, fr, en, ...)' },
source_lang: { type: 'string', default: 'auto' },
},
},
},
{
name: 'search_tm',
description: 'Search the translation memory (vector) for a tenant.',
inputSchema: {
type: 'object',
required: ['query', 'tenant', 'target_lang'],
properties: {
query: { type: 'string' },
tenant: { type: 'string' },
source_lang: { type: 'string', default: 'en' },
target_lang: { type: 'string' },
top_k: { type: 'integer', default: 5 },
},
},
},
{
name: 'upsert_glossary',
description: 'Add or update a glossary term for a tenant.',
inputSchema: {
type: 'object',
required: ['tenant', 'source_term', 'target_term', 'source_lang', 'target_lang'],
properties: {
tenant: { type: 'string' },
source_term: { type: 'string' },
target_term: { type: 'string' },
source_lang: { type: 'string' },
target_lang: { type: 'string' },
is_dnt: { type: 'boolean', default: false },
notes: { type: 'string' },
},
},
},
{
name: 'record_correction',
description: 'Record a human-corrected translation (boosts correction_weight).',
inputSchema: {
type: 'object',
required: ['tm_id', 'corrected_target', 'tenant'],
properties: {
tm_id: { type: 'string', description: 'UUID of translation_memory row' },
corrected_target: { type: 'string' },
tenant: { type: 'string' },
},
},
},
{
name: 'list_tenants',
description: 'List configured tenants.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_tenant_profile',
description: 'Return the tenant profile row (tone, rules, glossary hints).',
inputSchema: {
type: 'object',
required: ['tenant'],
properties: { tenant: { type: 'string' } },
},
},
];
function ok(data) {
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
function err(message, extra = {}) {
return { content: [{ type: 'text', text: JSON.stringify({ error: message, ...extra }) }], isError: true };
}
async function handleTranslate(a) {
const profile = await db.getTenantProfile(a.tenant);
if (!profile) return err(`unknown tenant: ${a.tenant}`, { hint: 'call list_tenants' });
const sourceLang = (a.source_lang && a.source_lang !== 'auto') ? a.source_lang : (profile.default_source_lang ?? 'en');
const targetLang = a.target_lang;
const exact = await db.tmExactHit(a.text, sourceLang, targetLang, a.tenant);
if (exact && !exact.is_correction) {
return ok({
translated: exact.target_text,
cache: 'exact',
tm_id: exact.id,
tenant: a.tenant,
});
}
const embedding = await llm.embed(a.text);
const [tmHits, glossary] = await Promise.all([
db.tmVectorHits(embedding, a.tenant, sourceLang, targetLang, 5),
db.glossaryHits(a.text, a.tenant, sourceLang, targetLang),
]);
const systemPrompt = llm.buildSystemPrompt(profile, glossary, tmHits);
const result = await llm.translateWithGemini({
systemPrompt,
text: a.text,
sourceLang,
targetLang,
});
const newId = await db.tmInsert({
text: a.text,
translated: result.translated,
sourceLang,
targetLang,
tenant: a.tenant,
embedding,
modelUsed: result.model,
});
return ok({
translated: result.translated,
cache: 'miss',
tm_id: newId,
tenant: a.tenant,
model: result.model,
tm_hits_used: tmHits.map((h) => h.id),
glossary_applied: glossary.map((g) => g.source_term),
usage: result.usage,
});
}
async function handleSearchTm(a) {
const embedding = await llm.embed(a.query);
const hits = await db.tmVectorHits(
embedding,
a.tenant,
a.source_lang ?? 'en',
a.target_lang,
a.top_k ?? 5
);
return ok({ hits });
}
async function handleUpsertGlossary(a) {
await db.upsertGlossary({
tenant: a.tenant,
sourceTerm: a.source_term,
targetTerm: a.target_term,
sourceLang: a.source_lang,
targetLang: a.target_lang,
isDnt: a.is_dnt ?? false,
notes: a.notes ?? null,
});
return ok({ ok: true });
}
async function handleRecordCorrection(a) {
await db.recordCorrection(a.tm_id, a.corrected_target, a.tenant);
return ok({ ok: true });
}
async function handleListTenants() {
return ok({ tenants: await db.listTenants() });
}
async function handleGetTenantProfile(a) {
const p = await db.getTenantProfile(a.tenant);
return p ? ok(p) : err('unknown tenant');
}
const HANDLERS = {
translate: handleTranslate,
search_tm: handleSearchTm,
upsert_glossary: handleUpsertGlossary,
record_correction: handleRecordCorrection,
list_tenants: handleListTenants,
get_tenant_profile: handleGetTenantProfile,
};
export function createServer() {
const server = new Server(
{ name: 'translate-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args = {} } = req.params;
const handler = HANDLERS[name];
if (!handler) return err(`unknown tool: ${name}`);
try {
return await handler(args);
} catch (e) {
console.error(`[translate-mcp] ${name} failed:`, e);
Sentry.captureException?.(e);
return err(String(e?.message ?? e), { tool: name });
}
});
return server;
}