#!/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);