303 lines
12 KiB
JavaScript
303 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* MCP-Server: Sprachkurs-Ergänzung (Begriffe, Phrasen, Konversationsübung).
|
||
*
|
||
* Daten: JSON unter LANGUAGE_COURSE_MCP_DATA (Standard: ../data), Datei glossary.json
|
||
* LLM: LANGUAGE_COURSE_LLM_* / OPENAI_API_KEY (siehe README, .env.example)
|
||
*/
|
||
import { config as loadDotenv } from 'dotenv';
|
||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||
import { z } from 'zod';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import crypto from 'crypto';
|
||
import { chatComplete, getLlmEnv, isLlmConfigured } from './llm.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
loadDotenv({ path: path.join(ROOT, '.env') });
|
||
const DATA_DIR = process.env.LANGUAGE_COURSE_MCP_DATA
|
||
? path.resolve(process.env.LANGUAGE_COURSE_MCP_DATA)
|
||
: path.join(ROOT, 'data');
|
||
const GLOSSARY_FILE = path.join(DATA_DIR, 'glossary.json');
|
||
|
||
function ensureDataDir() {
|
||
if (!fs.existsSync(DATA_DIR)) {
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
}
|
||
}
|
||
|
||
function loadGlossary() {
|
||
ensureDataDir();
|
||
if (!fs.existsSync(GLOSSARY_FILE)) {
|
||
const empty = { version: 1, entries: [] };
|
||
fs.writeFileSync(GLOSSARY_FILE, JSON.stringify(empty, null, 2), 'utf8');
|
||
return empty;
|
||
}
|
||
const raw = fs.readFileSync(GLOSSARY_FILE, 'utf8');
|
||
return JSON.parse(raw);
|
||
}
|
||
|
||
function saveGlossary(data) {
|
||
ensureDataDir();
|
||
fs.writeFileSync(GLOSSARY_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||
}
|
||
|
||
const server = new McpServer({
|
||
name: 'language-course',
|
||
version: '1.0.0',
|
||
});
|
||
|
||
server.tool(
|
||
'search_terms',
|
||
'Begriffe und Phrasen im Glossar durchsuchen (Teilstring in term, translation, context, tags).',
|
||
{
|
||
query: z.string().describe('Suchtext (leer = alles)'),
|
||
targetLang: z.string().optional().describe('ISO/Sprachname filter, z. B. de'),
|
||
nativeLang: z.string().optional().describe('Filter Muttersprache, z. B. en'),
|
||
limit: z.number().int().min(1).max(100).optional().default(20),
|
||
},
|
||
async ({ query, targetLang, nativeLang, limit }) => {
|
||
const g = loadGlossary();
|
||
const q = (query || '').trim().toLowerCase();
|
||
let list = g.entries || [];
|
||
if (targetLang) {
|
||
list = list.filter((e) => (e.targetLang || '').toLowerCase() === targetLang.toLowerCase());
|
||
}
|
||
if (nativeLang) {
|
||
list = list.filter((e) => (e.nativeLang || '').toLowerCase() === nativeLang.toLowerCase());
|
||
}
|
||
if (q) {
|
||
list = list.filter((e) => {
|
||
const hay = [
|
||
e.term,
|
||
e.translation,
|
||
e.context,
|
||
...(Array.isArray(e.tags) ? e.tags : []),
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
}
|
||
list = list.slice(0, limit);
|
||
const text = list.length
|
||
? JSON.stringify(list, null, 2)
|
||
: 'Keine Treffer.';
|
||
return { content: [{ type: 'text', text }] };
|
||
}
|
||
);
|
||
|
||
server.tool(
|
||
'add_phrase',
|
||
'Neue Phrase oder Begriff zum Glossar hinzufügen (schreibt glossary.json).',
|
||
{
|
||
targetLang: z.string().describe('Zielsprache, z. B. de'),
|
||
term: z.string().describe('Ausdruck in der Zielsprache'),
|
||
translation: z.string().describe('Übersetzung oder Erklärung'),
|
||
nativeLang: z.string().optional().describe('Muttersprache/Lernkontext, z. B. en'),
|
||
context: z.string().optional().describe('Kontext oder Beispielsatz'),
|
||
tags: z.array(z.string()).optional().describe('Stichwörter, z. B. ["travel","A2"]'),
|
||
},
|
||
async ({ targetLang, term, translation, nativeLang, context, tags }) => {
|
||
const g = loadGlossary();
|
||
const id = crypto.randomUUID();
|
||
g.entries = g.entries || [];
|
||
g.entries.push({
|
||
id,
|
||
targetLang,
|
||
nativeLang: nativeLang || null,
|
||
term: term.trim(),
|
||
translation: translation.trim(),
|
||
context: context || null,
|
||
tags: tags || [],
|
||
});
|
||
saveGlossary(g);
|
||
return {
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `Eintrag gespeichert (id=${id}). Datei: ${GLOSSARY_FILE}`,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
const SCENARIOS = [
|
||
{
|
||
topic: 'Bahnhof / Tickets',
|
||
prompt:
|
||
'Du bist am Schalter. Frage höflich nach einer Fahrkarte nach … (Ziel nennen). Reagiere auf Rückfragen.',
|
||
},
|
||
{
|
||
topic: 'Restaurant',
|
||
prompt:
|
||
'Bestelle ein Gericht, frage nach Allergenen, bitte um die Rechnung. Halte den Dialog kurz und natürlich.',
|
||
},
|
||
{
|
||
topic: 'Arzt – Termin',
|
||
prompt:
|
||
'Rufe an oder sprich am Empfang: Du brauchst einen Termin und beschreibst kurz dein Anliegen.',
|
||
},
|
||
{
|
||
topic: 'Smalltalk Wetter',
|
||
prompt:
|
||
'Führe ein 1-minütiges Gespräch über das Wetter und das Wochenende – keine Fakten nötig, nur flüssig bleiben.',
|
||
},
|
||
];
|
||
|
||
server.tool(
|
||
'conversation_practice',
|
||
'Liefert ein kurzes Rollenspiel-Szenario zum Üben (ohne Bewertung). Optional mit Fokus-Thema.',
|
||
{
|
||
topicHint: z.string().optional().describe('z. B. Restaurant, Arzt, Bahnhof'),
|
||
targetLang: z.string().optional().describe('Nur für die Anzeige, z. B. Deutsch'),
|
||
},
|
||
async ({ topicHint, targetLang }) => {
|
||
let pick = SCENARIOS[Math.floor(Math.random() * SCENARIOS.length)];
|
||
if (topicHint) {
|
||
const h = topicHint.toLowerCase();
|
||
const found = SCENARIOS.find((s) => s.topic.toLowerCase().includes(h) || h.includes(s.topic.split(' ')[0].toLowerCase()));
|
||
if (found) pick = found;
|
||
}
|
||
const lines = [
|
||
`Zielsprache (Anzeige): ${targetLang || 'frei wählbar'}`,
|
||
`Thema: ${pick.topic}`,
|
||
'',
|
||
'Aufgabe:',
|
||
pick.prompt,
|
||
'',
|
||
'Tipp: Formuliere laut oder schriftlich 5–10 Äußerungen; danach kannst du mit search_terms passende Redewendungen nachschlagen.',
|
||
];
|
||
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
||
}
|
||
);
|
||
|
||
server.tool(
|
||
'export_glossary_snippet',
|
||
'Kopier-fertigen JSON-Ausschnitt der letzten n Einträge exportieren (für Import ins YourPart-Skript o. Ä.).',
|
||
{ limit: z.number().int().min(1).max(200).optional().default(30) },
|
||
async ({ limit }) => {
|
||
const g = loadGlossary();
|
||
const entries = (g.entries || []).slice(-limit);
|
||
const text = JSON.stringify({ version: g.version || 1, entries }, null, 2);
|
||
return { content: [{ type: 'text', text }] };
|
||
}
|
||
);
|
||
|
||
server.tool(
|
||
'llm_status',
|
||
'Prüft, ob ein LLM per API-Key und Modell erreichbar konfiguriert ist (kein Netzaufruf).',
|
||
{},
|
||
async () => {
|
||
const { model, baseURL } = getLlmEnv();
|
||
const ok = isLlmConfigured();
|
||
const text = [
|
||
`Konfiguriert: ${ok ? 'ja' : 'nein'}`,
|
||
`Modell (Default): ${model}`,
|
||
baseURL ? `Base URL: ${baseURL}` : 'Base URL: (OpenAI-Standard)',
|
||
'',
|
||
ok
|
||
? 'Hinweis: Echte Erreichbarkeit erst mit llm_chat oder llm_language_tutor testen.'
|
||
: 'Setze LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY (siehe .env.example).',
|
||
].join('\n');
|
||
return { content: [{ type: 'text', text }] };
|
||
}
|
||
);
|
||
|
||
server.tool(
|
||
'llm_chat',
|
||
'Freier Chat mit dem konfigurierten LLM (OpenAI-kompatibel). Für Erklärungen, Übersetzungen, freie Dialoge.',
|
||
{
|
||
userMessage: z.string().describe('Nutzer-/Assistenten-Anfrage'),
|
||
systemPrompt: z
|
||
.string()
|
||
.optional()
|
||
.describe('Systemrolle, z. B. „Du bist ein freundlicher Deutschlehrer …“'),
|
||
temperature: z.number().min(0).max(2).optional().default(0.6),
|
||
},
|
||
async ({ userMessage, systemPrompt, temperature }) => {
|
||
try {
|
||
const messages = [];
|
||
if (systemPrompt && systemPrompt.trim()) {
|
||
messages.push({ role: 'system', content: systemPrompt.trim() });
|
||
}
|
||
messages.push({ role: 'user', content: userMessage });
|
||
const out = await chatComplete(messages, { temperature });
|
||
return { content: [{ type: 'text', text: out }] };
|
||
} catch (e) {
|
||
return {
|
||
content: [{ type: 'text', text: e.message || String(e) }],
|
||
isError: true,
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
server.tool(
|
||
'llm_language_tutor',
|
||
'Rollenspiel oder Feedback: Das Modell antwortet in der Zielsprache (Dialog) oder gibt kurzes Feedback (evaluate).',
|
||
{
|
||
mode: z.enum(['dialogue', 'evaluate']).describe('dialogue = Konversation üben; evaluate = Antwort bewerten'),
|
||
targetLang: z.string().describe('Zielsprache, z. B. Deutsch'),
|
||
nativeLang: z.string().optional().describe('Erklärungen ggf. in dieser Sprache, z. B. Englisch'),
|
||
scenario: z.string().describe('Szenario oder Aufgabe (z. B. Restaurant, vorheriger conversation_practice-Text)'),
|
||
userMessage: z
|
||
.string()
|
||
.describe('Deine Äußerung in der Zielsprache (Dialog) bzw. deine Lösung (evaluate)'),
|
||
conversationContext: z
|
||
.string()
|
||
.optional()
|
||
.describe('Optional: bisheriger Dialog oder Lektionskontext'),
|
||
},
|
||
async ({ mode, targetLang, nativeLang, scenario, userMessage, conversationContext }) => {
|
||
try {
|
||
const explain = nativeLang || 'die Muttersprache des Lernenden';
|
||
let system;
|
||
if (mode === 'dialogue') {
|
||
system = [
|
||
`Du bist eine Gesprächspartnerin/ein Gesprächspartner für ${targetLang}.`,
|
||
`Spiele die andere Rolle im folgenden Szenario realistisch und kurz (1–3 Sätze pro Antwort).`,
|
||
`Antworte durchgehend in ${targetLang}.`,
|
||
`Wenn der Lernende einen Fehler macht, korrigiere ihn nicht ausführlich im Dialog; höchstens eine sanfte, kurze Rückmeldung.`,
|
||
`Erklärungen nur auf ${explain}, und nur wenn der Lernende ausdrücklich nach Hilfe fragt.`,
|
||
'',
|
||
`Szenario: ${scenario}`,
|
||
conversationContext ? `\nBisheriger Kontext:\n${conversationContext}` : '',
|
||
].join('\n');
|
||
} else {
|
||
system = [
|
||
`Du bist eine freundliche Sprachlehrerin für ${targetLang}.`,
|
||
`Der Lernende hat eine Aufgabe zum Szenario bearbeitet. Bewerte kurz (Stärken, 1–2 Verbesserungen).`,
|
||
`Antworte mit kurzem Feedback zuerst in ${explain}; optional ein korrektes Beispiel in ${targetLang}.`,
|
||
`Halte dich unter etwa 120 Wörtern.`,
|
||
'',
|
||
`Szenario/Aufgabe: ${scenario}`,
|
||
conversationContext ? `\nZusatzkontext:\n${conversationContext}` : '',
|
||
].join('\n');
|
||
}
|
||
const user = userMessage.trim();
|
||
const out = await chatComplete(
|
||
[
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user },
|
||
],
|
||
{ temperature: mode === 'dialogue' ? 0.75 : 0.4 }
|
||
);
|
||
return { content: [{ type: 'text', text: out }] };
|
||
} catch (e) {
|
||
return {
|
||
content: [{ type: 'text', text: e.message || String(e) }],
|
||
isError: true,
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|