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:
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
46
README.md
Normal 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
18
package.json
Normal 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
83
sql/001_schema.sql
Normal 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
138
src/db.js
Normal 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
70
src/http-server.js
Normal 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
84
src/llm.js
Normal 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
243
src/server.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user