commit 8c9e5ee91d294a88aeaee450b532733892318d26 Author: Christian Gick Date: Tue Apr 14 16:27:44 2026 +0300 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ac8313 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c44cdc9 --- /dev/null +++ b/README.md @@ -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`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b7af98 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/sql/001_schema.sql b/sql/001_schema.sql new file mode 100644 index 0000000..b52a8f2 --- /dev/null +++ b/sql/001_schema.sql @@ -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; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..c885349 --- /dev/null +++ b/src/db.js @@ -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] + ); +} diff --git a/src/http-server.js b/src/http-server.js new file mode 100644 index 0000000..f171479 --- /dev/null +++ b/src/http-server.js @@ -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)); diff --git a/src/llm.js b/src/llm.js new file mode 100644 index 0000000..0351657 --- /dev/null +++ b/src/llm.js @@ -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'); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..35a3fc3 --- /dev/null +++ b/src/server.js @@ -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; +}