@nado-language/mcp 0.1.2 → 0.1.4

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 CHANGED
@@ -6,12 +6,19 @@ This stdio MCP server lets AI chat clients save and practice Nado Language study
6
6
 
7
7
  - `nado_whoami`: validates the configured Nado account.
8
8
  - `nado_save_flashcard`: saves a structured flashcard generated by the user's chat AI. Nado does not call AI for this path.
9
+ - `nado_save_study_item`: alias of `nado_save_flashcard` for natural memorization requests such as "암기할래", "외울래", "암기장에 넣어줘", and "단어장에 추가".
9
10
  - `nado_analyze_and_save_flashcard`: Pro/Admin only. Nado AI generates the learner definition, usage note, examples, and variants, then saves the flashcard.
10
11
  - `nado_list_study_items`: loads saved flashcards for the authenticated user.
11
12
  - `nado_generate_practice`: builds practice exercises from saved flashcards only.
12
13
 
13
14
  The default MCP path is designed to avoid double charging for AI. ChatGPT, Claude, Codex, or another user-paid AI model should fill the structured flashcard fields, and Nado only validates/saves them. Nado AI quality generation is a separate Pro/Admin tool.
14
15
 
16
+ Intent routing:
17
+
18
+ - If the user provides a new English item and says "암기할래", "외울래", "암기장에 추가", "단어장에 넣어줘", "remember this", or "add this to my flashcards", use `nado_save_flashcard` or `nado_save_study_item`.
19
+ - If the user asks to study already saved cards with phrases like "외울래", "암기 연습", "퀴즈 내줘", "쓰기연습", "영작하기", or "복습할래", use `nado_generate_practice`.
20
+ - If the user wants to inspect saved items first, use `nado_list_study_items`.
21
+
15
22
  ## Authentication
16
23
 
17
24
  Install from npm:
@@ -47,6 +54,8 @@ NADO_MCP_REFRESH_TOKEN='supabase-user-refresh-token'
47
54
 
48
55
  By default this uses the existing Azure Static Web Apps production site as a static OAuth relay. It does not require a new Azure Function, App Service, database, or paid runtime. The local helper still receives the final callback on `127.0.0.1`; Azure only serves the static relay page.
49
56
 
57
+ The installed CLI opens the Nado relay page first. The relay stores the local callback in browser session storage, then sends Supabase a fixed redirect URL. This avoids Supabase rejecting a dynamic `redirect_to` URL with `local_callback` query parameters and falling back to the normal Nado web site.
58
+
50
59
  The MCP server refreshes expired access tokens with `NADO_MCP_REFRESH_TOKEN` and updates the auth file when Supabase rotates the refresh token.
51
60
 
52
61
  Supported local browser providers are `google`, `kakao`, and `apple`. Naver login is not available in the local MCP flow yet because the current Naver Edge Function uses fixed web/native redirect URLs.
@@ -57,6 +66,8 @@ Supabase Auth must allow the Azure relay redirect URL:
57
66
  https://language.nado.ai.kr/auth/mcp-callback
58
67
  ```
59
68
 
69
+ Register the exact URL above. Do not include `local_callback`, `provider`, or other query parameters in the Supabase allow list.
70
+
60
71
  For direct local callback mode, run with `--redirect-mode local` and allow:
61
72
 
62
73
  ```text
@@ -139,6 +150,7 @@ For Codex, the command uses the Codex CLI when it is on `PATH`. If the user only
139
150
  - Windows: `%USERPROFILE%\.codex\config.toml`
140
151
 
141
152
  Restart Codex Desktop after login completes.
153
+ If a client was already open, start a new chat/session after restart. Most local MCP clients load tool definitions when the session starts, so a config that was just written may not appear inside an already-running conversation.
142
154
 
143
155
  ChatGPT is different: it uses hosted/remote MCP apps configured from ChatGPT Apps settings, not local stdio config files. The local package can prepare local clients such as Codex, Claude Desktop, OpenCode, and generic JSON-based MCP clients.
144
156
 
@@ -180,6 +192,7 @@ When using the installed package, `nado-mcp login` saves auth to the user's OS c
180
192
  Basic tool discovery:
181
193
 
182
194
  ```bash
195
+ nado-mcp doctor
183
196
  nado-mcp probe list
184
197
  ```
185
198
 
@@ -245,9 +258,13 @@ npm run mcp:nado:probe -- save-nado-ai "serendipity"
245
258
  Install/register/login flow:
246
259
 
247
260
  ```bash
248
- nado-mcp connect codex
249
- nado-mcp connect claude
250
- nado-mcp connect opencode
261
+ nado-mcp connect
262
+ ```
263
+
264
+ Force all supported local config writers:
265
+
266
+ ```bash
267
+ nado-mcp connect all
251
268
  ```
252
269
 
253
270
  Generic MCP fallback:
@@ -256,3 +273,10 @@ Generic MCP fallback:
256
273
  nado-mcp config
257
274
  nado-mcp login
258
275
  ```
276
+
277
+ Diagnostics:
278
+
279
+ ```bash
280
+ nado-mcp doctor
281
+ nado-mcp probe list
282
+ ```
@@ -30,6 +30,40 @@ let cachedRefreshGrant = null;
30
30
  const FLASHCARD_TYPES = new Set(['word', 'phrase', 'sentence_structure']);
31
31
  const PRACTICE_MODES = new Set(['writing', 'vocabulary_quiz', 'conversation', 'sentence_completion', 'translation']);
32
32
 
33
+ const saveFlashcardInputSchema = {
34
+ type: 'object',
35
+ required: ['original', 'definition'],
36
+ properties: {
37
+ original: {
38
+ type: 'string',
39
+ minLength: 1,
40
+ description: 'English word, phrase, or sentence to save. Example: for "hello 암기할래", use "hello".',
41
+ },
42
+ type: { type: 'string', enum: ['word', 'phrase', 'sentence_structure'], default: 'phrase' },
43
+ definition: { type: 'string', minLength: 1, description: 'Learner-language definition or translation generated by the chat AI.' },
44
+ inlineDefinition: { type: 'string', description: 'Short review-side meaning. Defaults to a compact definition.' },
45
+ explanation: { type: 'string', description: 'Optional nuance, usage note, or grammar explanation.' },
46
+ exampleSentences: {
47
+ type: 'array',
48
+ items: { type: 'string' },
49
+ description: 'Optional examples generated by the user chat AI.',
50
+ },
51
+ variants: {
52
+ type: 'array',
53
+ items: { type: 'string' },
54
+ description: 'Optional related forms, collocations, or variants.',
55
+ },
56
+ contextSentence: { type: 'string', description: 'Original sentence/context from the chat. Defaults to text.' },
57
+ articleTitle: { type: 'string', description: 'Source title shown in analysis. Defaults to MCP Chat.' },
58
+ sourceLang: { type: 'string', default: 'ko', description: 'User language for definitions.' },
59
+ targetLang: { type: 'string', default: 'en', description: 'Language being learned.' },
60
+ userLevel: { type: 'string', default: 'B1', description: 'CEFR level used by Nado analysis.' },
61
+ articleId: { type: 'string', default: 'mcp-chat' },
62
+ sourceHighlightId: { type: 'string', default: 'mcp-chat' },
63
+ },
64
+ additionalProperties: false,
65
+ };
66
+
33
67
  const tools = [
34
68
  {
35
69
  name: 'nado_whoami',
@@ -42,40 +76,17 @@ const tools = [
42
76
  },
43
77
  {
44
78
  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
- },
79
+ description: 'Save a structured Nado Language flashcard that the chat model already generated. Use this when the user gives an English item and asks to memorize/save it, for example "암기장에 추가해줘", "암기할래", "외울래", "단어장에 넣어줘", "remember this", or "add this to my flashcards". This does not call Nado AI; the user/chat AI supplies the content quality.',
80
+ inputSchema: saveFlashcardInputSchema,
81
+ },
82
+ {
83
+ name: 'nado_save_study_item',
84
+ description: 'Alias of nado_save_flashcard for study-list and memorization intents. Prefer this or nado_save_flashcard when the user asks to save a specific word/phrase/sentence to Nado for later memorization, including Korean requests like "암기할래", "외울래", "암기장에 넣어줘", or "단어장에 추가". This free path does not call Nado AI.',
85
+ inputSchema: saveFlashcardInputSchema,
75
86
  },
76
87
  {
77
88
  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.',
89
+ description: 'Pro/Admin only: use Nado AI to produce validated definitions/examples for a word, phrase, or sentence, then save the flashcard. Use only when the user explicitly wants Nado-verified/pro quality analysis or examples; normal memorize/save requests should use nado_save_flashcard so the user AI supplies the content.',
79
90
  inputSchema: {
80
91
  type: 'object',
81
92
  required: ['original'],
@@ -94,7 +105,7 @@ const tools = [
94
105
  },
95
106
  {
96
107
  name: 'nado_list_study_items',
97
- description: 'Load saved Nado Language vocabulary and sentence cards for practice.',
108
+ description: 'Load saved Nado Language vocabulary and sentence cards. Use when the user asks what is saved, wants to inspect the memorization list, or asks to review existing Nado study items before practice.',
98
109
  inputSchema: {
99
110
  type: 'object',
100
111
  properties: {
@@ -107,7 +118,7 @@ const tools = [
107
118
  },
108
119
  {
109
120
  name: 'nado_generate_practice',
110
- description: 'Generate English practice content using only the authenticated user saved Nado Language study cards.',
121
+ description: 'Generate English practice content using only the authenticated user saved Nado Language study cards. Use when the user wants to study/review already saved items: "외울래", "암기 연습", "퀴즈 내줘", "쓰기연습", "영작하기", "복습할래", "practice my saved words". If the user supplies a new English item to save, use nado_save_flashcard instead.',
111
122
  inputSchema: {
112
123
  type: 'object',
113
124
  required: ['mode'],
@@ -883,8 +894,8 @@ function stableHash(value) {
883
894
  async function callTool(name, args = {}) {
884
895
  if (name === 'nado_whoami') return handleWhoami();
885
896
  if (name === 'nado_save_flashcard') return handleSaveFlashcard(args);
897
+ if (name === 'nado_save_study_item') return handleSaveFlashcard(args);
886
898
  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
899
  if (name === 'nado_list_study_items') return handleListStudyItems(args);
889
900
  if (name === 'nado_generate_practice') return handleGeneratePractice(args);
890
901
  throw new Error(`UNKNOWN_TOOL: ${name}`);
@@ -183,23 +183,22 @@ async function login(options) {
183
183
 
184
184
  const localCallbackUrl = new URL(`http://127.0.0.1:${address.port}/callback`);
185
185
  localCallbackUrl.searchParams.set('state', state);
186
- const redirectTo = buildRedirectTo(options, localCallbackUrl.toString());
187
- const authUrl = buildAuthorizeUrl({
188
- supabaseUrl: options.supabaseUrl,
186
+ const browserUrl = buildBrowserLoginUrl({
187
+ options,
189
188
  provider,
190
- redirectTo,
189
+ localCallbackUrl: localCallbackUrl.toString(),
191
190
  codeChallenge,
192
191
  });
193
192
 
194
193
  console.log(`Opening browser for Nado MCP login (${provider}).`);
195
194
  console.log(`Local callback: ${localCallbackUrl.toString()}`);
196
195
  if (options.redirectMode === 'azure') console.log(`Azure relay: ${options.relayUrl}`);
197
- if (!options.noOpen) openBrowser(authUrl);
198
- console.log(`If the browser did not open, visit:\n${authUrl}`);
196
+ if (!options.noOpen) openBrowser(browserUrl);
197
+ console.log(`If the browser did not open, visit:\n${browserUrl}`);
199
198
 
200
199
  let timeoutId;
201
200
  const timeout = new Promise((_, reject) => {
202
- timeoutId = setTimeout(() => reject(new Error('Timed out waiting for browser login.')), options.timeoutMs);
201
+ timeoutId = setTimeout(() => reject(loginTimeoutError(options)), options.timeoutMs);
203
202
  });
204
203
 
205
204
  try {
@@ -212,14 +211,44 @@ async function login(options) {
212
211
  }
213
212
  }
214
213
 
215
- function buildRedirectTo(options, localCallbackUrl) {
216
- if (options.redirectMode === 'local') return localCallbackUrl;
214
+ function loginTimeoutError(options) {
215
+ return new Error([
216
+ 'Timed out waiting for browser login.',
217
+ `Rerun \`nado-mcp login --provider ${options.provider} --timeout-ms 900000\` and keep the terminal open until the browser says login completed.`,
218
+ 'If the browser did not open, copy the printed URL into the same desktop browser where you can sign in.',
219
+ 'If Google login succeeds but the browser lands on the normal Nado site, upgrade @nado-language/mcp and confirm Supabase Auth allows the exact relay URL without query parameters.',
220
+ 'If it still times out after the relay page says it is returning to the local helper, check that the browser can reach the printed 127.0.0.1 local callback URL.',
221
+ ].join(' '));
222
+ }
223
+
224
+ function buildBrowserLoginUrl({ options, provider, localCallbackUrl, codeChallenge }) {
225
+ if (options.redirectMode === 'local') {
226
+ return buildAuthorizeUrl({
227
+ supabaseUrl: options.supabaseUrl,
228
+ provider,
229
+ redirectTo: localCallbackUrl,
230
+ codeChallenge,
231
+ });
232
+ }
233
+
234
+ return buildRelayStartUrl({
235
+ relayUrl: options.relayUrl,
236
+ localCallbackUrl,
237
+ provider,
238
+ supabaseUrl: options.supabaseUrl,
239
+ codeChallenge,
240
+ });
241
+ }
217
242
 
218
- const relayUrl = new URL(options.relayUrl);
243
+ function buildRelayStartUrl({ relayUrl: value, localCallbackUrl, provider, supabaseUrl, codeChallenge }) {
244
+ const relayUrl = new URL(value);
219
245
  if (relayUrl.protocol !== 'https:') {
220
246
  throw new Error('--relay-url must be an HTTPS URL when --redirect-mode azure is used.');
221
247
  }
222
248
  relayUrl.searchParams.set('local_callback', localCallbackUrl);
249
+ relayUrl.searchParams.set('provider', provider);
250
+ relayUrl.searchParams.set('supabase_url', supabaseUrl);
251
+ relayUrl.searchParams.set('code_challenge', codeChallenge);
223
252
  return relayUrl.toString();
224
253
  }
225
254
 
@@ -489,6 +518,10 @@ Default mode uses the existing Azure Static Web Apps site as a zero-new-resource
489
518
  OAuth relay. Supabase Auth must allow this redirect URL:
490
519
  ${DEFAULT_RELAY_URL}
491
520
 
521
+ The relay starts login with local state in browser sessionStorage, then sends
522
+ Supabase the fixed redirect URL above. Do not add local_callback query strings
523
+ to the Supabase allow list.
524
+
492
525
  The optional local mode requires Supabase Auth to allow:
493
526
  http://127.0.0.1:*/callback
494
527
  `);
@@ -45,15 +45,19 @@ try {
45
45
  await runNode(probePath, args, { stdio: 'inherit' });
46
46
  } else if (command === 'setup') {
47
47
  const parsed = splitClientAndSetupOptions(args);
48
- await setup(parsed.client, parsed.setupOptions);
48
+ const didRegister = await setup(parsed.client, parsed.setupOptions);
49
+ if (didRegister) printToolProbeSummary();
49
50
  } else if (command === 'connect' || command === 'install') {
50
51
  const parsed = splitClientAndSetupOptions(args);
51
52
  const didRegister = await setup(parsed.client, parsed.setupOptions, { loginAfter: true });
52
- if (didRegister) await runAuth(['login', ...parsed.rest]);
53
+ if (didRegister) {
54
+ printToolProbeSummary();
55
+ await runAuth(['login', ...parsed.rest]);
56
+ }
53
57
  } else if (command === 'config') {
54
58
  printConfig(args[0] || 'all');
55
59
  } else if (command === 'doctor') {
56
- doctor();
60
+ await doctor();
57
61
  } else {
58
62
  throw new Error(`Unknown command: ${command}`);
59
63
  }
@@ -228,15 +232,182 @@ function setupMcpServersJson(configPath, flow = {}) {
228
232
  printLoginNext(flow);
229
233
  }
230
234
 
231
- function doctor() {
235
+ async function doctor() {
236
+ const auth = authStatus(configuredAuthEnvFile());
237
+ const codex = codexRegistrationStatus();
238
+ const claude = jsonRegistrationStatus(claudeDesktopConfigPath(), ['mcpServers', serverName]);
239
+ const opencode = jsonRegistrationStatus(opencodeConfigPath(), ['mcp', serverName]);
240
+ const probe = probeTools();
241
+
232
242
  console.log('Nado MCP doctor');
233
243
  console.log(`Node: ${process.version}`);
234
244
  console.log(`Server: ${serverPath}${existsSync(serverPath) ? '' : ' (missing)'}`);
235
245
  console.log(`Auth CLI: ${authPath}${existsSync(authPath) ? '' : ' (missing)'}`);
236
- console.log(`Auth file: ${defaultUserAuthEnvFile()}${existsSync(defaultUserAuthEnvFile()) ? ' (present)' : ' (missing)'}`);
237
- console.log(`Codex Desktop config: ${codexDesktopConfigPath()}`);
238
- console.log(`Claude Desktop config: ${claudeDesktopConfigPath()}`);
239
- console.log(`OpenCode config: ${opencodeConfigPath()}`);
246
+ console.log(`Local MCP server check: ${probe.ok ? `ok (${probe.tools.join(', ')})` : `failed (${probe.error})`}`);
247
+ console.log(`Auth file: ${auth.filePath}${auth.fileExists ? ' (present)' : ' (missing)'}`);
248
+ console.log(`Auth access token: ${auth.accessToken ? `present${tokenExpiryText(auth.accessToken)}` : 'missing'}`);
249
+ console.log(`Auth refresh token: ${auth.refreshToken ? 'present' : 'missing'}`);
250
+ console.log(`Auth email/password fallback: ${auth.email ? `configured for ${auth.email}` : 'missing'}`);
251
+ console.log(`Codex Desktop config: ${registrationText(codex)}`);
252
+ console.log(`Claude Desktop config: ${registrationText(claude)}`);
253
+ console.log(`OpenCode config: ${registrationText(opencode)}`);
254
+ console.log('');
255
+ console.log('If the server check is ok but Nado tools are not visible in the AI app:');
256
+ console.log(' 1. Fully quit and restart the desktop app after `nado-mcp connect`.');
257
+ console.log(' 2. Start a new chat/session; existing sessions may not reload newly added MCP tools.');
258
+ console.log(' 3. Run `nado-mcp status` for auth and `nado-mcp probe list` for local server tools.');
259
+ }
260
+
261
+ function printToolProbeSummary() {
262
+ const probe = probeTools();
263
+ if (probe.ok) {
264
+ console.log(`Local MCP server check: ok (${probe.tools.join(', ')})`);
265
+ } else {
266
+ console.log(`Local MCP server check failed: ${probe.error}`);
267
+ }
268
+ }
269
+
270
+ function probeTools() {
271
+ if (!existsSync(probePath)) return { ok: false, error: `probe script missing at ${probePath}` };
272
+
273
+ const result = spawnSync(process.execPath, [probePath, 'list'], {
274
+ encoding: 'utf8',
275
+ env: {
276
+ ...process.env,
277
+ NADO_MCP_SKIP_DOTENV: '1',
278
+ },
279
+ });
280
+
281
+ if (result.error) return { ok: false, error: result.error.message };
282
+ if (result.status !== 0) {
283
+ const message = (result.stderr || result.stdout || `probe exited with code ${result.status}`).trim();
284
+ return { ok: false, error: message };
285
+ }
286
+
287
+ try {
288
+ const parsed = JSON.parse(result.stdout);
289
+ const tools = (parsed.tools || []).map((tool) => tool.name).filter(Boolean);
290
+ if (tools.length === 0) return { ok: false, error: 'no tools returned by server' };
291
+ return { ok: true, tools };
292
+ } catch (error) {
293
+ return { ok: false, error: `could not parse probe output: ${error instanceof Error ? error.message : String(error)}` };
294
+ }
295
+ }
296
+
297
+ function configuredAuthEnvFile() {
298
+ return process.env.NADO_MCP_AUTH_ENV_FILE ? expandHome(process.env.NADO_MCP_AUTH_ENV_FILE) : defaultUserAuthEnvFile();
299
+ }
300
+
301
+ function authStatus(filePath) {
302
+ const values = readEnvFile(filePath);
303
+ const accessToken = process.env.NADO_MCP_ACCESS_TOKEN || values.NADO_MCP_ACCESS_TOKEN || process.env.NADO_ACCESS_TOKEN || '';
304
+ const refreshToken = process.env.NADO_MCP_REFRESH_TOKEN || values.NADO_MCP_REFRESH_TOKEN || process.env.NADO_REFRESH_TOKEN || '';
305
+ const email = process.env.NADO_MCP_EMAIL || values.NADO_MCP_EMAIL || '';
306
+
307
+ return {
308
+ filePath,
309
+ fileExists: existsSync(filePath),
310
+ accessToken,
311
+ refreshToken,
312
+ email,
313
+ };
314
+ }
315
+
316
+ function codexRegistrationStatus() {
317
+ const configPath = codexDesktopConfigPath();
318
+ if (!existsSync(configPath)) return { path: configPath, fileExists: false, registered: false };
319
+
320
+ try {
321
+ const text = readFileSync(configPath, 'utf8');
322
+ const sectionPattern = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(serverName)}]\\s*$`, 'm');
323
+ return { path: configPath, fileExists: true, registered: sectionPattern.test(text) };
324
+ } catch (error) {
325
+ return { path: configPath, fileExists: true, registered: false, error: error instanceof Error ? error.message : String(error) };
326
+ }
327
+ }
328
+
329
+ function jsonRegistrationStatus(configPath, keyPath) {
330
+ if (!existsSync(configPath)) return { path: configPath, fileExists: false, registered: false };
331
+
332
+ try {
333
+ const config = readJsonConfig(configPath);
334
+ return {
335
+ path: configPath,
336
+ fileExists: true,
337
+ registered: getPathValue(config, keyPath) !== undefined,
338
+ };
339
+ } catch (error) {
340
+ return { path: configPath, fileExists: true, registered: false, error: error instanceof Error ? error.message : String(error) };
341
+ }
342
+ }
343
+
344
+ function registrationText(status) {
345
+ if (status.error) return `${status.path} (error: ${status.error})`;
346
+ if (!status.fileExists) return `${status.path} (missing)`;
347
+ return `${status.path} (${status.registered ? 'registered' : 'not registered'})`;
348
+ }
349
+
350
+ function getPathValue(value, keys) {
351
+ let current = value;
352
+ for (const key of keys) {
353
+ if (!current || typeof current !== 'object' || !(key in current)) return undefined;
354
+ current = current[key];
355
+ }
356
+ return current;
357
+ }
358
+
359
+ function readEnvFile(filePath) {
360
+ if (!existsSync(filePath)) return {};
361
+ const values = {};
362
+ for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) {
363
+ const parsed = parseEnvLine(line);
364
+ if (parsed) values[parsed.key] = parsed.value;
365
+ }
366
+ return values;
367
+ }
368
+
369
+ function parseEnvLine(line) {
370
+ const trimmed = line.trim();
371
+ if (!trimmed || trimmed.startsWith('#')) return null;
372
+
373
+ const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
374
+ const separator = withoutExport.indexOf('=');
375
+ if (separator <= 0) return null;
376
+
377
+ const key = withoutExport.slice(0, separator).trim();
378
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
379
+
380
+ let value = withoutExport.slice(separator + 1).trim();
381
+ const quote = value[0];
382
+ if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
383
+ value = value.slice(1, -1);
384
+ } else {
385
+ value = value.replace(/\s+#.*$/, '').trim();
386
+ }
387
+
388
+ return { key, value };
389
+ }
390
+
391
+ function tokenExpiryText(token) {
392
+ const expiresAt = jwtExpiresAtMs(token);
393
+ if (!expiresAt) return '';
394
+ return `, expires ${new Date(expiresAt).toISOString()}`;
395
+ }
396
+
397
+ function jwtExpiresAtMs(token) {
398
+ const parts = String(token || '').split('.');
399
+ if (parts.length < 2) return null;
400
+ try {
401
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
402
+ const exp = Number(payload.exp);
403
+ return Number.isFinite(exp) ? exp * 1000 : null;
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ function escapeRegExp(value) {
410
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
240
411
  }
241
412
 
242
413
  function firstExisting(candidates) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nado-language/mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Nado Language MCP server for saving AI-generated English flashcards and practicing saved materials.",
5
5
  "type": "module",
6
6
  "private": false,