@nado-language/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +225 -0
- package/dist/nado-language-server.mjs +1020 -0
- package/dist/nado-mcp-auth.mjs +495 -0
- package/dist/nado-mcp-cli.mjs +521 -0
- package/dist/probe-nado-mcp.mjs +222 -0
- package/package.json +30 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SUPABASE_URL = 'https://ptbwzhxifxdnfmqsiugi.supabase.co';
|
|
9
|
+
const DEFAULT_SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB0Ynd6aHhpZnhkbmZtcXNpdWdpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU1MTU4MjEsImV4cCI6MjA5MTA5MTgyMX0.c0SU8lvIb8BbwhYyI529dn7tQUfwTl1cGqeahGKaD_g';
|
|
10
|
+
|
|
11
|
+
const SERVER_NAME = 'nado-language';
|
|
12
|
+
const SERVER_VERSION = '0.1.0';
|
|
13
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
14
|
+
const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const REPO_ROOT = path.resolve(SERVER_DIR, '..');
|
|
16
|
+
|
|
17
|
+
let authEnvFilePath = process.env.NADO_MCP_AUTH_ENV_FILE || null;
|
|
18
|
+
|
|
19
|
+
loadLocalEnvFiles();
|
|
20
|
+
|
|
21
|
+
const env = process.env;
|
|
22
|
+
const SUPABASE_URL = normalizeUrl(env.NADO_MCP_SUPABASE_URL || env.EXPO_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL);
|
|
23
|
+
const SUPABASE_ANON_KEY = env.NADO_MCP_SUPABASE_ANON_KEY || env.EXPO_PUBLIC_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
|
|
24
|
+
const FUNCTIONS_URL = normalizeUrl(env.NADO_MCP_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`);
|
|
25
|
+
const DEVICE_ID = env.NADO_MCP_DEVICE_ID || `nado-mcp-${crypto.randomUUID()}`;
|
|
26
|
+
|
|
27
|
+
let cachedPasswordGrant = null;
|
|
28
|
+
let cachedRefreshGrant = null;
|
|
29
|
+
|
|
30
|
+
const FLASHCARD_TYPES = new Set(['word', 'phrase', 'sentence_structure']);
|
|
31
|
+
const PRACTICE_MODES = new Set(['writing', 'vocabulary_quiz', 'conversation', 'sentence_completion', 'translation']);
|
|
32
|
+
|
|
33
|
+
const tools = [
|
|
34
|
+
{
|
|
35
|
+
name: 'nado_whoami',
|
|
36
|
+
description: 'Validate the configured Nado Language user and return profile metadata.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {},
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'nado_save_flashcard',
|
|
45
|
+
description: 'Save a structured flashcard that the chat model already generated. This does not call Nado AI; the user/chat AI is responsible for content quality.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
required: ['original', 'definition'],
|
|
49
|
+
properties: {
|
|
50
|
+
original: { type: 'string', minLength: 1, description: 'English word, phrase, or sentence to save.' },
|
|
51
|
+
type: { type: 'string', enum: ['word', 'phrase', 'sentence_structure'], default: 'phrase' },
|
|
52
|
+
definition: { type: 'string', minLength: 1, description: 'Learner-language definition or translation.' },
|
|
53
|
+
inlineDefinition: { type: 'string', description: 'Short review-side meaning. Defaults to a compact definition.' },
|
|
54
|
+
explanation: { type: 'string', description: 'Optional nuance, usage note, or grammar explanation.' },
|
|
55
|
+
exampleSentences: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
items: { type: 'string' },
|
|
58
|
+
description: 'Optional examples generated by the user chat AI.',
|
|
59
|
+
},
|
|
60
|
+
variants: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: { type: 'string' },
|
|
63
|
+
description: 'Optional related forms, collocations, or variants.',
|
|
64
|
+
},
|
|
65
|
+
contextSentence: { type: 'string', description: 'Original sentence/context from the chat. Defaults to text.' },
|
|
66
|
+
articleTitle: { type: 'string', description: 'Source title shown in analysis. Defaults to MCP Chat.' },
|
|
67
|
+
sourceLang: { type: 'string', default: 'ko', description: 'User language for definitions.' },
|
|
68
|
+
targetLang: { type: 'string', default: 'en', description: 'Language being learned.' },
|
|
69
|
+
userLevel: { type: 'string', default: 'B1', description: 'CEFR level used by Nado analysis.' },
|
|
70
|
+
articleId: { type: 'string', default: 'mcp-chat' },
|
|
71
|
+
sourceHighlightId: { type: 'string', default: 'mcp-chat' },
|
|
72
|
+
},
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'nado_analyze_and_save_flashcard',
|
|
78
|
+
description: 'Pro/Admin only: use Nado AI to produce validated definitions/examples for a word, phrase, or sentence, then save the flashcard.',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
required: ['original'],
|
|
82
|
+
properties: {
|
|
83
|
+
original: { type: 'string', minLength: 1, description: 'English word, phrase, or sentence to analyze and save with Nado AI.' },
|
|
84
|
+
contextSentence: { type: 'string', description: 'Original sentence/context from the chat. Defaults to original.' },
|
|
85
|
+
articleTitle: { type: 'string', description: 'Source title shown in analysis. Defaults to MCP Chat.' },
|
|
86
|
+
sourceLang: { type: 'string', default: 'ko', description: 'User language for definitions.' },
|
|
87
|
+
targetLang: { type: 'string', default: 'en', description: 'Language being learned.' },
|
|
88
|
+
userLevel: { type: 'string', default: 'B1', description: 'CEFR level used by Nado analysis.' },
|
|
89
|
+
articleId: { type: 'string', default: 'mcp-chat' },
|
|
90
|
+
sourceHighlightId: { type: 'string', default: 'mcp-chat' },
|
|
91
|
+
},
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'nado_list_study_items',
|
|
97
|
+
description: 'Load saved Nado Language vocabulary and sentence cards for practice.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
query: { type: 'string', description: 'Optional substring filter over original/context/definition.' },
|
|
102
|
+
limit: { type: 'number', default: 20, minimum: 1, maximum: 100 },
|
|
103
|
+
includeExcluded: { type: 'boolean', default: false },
|
|
104
|
+
},
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'nado_generate_practice',
|
|
110
|
+
description: 'Generate English practice content using only the authenticated user saved Nado Language study cards.',
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
required: ['mode'],
|
|
114
|
+
properties: {
|
|
115
|
+
mode: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
enum: ['writing', 'vocabulary_quiz', 'conversation', 'sentence_completion', 'translation'],
|
|
118
|
+
},
|
|
119
|
+
query: { type: 'string', description: 'Optional substring filter over saved cards before practice generation.' },
|
|
120
|
+
limit: { type: 'number', default: 5, minimum: 1, maximum: 20 },
|
|
121
|
+
},
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function normalizeUrl(value) {
|
|
128
|
+
return String(value || '').replace(/\/+$/, '');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function loadLocalEnvFiles() {
|
|
132
|
+
if (process.env.NADO_MCP_SKIP_DOTENV === '1') return;
|
|
133
|
+
|
|
134
|
+
const candidates = [
|
|
135
|
+
authEnvFilePath,
|
|
136
|
+
path.join(process.cwd(), '.env.mcp.local'),
|
|
137
|
+
path.join(process.cwd(), '.env.local'),
|
|
138
|
+
path.join(REPO_ROOT, '.env.mcp.local'),
|
|
139
|
+
path.join(REPO_ROOT, '.env.local'),
|
|
140
|
+
defaultUserAuthEnvFile(),
|
|
141
|
+
].filter(Boolean);
|
|
142
|
+
|
|
143
|
+
for (const filePath of [...new Set(candidates)]) {
|
|
144
|
+
if (!authEnvFilePath && existsSync(filePath) && (path.basename(filePath) === '.env.mcp.local' || filePath === defaultUserAuthEnvFile())) {
|
|
145
|
+
authEnvFilePath = filePath;
|
|
146
|
+
}
|
|
147
|
+
loadEnvFile(filePath);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function defaultUserAuthEnvFile() {
|
|
152
|
+
if (process.platform === 'win32') {
|
|
153
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Nado', 'MCP', 'auth.env');
|
|
154
|
+
}
|
|
155
|
+
if (process.platform === 'darwin') {
|
|
156
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Nado', 'MCP', 'auth.env');
|
|
157
|
+
}
|
|
158
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'nado', 'mcp', 'auth.env');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function loadEnvFile(filePath) {
|
|
162
|
+
if (!existsSync(filePath)) return;
|
|
163
|
+
|
|
164
|
+
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
const parsed = parseEnvLine(line);
|
|
167
|
+
if (!parsed) continue;
|
|
168
|
+
const { key, value } = parsed;
|
|
169
|
+
if (Object.prototype.hasOwnProperty.call(process.env, key)) continue;
|
|
170
|
+
process.env[key] = value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseEnvLine(line) {
|
|
175
|
+
const trimmed = line.trim();
|
|
176
|
+
if (!trimmed || trimmed.startsWith('#')) return null;
|
|
177
|
+
|
|
178
|
+
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
|
|
179
|
+
const separator = withoutExport.indexOf('=');
|
|
180
|
+
if (separator <= 0) return null;
|
|
181
|
+
|
|
182
|
+
const key = withoutExport.slice(0, separator).trim();
|
|
183
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
|
|
184
|
+
|
|
185
|
+
let value = withoutExport.slice(separator + 1).trim();
|
|
186
|
+
const quote = value[0];
|
|
187
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
188
|
+
value = value.slice(1, -1);
|
|
189
|
+
} else {
|
|
190
|
+
value = value.replace(/\s+#.*$/, '').trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { key, value };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function clampText(value, max) {
|
|
197
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function asStringArray(value, fallback = []) {
|
|
201
|
+
if (!Array.isArray(value)) return fallback;
|
|
202
|
+
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseLimit(value, fallback, min, max) {
|
|
206
|
+
const parsed = Number(value);
|
|
207
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
208
|
+
return Math.min(max, Math.max(min, Math.trunc(parsed)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function tokenFromEnv() {
|
|
212
|
+
const token = clampText(env.NADO_MCP_ACCESS_TOKEN || env.NADO_ACCESS_TOKEN || '', 10000);
|
|
213
|
+
return token || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function refreshTokenFromEnv() {
|
|
217
|
+
const token = clampText(env.NADO_MCP_REFRESH_TOKEN || env.NADO_REFRESH_TOKEN || '', 10000);
|
|
218
|
+
return token || null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getAccessToken() {
|
|
222
|
+
const directToken = tokenFromEnv();
|
|
223
|
+
const refreshToken = refreshTokenFromEnv();
|
|
224
|
+
|
|
225
|
+
if (directToken && !tokenNeedsRefresh(directToken)) return directToken;
|
|
226
|
+
|
|
227
|
+
if (refreshToken) {
|
|
228
|
+
if (
|
|
229
|
+
cachedRefreshGrant
|
|
230
|
+
&& cachedRefreshGrant.sourceRefreshToken === refreshToken
|
|
231
|
+
&& cachedRefreshGrant.expiresAt > Date.now() + 60_000
|
|
232
|
+
) {
|
|
233
|
+
return cachedRefreshGrant.accessToken;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
return await refreshAccessToken(refreshToken);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (directToken && !tokenIsExpired(directToken)) return directToken;
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (directToken) return directToken;
|
|
245
|
+
|
|
246
|
+
const email = clampText(env.NADO_MCP_EMAIL || '', 320);
|
|
247
|
+
const password = env.NADO_MCP_PASSWORD || '';
|
|
248
|
+
if (!email || !password) {
|
|
249
|
+
throw new Error('AUTH_NOT_CONFIGURED: run `nado-mcp login`, or in a repo checkout run `npm run mcp:nado:auth -- --provider google`, before using Nado MCP tools.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (cachedPasswordGrant && cachedPasswordGrant.expiresAt > Date.now() + 60_000) {
|
|
253
|
+
return cachedPasswordGrant.accessToken;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: {
|
|
259
|
+
apikey: SUPABASE_ANON_KEY,
|
|
260
|
+
'Content-Type': 'application/json',
|
|
261
|
+
},
|
|
262
|
+
body: JSON.stringify({ email, password }),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const body = await readJsonResponse(response);
|
|
266
|
+
if (!response.ok || !body.access_token) {
|
|
267
|
+
throw new Error(`AUTH_FAILED: ${response.status} ${body.error_description || body.error || body.msg || 'password grant failed'}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
cachedPasswordGrant = {
|
|
271
|
+
accessToken: body.access_token,
|
|
272
|
+
expiresAt: Date.now() + Math.max(0, Number(body.expires_in || 3600) * 1000),
|
|
273
|
+
};
|
|
274
|
+
return cachedPasswordGrant.accessToken;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function refreshAccessToken(refreshToken) {
|
|
278
|
+
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: {
|
|
281
|
+
apikey: SUPABASE_ANON_KEY,
|
|
282
|
+
'Content-Type': 'application/json',
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const body = await readJsonResponse(response);
|
|
288
|
+
if (!response.ok || !body.access_token) {
|
|
289
|
+
throw new Error(`AUTH_REFRESH_FAILED: ${response.status} ${body.error_description || body.error || body.msg || 'refresh token grant failed'}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const rotatedRefreshToken = body.refresh_token || refreshToken;
|
|
293
|
+
const expiresAt = jwtExpiresAtMs(body.access_token) ?? (Date.now() + Math.max(0, Number(body.expires_in || 3600) * 1000));
|
|
294
|
+
|
|
295
|
+
cachedRefreshGrant = {
|
|
296
|
+
accessToken: body.access_token,
|
|
297
|
+
sourceRefreshToken: rotatedRefreshToken,
|
|
298
|
+
expiresAt,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
persistAuthSession({
|
|
302
|
+
access_token: body.access_token,
|
|
303
|
+
refresh_token: rotatedRefreshToken,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return cachedRefreshGrant.accessToken;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function tokenNeedsRefresh(token) {
|
|
310
|
+
const expiresAt = jwtExpiresAtMs(token);
|
|
311
|
+
return expiresAt !== null && expiresAt <= Date.now() + 60_000;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function tokenIsExpired(token) {
|
|
315
|
+
const expiresAt = jwtExpiresAtMs(token);
|
|
316
|
+
return expiresAt !== null && expiresAt <= Date.now();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function jwtExpiresAtMs(token) {
|
|
320
|
+
const parts = String(token || '').split('.');
|
|
321
|
+
if (parts.length < 2) return null;
|
|
322
|
+
try {
|
|
323
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
324
|
+
const exp = Number(payload.exp);
|
|
325
|
+
return Number.isFinite(exp) ? exp * 1000 : null;
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function persistAuthSession(session) {
|
|
332
|
+
if (!session?.access_token) return;
|
|
333
|
+
|
|
334
|
+
process.env.NADO_MCP_ACCESS_TOKEN = session.access_token;
|
|
335
|
+
if (session.refresh_token) process.env.NADO_MCP_REFRESH_TOKEN = session.refresh_token;
|
|
336
|
+
|
|
337
|
+
if (process.env.NADO_MCP_DISABLE_AUTH_PERSIST === '1') return;
|
|
338
|
+
|
|
339
|
+
const filePath = process.env.NADO_MCP_AUTH_ENV_FILE || authEnvFilePath;
|
|
340
|
+
if (!filePath) return;
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
updateEnvFile(filePath, {
|
|
344
|
+
NADO_MCP_ACCESS_TOKEN: session.access_token,
|
|
345
|
+
NADO_MCP_REFRESH_TOKEN: session.refresh_token,
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error(`Nado MCP auth refresh succeeded but token persistence failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function updateEnvFile(filePath, updates) {
|
|
353
|
+
const nextUpdates = Object.fromEntries(
|
|
354
|
+
Object.entries(updates).filter(([, value]) => typeof value === 'string' && value.length > 0),
|
|
355
|
+
);
|
|
356
|
+
if (Object.keys(nextUpdates).length === 0) return;
|
|
357
|
+
|
|
358
|
+
const seen = new Set();
|
|
359
|
+
const existingLines = existsSync(filePath) ? readFileSync(filePath, 'utf8').split(/\r?\n/) : [];
|
|
360
|
+
const lines = existingLines.map((line) => {
|
|
361
|
+
const parsed = parseEnvLine(line);
|
|
362
|
+
if (!parsed || !(parsed.key in nextUpdates)) return line;
|
|
363
|
+
seen.add(parsed.key);
|
|
364
|
+
return `${parsed.key}=${formatEnvValue(nextUpdates[parsed.key])}`;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
for (const [key, value] of Object.entries(nextUpdates)) {
|
|
368
|
+
if (!seen.has(key)) lines.push(`${key}=${formatEnvValue(value)}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
writeFileSync(filePath, `${lines.filter((line, index) => index < lines.length - 1 || line).join('\n')}\n`, { mode: 0o600 });
|
|
372
|
+
try {
|
|
373
|
+
chmodSync(filePath, 0o600);
|
|
374
|
+
} catch {
|
|
375
|
+
// Best effort. Some filesystems do not support chmod.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function formatEnvValue(value) {
|
|
380
|
+
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
|
|
381
|
+
return JSON.stringify(value);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function readJsonResponse(response) {
|
|
385
|
+
const text = await response.text();
|
|
386
|
+
if (!text) return {};
|
|
387
|
+
try {
|
|
388
|
+
return JSON.parse(text);
|
|
389
|
+
} catch {
|
|
390
|
+
return { raw: text };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function fetchJson(url, options) {
|
|
395
|
+
const response = await fetch(url, options);
|
|
396
|
+
const body = await readJsonResponse(response);
|
|
397
|
+
if (!response.ok) {
|
|
398
|
+
const message = body.message || body.error_description || body.error || body.raw || response.statusText;
|
|
399
|
+
throw new Error(`HTTP_${response.status}: ${message}`);
|
|
400
|
+
}
|
|
401
|
+
return body;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function callAppData(actionBody) {
|
|
405
|
+
const token = await getAccessToken();
|
|
406
|
+
const body = await fetchJson(`${FUNCTIONS_URL}/app-data`, {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: {
|
|
409
|
+
Authorization: `Bearer ${token}`,
|
|
410
|
+
'Content-Type': 'application/json',
|
|
411
|
+
},
|
|
412
|
+
body: JSON.stringify(actionBody),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (body.error) {
|
|
416
|
+
throw new Error(`${body.error}: ${body.message || 'app-data call failed'}`);
|
|
417
|
+
}
|
|
418
|
+
return body.data ?? body;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function analyzeForFlashcard(args) {
|
|
422
|
+
const token = await getAccessToken();
|
|
423
|
+
const text = clampText(args.original || args.text, 3000);
|
|
424
|
+
if (!text) throw new Error('TEXT_REQUIRED');
|
|
425
|
+
|
|
426
|
+
const contextSentence = clampText(args.contextSentence || text, 2200) || text;
|
|
427
|
+
const articleTitle = clampText(args.articleTitle || 'MCP Chat', 180) || 'MCP Chat';
|
|
428
|
+
const sourceLang = clampText(args.sourceLang || 'ko', 8) || 'ko';
|
|
429
|
+
const targetLang = clampText(args.targetLang || 'en', 8) || 'en';
|
|
430
|
+
const userLevel = clampText(args.userLevel || 'B1', 8) || 'B1';
|
|
431
|
+
|
|
432
|
+
const body = await fetchJson(`${FUNCTIONS_URL}/ai-proxy`, {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
headers: {
|
|
435
|
+
Authorization: `Bearer ${token}`,
|
|
436
|
+
'x-device-id': DEVICE_ID,
|
|
437
|
+
'Content-Type': 'application/json',
|
|
438
|
+
},
|
|
439
|
+
body: JSON.stringify({
|
|
440
|
+
action: 'analyze',
|
|
441
|
+
text,
|
|
442
|
+
contextSentence,
|
|
443
|
+
articleTitle,
|
|
444
|
+
sourceLang,
|
|
445
|
+
targetLang,
|
|
446
|
+
userLevel,
|
|
447
|
+
}),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (body.error) throw new Error(`${body.error}: ai-proxy analyze failed`);
|
|
451
|
+
if (!body.data || typeof body.data !== 'object') throw new Error('ANALYZE_EMPTY_RESULT');
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
analysis: normalizeAnalysis(body.data),
|
|
455
|
+
remaining: body.remaining,
|
|
456
|
+
isPro: body.isPro,
|
|
457
|
+
contextSentence,
|
|
458
|
+
articleTitle,
|
|
459
|
+
sourceLang,
|
|
460
|
+
targetLang,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function normalizeAnalysis(data) {
|
|
465
|
+
const type = ['word', 'phrase', 'sentence_structure'].includes(data.type) ? data.type : 'phrase';
|
|
466
|
+
const definition = clampText(data.definition, 4000);
|
|
467
|
+
const inlineDefinition = clampText(data.inlineDefinition || definition.split(',')[0] || '', 1000);
|
|
468
|
+
const explanation = clampText(data.explanation, 4000);
|
|
469
|
+
const exampleSentences = asStringArray(data.exampleSentences).slice(0, 6);
|
|
470
|
+
const variants = asStringArray(data.variants).slice(0, 12);
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
type,
|
|
474
|
+
definition,
|
|
475
|
+
inlineDefinition,
|
|
476
|
+
explanation,
|
|
477
|
+
exampleSentences,
|
|
478
|
+
variants,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function normalizeFlashcardDraft(args) {
|
|
483
|
+
const original = clampText(args.original || args.text, 3000);
|
|
484
|
+
if (!original) throw new Error('ORIGINAL_REQUIRED');
|
|
485
|
+
|
|
486
|
+
const type = FLASHCARD_TYPES.has(args.type) ? args.type : inferFlashcardType(original);
|
|
487
|
+
const definition = clampText(args.definition || '', 4000);
|
|
488
|
+
const explanation = clampText(args.explanation || '', 4000);
|
|
489
|
+
const inlineDefinition = clampText(
|
|
490
|
+
args.inlineDefinition || compactDefinition(definition || explanation),
|
|
491
|
+
1000,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
if (!definition && !inlineDefinition && !explanation) {
|
|
495
|
+
throw new Error('DEFINITION_REQUIRED');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
original,
|
|
500
|
+
type,
|
|
501
|
+
definition: definition || inlineDefinition || explanation,
|
|
502
|
+
inlineDefinition: inlineDefinition || compactDefinition(definition || explanation),
|
|
503
|
+
explanation,
|
|
504
|
+
exampleSentences: asStringArray(args.exampleSentences).slice(0, 6),
|
|
505
|
+
variants: asStringArray(args.variants).slice(0, 12),
|
|
506
|
+
contextSentence: clampText(args.contextSentence || original, 2200) || original,
|
|
507
|
+
articleTitle: clampText(args.articleTitle || 'MCP Chat', 180) || 'MCP Chat',
|
|
508
|
+
sourceLang: clampText(args.sourceLang || 'ko', 8) || 'ko',
|
|
509
|
+
targetLang: clampText(args.targetLang || 'en', 8) || 'en',
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function compactDefinition(value) {
|
|
514
|
+
const text = clampText(value || '', 1000);
|
|
515
|
+
return text.split(/\n|,/)[0]?.trim() || text;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function inferFlashcardType(original) {
|
|
519
|
+
const trimmed = original.trim();
|
|
520
|
+
if (!trimmed) return 'phrase';
|
|
521
|
+
if (/[.!?。!?]\s*$/.test(trimmed) || trimmed.split(/\s+/).length > 6) return 'sentence_structure';
|
|
522
|
+
if (/^[A-Za-z][A-Za-z'-]*$/.test(trimmed)) return 'word';
|
|
523
|
+
return 'phrase';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildFlashcardRow(args, analyzed) {
|
|
527
|
+
const now = new Date().toISOString();
|
|
528
|
+
const original = clampText(args.original || args.text, 3000);
|
|
529
|
+
const sourceHighlightId = clampText(args.sourceHighlightId || 'mcp-chat', 120) || 'mcp-chat';
|
|
530
|
+
const articleId = clampText(args.articleId || 'mcp-chat', 120) || 'mcp-chat';
|
|
531
|
+
const contextSentence = analyzed.contextSentence || original;
|
|
532
|
+
const startIndex = contextSentence.toLocaleLowerCase().indexOf(original.toLocaleLowerCase());
|
|
533
|
+
const endIndex = startIndex >= 0 ? startIndex + original.length : null;
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
id: `mcp-${crypto.randomUUID()}`,
|
|
537
|
+
source_highlight_id: sourceHighlightId,
|
|
538
|
+
article_id: articleId,
|
|
539
|
+
original,
|
|
540
|
+
type: analyzed.analysis.type,
|
|
541
|
+
definition: analyzed.analysis.definition,
|
|
542
|
+
inline_definition: analyzed.analysis.inlineDefinition,
|
|
543
|
+
explanation: analyzed.analysis.explanation,
|
|
544
|
+
example_sentences: analyzed.analysis.exampleSentences,
|
|
545
|
+
variants: analyzed.analysis.variants,
|
|
546
|
+
context_sentence: contextSentence,
|
|
547
|
+
context_start_index: startIndex >= 0 ? startIndex : null,
|
|
548
|
+
context_end_index: endIndex,
|
|
549
|
+
source_lang: analyzed.sourceLang,
|
|
550
|
+
target_lang: analyzed.targetLang,
|
|
551
|
+
excluded: false,
|
|
552
|
+
used_in_writing: false,
|
|
553
|
+
used_in_writing_at: null,
|
|
554
|
+
pending_ai: false,
|
|
555
|
+
stability: 0,
|
|
556
|
+
difficulty: 0,
|
|
557
|
+
elapsed_days: 0,
|
|
558
|
+
scheduled_days: 0,
|
|
559
|
+
reps: 0,
|
|
560
|
+
lapses: 0,
|
|
561
|
+
state: 'new',
|
|
562
|
+
learning_step: 0,
|
|
563
|
+
next_review_at: now,
|
|
564
|
+
last_review_at: null,
|
|
565
|
+
created_at: now,
|
|
566
|
+
updated_at: now,
|
|
567
|
+
deleted: false,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function buildStructuredFlashcardRow(args) {
|
|
572
|
+
const card = normalizeFlashcardDraft(args);
|
|
573
|
+
return buildFlashcardRow(
|
|
574
|
+
{ ...args, original: card.original },
|
|
575
|
+
{
|
|
576
|
+
analysis: {
|
|
577
|
+
type: card.type,
|
|
578
|
+
definition: card.definition,
|
|
579
|
+
inlineDefinition: card.inlineDefinition,
|
|
580
|
+
explanation: card.explanation,
|
|
581
|
+
exampleSentences: card.exampleSentences,
|
|
582
|
+
variants: card.variants,
|
|
583
|
+
},
|
|
584
|
+
contextSentence: card.contextSentence,
|
|
585
|
+
articleTitle: card.articleTitle,
|
|
586
|
+
sourceLang: card.sourceLang,
|
|
587
|
+
targetLang: card.targetLang,
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function rowToFlashcard(row) {
|
|
593
|
+
return {
|
|
594
|
+
id: row.id,
|
|
595
|
+
sourceHighlightId: row.source_highlight_id ?? 'shared',
|
|
596
|
+
articleId: row.article_id ?? 'shared',
|
|
597
|
+
original: row.original,
|
|
598
|
+
type: row.type ?? 'phrase',
|
|
599
|
+
definition: row.definition ?? '',
|
|
600
|
+
inlineDefinition: row.inline_definition ?? undefined,
|
|
601
|
+
explanation: row.explanation ?? '',
|
|
602
|
+
exampleSentences: row.example_sentences ?? [],
|
|
603
|
+
variants: row.variants ?? [],
|
|
604
|
+
contextSentence: row.context_sentence ?? '',
|
|
605
|
+
contextStartIndex: typeof row.context_start_index === 'number' ? row.context_start_index : undefined,
|
|
606
|
+
contextEndIndex: typeof row.context_end_index === 'number' ? row.context_end_index : undefined,
|
|
607
|
+
sourceLang: row.source_lang ?? 'ko',
|
|
608
|
+
targetLang: row.target_lang ?? 'en',
|
|
609
|
+
excluded: row.excluded ?? false,
|
|
610
|
+
usedInWriting: row.used_in_writing ?? false,
|
|
611
|
+
usedInWritingAt: row.used_in_writing_at ?? undefined,
|
|
612
|
+
pendingAI: row.pending_ai ?? false,
|
|
613
|
+
stability: row.stability ?? 0,
|
|
614
|
+
difficulty: row.difficulty ?? 0,
|
|
615
|
+
elapsedDays: row.elapsed_days ?? 0,
|
|
616
|
+
scheduledDays: row.scheduled_days ?? 0,
|
|
617
|
+
reps: row.reps ?? 0,
|
|
618
|
+
lapses: row.lapses ?? 0,
|
|
619
|
+
state: row.state ?? 'new',
|
|
620
|
+
learningStep: row.learning_step ?? 0,
|
|
621
|
+
nextReviewAt: row.next_review_at ?? new Date().toISOString(),
|
|
622
|
+
lastReviewAt: row.last_review_at ?? null,
|
|
623
|
+
createdAt: row.created_at ?? new Date().toISOString(),
|
|
624
|
+
updatedAt: row.updated_at ?? row.created_at,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function loadStudyItems(args = {}) {
|
|
629
|
+
const limit = parseLimit(args.limit, 20, 1, 100);
|
|
630
|
+
const includeExcluded = args.includeExcluded === true;
|
|
631
|
+
const query = clampText(args.query || '', 500).toLocaleLowerCase();
|
|
632
|
+
const rows = await callAppData({
|
|
633
|
+
action: 'selectRowsChangedSince',
|
|
634
|
+
table: 'flashcards',
|
|
635
|
+
updatedAfter: '1970-01-01T00:00:00.000Z',
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const items = (Array.isArray(rows) ? rows : [])
|
|
639
|
+
.filter((row) => row && row.deleted !== true)
|
|
640
|
+
.filter((row) => includeExcluded || row.excluded !== true)
|
|
641
|
+
.map(rowToFlashcard)
|
|
642
|
+
.filter((card) => {
|
|
643
|
+
if (!query) return true;
|
|
644
|
+
const haystack = [
|
|
645
|
+
card.original,
|
|
646
|
+
card.definition,
|
|
647
|
+
card.inlineDefinition,
|
|
648
|
+
card.explanation,
|
|
649
|
+
card.contextSentence,
|
|
650
|
+
...(card.exampleSentences || []),
|
|
651
|
+
...(card.variants || []),
|
|
652
|
+
].join('\n').toLocaleLowerCase();
|
|
653
|
+
return haystack.includes(query);
|
|
654
|
+
})
|
|
655
|
+
.sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)))
|
|
656
|
+
.slice(0, limit);
|
|
657
|
+
|
|
658
|
+
return items;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function handleWhoami() {
|
|
662
|
+
const token = await getAccessToken();
|
|
663
|
+
const user = await fetchJson(`${SUPABASE_URL}/auth/v1/user`, {
|
|
664
|
+
method: 'GET',
|
|
665
|
+
headers: {
|
|
666
|
+
apikey: SUPABASE_ANON_KEY,
|
|
667
|
+
Authorization: `Bearer ${token}`,
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
const profile = await callAppData({ action: 'selectProfile' });
|
|
671
|
+
return {
|
|
672
|
+
user: {
|
|
673
|
+
id: user.id,
|
|
674
|
+
email: user.email,
|
|
675
|
+
aud: user.aud,
|
|
676
|
+
},
|
|
677
|
+
profile,
|
|
678
|
+
supabaseUrl: SUPABASE_URL,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function loadProfile() {
|
|
683
|
+
return await callAppData({ action: 'selectProfile' });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function isActiveProProfile(profile) {
|
|
687
|
+
if (!profile || profile.is_pro !== true) return false;
|
|
688
|
+
if (!profile.pro_expires_at) return true;
|
|
689
|
+
const expiresAt = Date.parse(profile.pro_expires_at);
|
|
690
|
+
return Number.isNaN(expiresAt) || expiresAt > Date.now();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function requireProOrAdmin(feature) {
|
|
694
|
+
const profile = await loadProfile();
|
|
695
|
+
if (profile?.is_admin === true || isActiveProProfile(profile)) return profile;
|
|
696
|
+
throw new Error(`PRO_REQUIRED: ${feature} is available to Pro/Admin users. Use nado_save_flashcard for user-AI generated cards without Nado AI cost.`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function handleSaveFlashcard(args) {
|
|
700
|
+
const row = buildStructuredFlashcardRow(args);
|
|
701
|
+
const saved = await callAppData({
|
|
702
|
+
action: 'upsertRows',
|
|
703
|
+
table: 'flashcards',
|
|
704
|
+
rows: [row],
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
saved: true,
|
|
709
|
+
qualitySource: 'user_ai',
|
|
710
|
+
qualityPolicy: 'Nado validated schema and saved the card without calling Nado AI. Content quality is supplied by the user/chat AI.',
|
|
711
|
+
persisted: saved,
|
|
712
|
+
flashcard: rowToFlashcard(row),
|
|
713
|
+
storage: {
|
|
714
|
+
table: 'flashcards',
|
|
715
|
+
articleId: row.article_id,
|
|
716
|
+
sourceHighlightId: row.source_highlight_id,
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function handleAnalyzeAndSaveFlashcard(args) {
|
|
722
|
+
const profile = await requireProOrAdmin('Nado AI flashcard analysis');
|
|
723
|
+
const analyzed = await analyzeForFlashcard(args);
|
|
724
|
+
const row = buildFlashcardRow({ ...args, original: args.original || args.text }, analyzed);
|
|
725
|
+
const saved = await callAppData({
|
|
726
|
+
action: 'upsertRows',
|
|
727
|
+
table: 'flashcards',
|
|
728
|
+
rows: [row],
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
saved: true,
|
|
733
|
+
qualitySource: 'nado_ai',
|
|
734
|
+
qualityPolicy: 'Nado AI generated the learner definition, short meaning, usage explanation, examples, and variants for Pro/Admin quality.',
|
|
735
|
+
profileTier: profile?.is_admin === true ? 'admin' : 'pro',
|
|
736
|
+
persisted: saved,
|
|
737
|
+
remainingAiCalls: analyzed.remaining,
|
|
738
|
+
isPro: analyzed.isPro,
|
|
739
|
+
analysis: analyzed.analysis,
|
|
740
|
+
flashcard: rowToFlashcard(row),
|
|
741
|
+
storage: {
|
|
742
|
+
table: 'flashcards',
|
|
743
|
+
articleId: row.article_id,
|
|
744
|
+
sourceHighlightId: row.source_highlight_id,
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function handleListStudyItems(args) {
|
|
750
|
+
const items = await loadStudyItems(args);
|
|
751
|
+
return {
|
|
752
|
+
count: items.length,
|
|
753
|
+
items,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function handleGeneratePractice(args) {
|
|
758
|
+
const mode = String(args.mode || '');
|
|
759
|
+
if (!PRACTICE_MODES.has(mode)) throw new Error(`UNSUPPORTED_PRACTICE_MODE: ${mode}`);
|
|
760
|
+
|
|
761
|
+
const limit = parseLimit(args.limit, 5, 1, 20);
|
|
762
|
+
const items = await loadStudyItems({ query: args.query, limit, includeExcluded: false });
|
|
763
|
+
if (items.length === 0) {
|
|
764
|
+
return {
|
|
765
|
+
mode,
|
|
766
|
+
onlySavedMaterials: true,
|
|
767
|
+
materialCount: 0,
|
|
768
|
+
exercises: [],
|
|
769
|
+
message: 'No saved study materials matched the request.',
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
mode,
|
|
775
|
+
onlySavedMaterials: true,
|
|
776
|
+
materialPolicy: 'Exercises are generated from saved Nado flashcards only. Each exercise cites sourceCardIds.',
|
|
777
|
+
materialCount: items.length,
|
|
778
|
+
materials: items.map(compactCard),
|
|
779
|
+
exercises: buildPractice(mode, items),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function compactCard(card) {
|
|
784
|
+
return {
|
|
785
|
+
id: card.id,
|
|
786
|
+
original: card.original,
|
|
787
|
+
type: card.type,
|
|
788
|
+
definition: card.definition,
|
|
789
|
+
inlineDefinition: card.inlineDefinition,
|
|
790
|
+
contextSentence: card.contextSentence,
|
|
791
|
+
exampleSentences: card.exampleSentences,
|
|
792
|
+
variants: card.variants,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function buildPractice(mode, items) {
|
|
797
|
+
switch (mode) {
|
|
798
|
+
case 'writing':
|
|
799
|
+
return items.map((card, index) => ({
|
|
800
|
+
id: `writing-${index + 1}`,
|
|
801
|
+
type: 'writing',
|
|
802
|
+
prompt: `Write two original sentences that correctly use the saved item "${card.original}".`,
|
|
803
|
+
requiredSavedItems: [card.original],
|
|
804
|
+
referenceMeaning: card.inlineDefinition || card.definition,
|
|
805
|
+
sourceCardIds: [card.id],
|
|
806
|
+
}));
|
|
807
|
+
|
|
808
|
+
case 'vocabulary_quiz':
|
|
809
|
+
return items.map((card, index) => {
|
|
810
|
+
const distractors = items
|
|
811
|
+
.filter((candidate) => candidate.id !== card.id)
|
|
812
|
+
.map((candidate) => candidate.inlineDefinition || candidate.definition)
|
|
813
|
+
.filter(Boolean)
|
|
814
|
+
.slice(0, 3);
|
|
815
|
+
return {
|
|
816
|
+
id: `quiz-${index + 1}`,
|
|
817
|
+
type: 'vocabulary_quiz',
|
|
818
|
+
question: `Choose the saved meaning of "${card.original}".`,
|
|
819
|
+
options: shuffleStable([card.inlineDefinition || card.definition, ...distractors].filter(Boolean)),
|
|
820
|
+
answer: card.inlineDefinition || card.definition,
|
|
821
|
+
sourceCardIds: [card.id, ...items.filter((candidate) => candidate.id !== card.id).slice(0, 3).map((candidate) => candidate.id)],
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
case 'conversation':
|
|
826
|
+
return items.map((card, index) => ({
|
|
827
|
+
id: `conversation-${index + 1}`,
|
|
828
|
+
type: 'conversation',
|
|
829
|
+
prompt: `Continue a short conversation using the saved item "${card.original}" naturally.`,
|
|
830
|
+
context: card.contextSentence || card.exampleSentences?.[0] || card.original,
|
|
831
|
+
expectedFocus: card.inlineDefinition || card.definition,
|
|
832
|
+
sourceCardIds: [card.id],
|
|
833
|
+
}));
|
|
834
|
+
|
|
835
|
+
case 'sentence_completion':
|
|
836
|
+
return items.map((card, index) => ({
|
|
837
|
+
id: `completion-${index + 1}`,
|
|
838
|
+
type: 'sentence_completion',
|
|
839
|
+
question: makeBlank(card),
|
|
840
|
+
answer: card.original,
|
|
841
|
+
hint: card.inlineDefinition || card.definition,
|
|
842
|
+
sourceCardIds: [card.id],
|
|
843
|
+
}));
|
|
844
|
+
|
|
845
|
+
case 'translation':
|
|
846
|
+
return items.map((card, index) => ({
|
|
847
|
+
id: `translation-${index + 1}`,
|
|
848
|
+
type: 'translation',
|
|
849
|
+
prompt: `Translate the saved item or sentence: "${card.original}".`,
|
|
850
|
+
answer: card.inlineDefinition || card.definition,
|
|
851
|
+
context: card.contextSentence,
|
|
852
|
+
sourceCardIds: [card.id],
|
|
853
|
+
}));
|
|
854
|
+
|
|
855
|
+
default:
|
|
856
|
+
throw new Error(`UNSUPPORTED_PRACTICE_MODE: ${mode}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function makeBlank(card) {
|
|
861
|
+
const context = card.contextSentence || card.exampleSentences?.[0] || card.original;
|
|
862
|
+
if (!context || context === card.original) return '_____';
|
|
863
|
+
const index = context.toLocaleLowerCase().indexOf(card.original.toLocaleLowerCase());
|
|
864
|
+
if (index < 0) return `${context} [blank: ${card.original.length} chars]`;
|
|
865
|
+
return `${context.slice(0, index)}_____${context.slice(index + card.original.length)}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function shuffleStable(values) {
|
|
869
|
+
return values
|
|
870
|
+
.map((value, index) => ({ value, index, key: stableHash(`${value}:${index}`) }))
|
|
871
|
+
.sort((a, b) => a.key - b.key)
|
|
872
|
+
.map((entry) => entry.value);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function stableHash(value) {
|
|
876
|
+
let hash = 0;
|
|
877
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
878
|
+
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
|
879
|
+
}
|
|
880
|
+
return hash;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function callTool(name, args = {}) {
|
|
884
|
+
if (name === 'nado_whoami') return handleWhoami();
|
|
885
|
+
if (name === 'nado_save_flashcard') return handleSaveFlashcard(args);
|
|
886
|
+
if (name === 'nado_analyze_and_save_flashcard') return handleAnalyzeAndSaveFlashcard(args);
|
|
887
|
+
if (name === 'nado_save_study_item') return handleAnalyzeAndSaveFlashcard({ ...args, original: args.original || args.text });
|
|
888
|
+
if (name === 'nado_list_study_items') return handleListStudyItems(args);
|
|
889
|
+
if (name === 'nado_generate_practice') return handleGeneratePractice(args);
|
|
890
|
+
throw new Error(`UNKNOWN_TOOL: ${name}`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function toolResult(result) {
|
|
894
|
+
return {
|
|
895
|
+
content: [
|
|
896
|
+
{
|
|
897
|
+
type: 'text',
|
|
898
|
+
text: JSON.stringify(result, null, 2),
|
|
899
|
+
},
|
|
900
|
+
],
|
|
901
|
+
structuredContent: result,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function toolError(error) {
|
|
906
|
+
const result = {
|
|
907
|
+
error: error instanceof Error ? error.message : String(error),
|
|
908
|
+
};
|
|
909
|
+
return {
|
|
910
|
+
isError: true,
|
|
911
|
+
content: [
|
|
912
|
+
{
|
|
913
|
+
type: 'text',
|
|
914
|
+
text: JSON.stringify(result, null, 2),
|
|
915
|
+
},
|
|
916
|
+
],
|
|
917
|
+
structuredContent: result,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function handleRequest(request) {
|
|
922
|
+
const { id, method, params = {} } = request;
|
|
923
|
+
if (!method) return null;
|
|
924
|
+
|
|
925
|
+
if (method.startsWith('notifications/')) return null;
|
|
926
|
+
|
|
927
|
+
if (method === 'initialize') {
|
|
928
|
+
const requested = params.protocolVersion;
|
|
929
|
+
const protocolVersion = SUPPORTED_PROTOCOLS.includes(requested) ? requested : SUPPORTED_PROTOCOLS[0];
|
|
930
|
+
return {
|
|
931
|
+
jsonrpc: '2.0',
|
|
932
|
+
id,
|
|
933
|
+
result: {
|
|
934
|
+
protocolVersion,
|
|
935
|
+
capabilities: {
|
|
936
|
+
tools: {},
|
|
937
|
+
},
|
|
938
|
+
serverInfo: {
|
|
939
|
+
name: SERVER_NAME,
|
|
940
|
+
version: SERVER_VERSION,
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (method === 'ping') {
|
|
947
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (method === 'tools/list') {
|
|
951
|
+
return { jsonrpc: '2.0', id, result: { tools } };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (method === 'tools/call') {
|
|
955
|
+
try {
|
|
956
|
+
const result = await callTool(params.name, params.arguments || {});
|
|
957
|
+
return { jsonrpc: '2.0', id, result: toolResult(result) };
|
|
958
|
+
} catch (error) {
|
|
959
|
+
return { jsonrpc: '2.0', id, result: toolError(error) };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
jsonrpc: '2.0',
|
|
965
|
+
id,
|
|
966
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
let inputBuffer = Buffer.alloc(0);
|
|
971
|
+
|
|
972
|
+
process.stdin.on('data', (chunk) => {
|
|
973
|
+
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
|
974
|
+
void drainInput();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
process.stdin.on('end', () => {
|
|
978
|
+
process.exit(0);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
async function drainInput() {
|
|
982
|
+
while (true) {
|
|
983
|
+
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
|
984
|
+
if (headerEnd < 0) return;
|
|
985
|
+
|
|
986
|
+
const header = inputBuffer.slice(0, headerEnd).toString('utf8');
|
|
987
|
+
const match = header.match(/content-length:\s*(\d+)/i);
|
|
988
|
+
if (!match) {
|
|
989
|
+
inputBuffer = inputBuffer.slice(headerEnd + 4);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const contentLength = Number(match[1]);
|
|
994
|
+
const frameEnd = headerEnd + 4 + contentLength;
|
|
995
|
+
if (inputBuffer.length < frameEnd) return;
|
|
996
|
+
|
|
997
|
+
const payload = inputBuffer.slice(headerEnd + 4, frameEnd).toString('utf8');
|
|
998
|
+
inputBuffer = inputBuffer.slice(frameEnd);
|
|
999
|
+
|
|
1000
|
+
try {
|
|
1001
|
+
const request = JSON.parse(payload);
|
|
1002
|
+
const response = await handleRequest(request);
|
|
1003
|
+
if (response) writeMessage(response);
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
writeMessage({
|
|
1006
|
+
jsonrpc: '2.0',
|
|
1007
|
+
id: null,
|
|
1008
|
+
error: {
|
|
1009
|
+
code: -32700,
|
|
1010
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function writeMessage(message) {
|
|
1018
|
+
const payload = JSON.stringify(message);
|
|
1019
|
+
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
|
|
1020
|
+
}
|