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:
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