@nado-language/mcp 0.1.5 → 0.1.7
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 +11 -8
- package/dist/nado-language-server.mjs +169 -4
- package/dist/nado-mcp-auth.mjs +108 -16
- package/dist/nado-mcp-cli.mjs +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ This stdio MCP server lets AI chat clients save and practice Nado Language study
|
|
|
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
9
|
- `nado_save_study_item`: alias of `nado_save_flashcard` for natural memorization requests such as "암기할래", "외울래", "암기장에 넣어줘", and "단어장에 추가".
|
|
10
|
+
- `nado_update_study_item`: updates an existing saved Nado card with a revised meaning, description/explanation, examples, variants, or context. Use it for requests like "hello 설명 추가", "description 추가", "뜻 수정", and "예문 추가".
|
|
10
11
|
- `nado_analyze_and_save_flashcard`: Pro/Admin only. Nado AI generates the learner definition, usage note, examples, and variants, then saves the flashcard.
|
|
11
12
|
- `nado_list_study_items`: loads saved flashcards for the authenticated user.
|
|
12
13
|
- `nado_generate_practice`: builds practice exercises from saved flashcards only.
|
|
@@ -15,7 +16,8 @@ The default MCP path is designed to avoid double charging for AI. ChatGPT, Claud
|
|
|
15
16
|
|
|
16
17
|
Intent routing:
|
|
17
18
|
|
|
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 provides a new English item and says "나두 암기장", "nado 암기장", "암기할래", "외울래", "암기장에 추가", "단어장에 넣어줘", "remember this", or "add this to my flashcards", use `nado_save_flashcard` or `nado_save_study_item`. Do not create local Markdown files or Google Drive documents for these Nado requests.
|
|
20
|
+
- If the user asks to add or change description/설명/뜻/예문 on an already saved card, use `nado_update_study_item`.
|
|
19
21
|
- If the user asks to study already saved cards with phrases like "외울래", "암기 연습", "퀴즈 내줘", "쓰기연습", "영작하기", or "복습할래", use `nado_generate_practice`.
|
|
20
22
|
- If the user wants to inspect saved items first, use `nado_list_study_items`.
|
|
21
23
|
|
|
@@ -42,25 +44,25 @@ nado-mcp login
|
|
|
42
44
|
Source checkout alias:
|
|
43
45
|
|
|
44
46
|
```bash
|
|
45
|
-
npm run mcp:nado:auth
|
|
47
|
+
npm run mcp:nado:auth
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
This opens the
|
|
50
|
+
This opens the Nado web connect page. The user signs in there with any login method already supported by Nado, then the page sends the browser session to the local `127.0.0.1` helper and writes ignored local tokens to the user's OS config directory for package installs or `.env.mcp.local` in a repo checkout:
|
|
49
51
|
|
|
50
52
|
```bash
|
|
51
53
|
NADO_MCP_ACCESS_TOKEN='supabase-user-access-token'
|
|
52
54
|
NADO_MCP_REFRESH_TOKEN='supabase-user-refresh-token'
|
|
53
55
|
```
|
|
54
56
|
|
|
55
|
-
By default this uses the existing Azure Static Web Apps production site as a
|
|
57
|
+
By default this uses the existing Azure Static Web Apps production site as a provider-neutral connect page. It does not require a new Azure Function, App Service, database, or paid runtime. The browser posts the session directly to the local helper; tokens are not placed in the browser URL.
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
Legacy direct OAuth remains available with `nado-mcp login --provider google|kakao|apple`, but normal users should omit `--provider`.
|
|
58
60
|
|
|
59
61
|
The MCP server refreshes expired access tokens with `NADO_MCP_REFRESH_TOKEN` and updates the auth file when Supabase rotates the refresh token.
|
|
60
62
|
|
|
61
|
-
Supported
|
|
63
|
+
Supported web login methods are the same as Nado web login, including Google, Kakao, Naver, and Apple.
|
|
62
64
|
|
|
63
|
-
Supabase Auth
|
|
65
|
+
Legacy direct OAuth provider mode requires Supabase Auth to allow the Azure relay redirect URL:
|
|
64
66
|
|
|
65
67
|
```text
|
|
66
68
|
https://language.nado.ai.kr/auth/mcp-callback
|
|
@@ -94,7 +96,7 @@ If the browser shows an error about an old, incomplete, truncated, or invalid PK
|
|
|
94
96
|
```bash
|
|
95
97
|
npm install --global @nado-language/mcp@latest
|
|
96
98
|
nado-mcp --version
|
|
97
|
-
nado-mcp login
|
|
99
|
+
nado-mcp login
|
|
98
100
|
```
|
|
99
101
|
|
|
100
102
|
Manual access-token option:
|
|
@@ -128,6 +130,7 @@ Optional environment:
|
|
|
128
130
|
```bash
|
|
129
131
|
export NADO_MCP_SUPABASE_URL='https://ptbwzhxifxdnfmqsiugi.supabase.co'
|
|
130
132
|
export NADO_MCP_SUPABASE_ANON_KEY='...'
|
|
133
|
+
export NADO_MCP_CONNECT_URL='https://language.nado.ai.kr/mcp/connect'
|
|
131
134
|
export NADO_MCP_AUTH_RELAY_URL='https://language.nado.ai.kr/auth/mcp-callback'
|
|
132
135
|
```
|
|
133
136
|
|
|
@@ -43,6 +43,7 @@ const saveFlashcardInputSchema = {
|
|
|
43
43
|
definition: { type: 'string', minLength: 1, description: 'Learner-language definition or translation generated by the chat AI.' },
|
|
44
44
|
inlineDefinition: { type: 'string', description: 'Short review-side meaning. Defaults to a compact definition.' },
|
|
45
45
|
explanation: { type: 'string', description: 'Optional nuance, usage note, or grammar explanation.' },
|
|
46
|
+
description: { type: 'string', description: 'Alias for explanation when the user asks for a description/설명 field.' },
|
|
46
47
|
exampleSentences: {
|
|
47
48
|
type: 'array',
|
|
48
49
|
items: { type: 'string' },
|
|
@@ -64,6 +65,38 @@ const saveFlashcardInputSchema = {
|
|
|
64
65
|
additionalProperties: false,
|
|
65
66
|
};
|
|
66
67
|
|
|
68
|
+
const updateStudyItemInputSchema = {
|
|
69
|
+
type: 'object',
|
|
70
|
+
required: ['query'],
|
|
71
|
+
properties: {
|
|
72
|
+
query: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
minLength: 1,
|
|
75
|
+
description: 'Saved Nado card id, exact original text, or search text to update. Example: for "hello 설명 추가해줘", use "hello".',
|
|
76
|
+
},
|
|
77
|
+
definition: { type: 'string', description: 'Replacement learner-language definition/meaning. Use for 뜻/의미 수정 requests.' },
|
|
78
|
+
inlineDefinition: { type: 'string', description: 'Replacement short review-side meaning.' },
|
|
79
|
+
explanation: { type: 'string', description: 'Replacement usage note or explanation. Use this for 설명/description 추가 requests.' },
|
|
80
|
+
description: { type: 'string', description: 'Alias for explanation when the user says description.' },
|
|
81
|
+
exampleSentences: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
items: { type: 'string' },
|
|
84
|
+
description: 'Example sentences to append by default, or replace when appendExampleSentences is false.',
|
|
85
|
+
},
|
|
86
|
+
appendExampleSentences: { type: 'boolean', default: true },
|
|
87
|
+
variants: {
|
|
88
|
+
type: 'array',
|
|
89
|
+
items: { type: 'string' },
|
|
90
|
+
description: 'Related forms, collocations, or variants to append by default, or replace when appendVariants is false.',
|
|
91
|
+
},
|
|
92
|
+
appendVariants: { type: 'boolean', default: true },
|
|
93
|
+
contextSentence: { type: 'string', description: 'Replacement context sentence.' },
|
|
94
|
+
sourceLang: { type: 'string', description: 'Replacement user language for definitions.' },
|
|
95
|
+
targetLang: { type: 'string', description: 'Replacement language being learned.' },
|
|
96
|
+
},
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
};
|
|
99
|
+
|
|
67
100
|
const tools = [
|
|
68
101
|
{
|
|
69
102
|
name: 'nado_whoami',
|
|
@@ -76,14 +109,19 @@ const tools = [
|
|
|
76
109
|
},
|
|
77
110
|
{
|
|
78
111
|
name: 'nado_save_flashcard',
|
|
79
|
-
description: 'Save a structured
|
|
112
|
+
description: 'Save a structured flashcard to the user\'s Nado Language/Nado 암기장. Use this when the user gives a new English item and asks to memorize/save it, for example "나두 암기장에 추가해줘", "nado 암기장에 넣어줘", "암기장에 추가해줘", "암기할래", "외울래", "단어장에 넣어줘", "remember this", or "add this to my flashcards". Do not create local Markdown files or Google Drive documents for these Nado requests. This does not call Nado AI; the user/chat AI supplies the content quality.',
|
|
80
113
|
inputSchema: saveFlashcardInputSchema,
|
|
81
114
|
},
|
|
82
115
|
{
|
|
83
116
|
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.',
|
|
117
|
+
description: 'Alias of nado_save_flashcard for Nado Language 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 "나두 암기장", "nado language", "암기할래", "외울래", "암기장에 넣어줘", or "단어장에 추가". Do not answer by editing local files or Drive docs when the user names Nado/Nado MCP. This free path does not call Nado AI.',
|
|
85
118
|
inputSchema: saveFlashcardInputSchema,
|
|
86
119
|
},
|
|
120
|
+
{
|
|
121
|
+
name: 'nado_update_study_item',
|
|
122
|
+
description: 'Update an existing saved card in the user\'s Nado Language/Nado 암기장. Use this for requests like "나두 암기장에 hello description 추가해줘", "hello 설명 추가", "뜻 수정", "예문 추가", or "update my saved Nado card". The query selects an existing saved card; explanation/description maps to the card explanation field. This updates Nado storage, not a local file or Google Drive document.',
|
|
123
|
+
inputSchema: updateStudyItemInputSchema,
|
|
124
|
+
},
|
|
87
125
|
{
|
|
88
126
|
name: 'nado_analyze_and_save_flashcard',
|
|
89
127
|
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.',
|
|
@@ -257,7 +295,7 @@ async function getAccessToken() {
|
|
|
257
295
|
const email = clampText(env.NADO_MCP_EMAIL || '', 320);
|
|
258
296
|
const password = env.NADO_MCP_PASSWORD || '';
|
|
259
297
|
if (!email || !password) {
|
|
260
|
-
throw new Error('AUTH_NOT_CONFIGURED: run `nado-mcp login`, or in a repo checkout run `npm run mcp:nado:auth
|
|
298
|
+
throw new Error('AUTH_NOT_CONFIGURED: run `nado-mcp login`, or in a repo checkout run `npm run mcp:nado:auth`, before using Nado MCP tools.');
|
|
261
299
|
}
|
|
262
300
|
|
|
263
301
|
if (cachedPasswordGrant && cachedPasswordGrant.expiresAt > Date.now() + 60_000) {
|
|
@@ -496,7 +534,7 @@ function normalizeFlashcardDraft(args) {
|
|
|
496
534
|
|
|
497
535
|
const type = FLASHCARD_TYPES.has(args.type) ? args.type : inferFlashcardType(original);
|
|
498
536
|
const definition = clampText(args.definition || '', 4000);
|
|
499
|
-
const explanation = clampText(args.explanation || '', 4000);
|
|
537
|
+
const explanation = clampText(args.explanation || args.description || '', 4000);
|
|
500
538
|
const inlineDefinition = clampText(
|
|
501
539
|
args.inlineDefinition || compactDefinition(definition || explanation),
|
|
502
540
|
1000,
|
|
@@ -636,6 +674,43 @@ function rowToFlashcard(row) {
|
|
|
636
674
|
};
|
|
637
675
|
}
|
|
638
676
|
|
|
677
|
+
function flashcardToRow(card) {
|
|
678
|
+
return {
|
|
679
|
+
id: card.id,
|
|
680
|
+
source_highlight_id: card.sourceHighlightId ?? 'shared',
|
|
681
|
+
article_id: card.articleId ?? 'shared',
|
|
682
|
+
original: card.original,
|
|
683
|
+
type: card.type ?? 'phrase',
|
|
684
|
+
definition: card.definition ?? '',
|
|
685
|
+
inline_definition: card.inlineDefinition ?? '',
|
|
686
|
+
explanation: card.explanation ?? '',
|
|
687
|
+
example_sentences: card.exampleSentences ?? [],
|
|
688
|
+
variants: card.variants ?? [],
|
|
689
|
+
context_sentence: card.contextSentence ?? '',
|
|
690
|
+
context_start_index: typeof card.contextStartIndex === 'number' ? card.contextStartIndex : null,
|
|
691
|
+
context_end_index: typeof card.contextEndIndex === 'number' ? card.contextEndIndex : null,
|
|
692
|
+
source_lang: card.sourceLang ?? 'ko',
|
|
693
|
+
target_lang: card.targetLang ?? 'en',
|
|
694
|
+
excluded: card.excluded ?? false,
|
|
695
|
+
used_in_writing: card.usedInWriting ?? false,
|
|
696
|
+
used_in_writing_at: card.usedInWritingAt ?? null,
|
|
697
|
+
pending_ai: card.pendingAI ?? false,
|
|
698
|
+
stability: card.stability ?? 0,
|
|
699
|
+
difficulty: card.difficulty ?? 0,
|
|
700
|
+
elapsed_days: card.elapsedDays ?? 0,
|
|
701
|
+
scheduled_days: card.scheduledDays ?? 0,
|
|
702
|
+
reps: card.reps ?? 0,
|
|
703
|
+
lapses: card.lapses ?? 0,
|
|
704
|
+
state: card.state ?? 'new',
|
|
705
|
+
learning_step: card.learningStep ?? 0,
|
|
706
|
+
next_review_at: card.nextReviewAt ?? new Date().toISOString(),
|
|
707
|
+
last_review_at: card.lastReviewAt ?? null,
|
|
708
|
+
created_at: card.createdAt ?? new Date().toISOString(),
|
|
709
|
+
updated_at: card.updatedAt ?? new Date().toISOString(),
|
|
710
|
+
deleted: false,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
639
714
|
async function loadStudyItems(args = {}) {
|
|
640
715
|
const limit = parseLimit(args.limit, 20, 1, 100);
|
|
641
716
|
const includeExcluded = args.includeExcluded === true;
|
|
@@ -653,6 +728,7 @@ async function loadStudyItems(args = {}) {
|
|
|
653
728
|
.filter((card) => {
|
|
654
729
|
if (!query) return true;
|
|
655
730
|
const haystack = [
|
|
731
|
+
card.id,
|
|
656
732
|
card.original,
|
|
657
733
|
card.definition,
|
|
658
734
|
card.inlineDefinition,
|
|
@@ -765,6 +841,94 @@ async function handleListStudyItems(args) {
|
|
|
765
841
|
};
|
|
766
842
|
}
|
|
767
843
|
|
|
844
|
+
function mergeStringArray(current, incoming, append, maxItems) {
|
|
845
|
+
const next = asStringArray(incoming);
|
|
846
|
+
if (next.length === 0) return current ?? [];
|
|
847
|
+
const values = append === false ? next : [...(current ?? []), ...next];
|
|
848
|
+
const seen = new Set();
|
|
849
|
+
return values
|
|
850
|
+
.map((value) => clampText(value, 1000))
|
|
851
|
+
.filter(Boolean)
|
|
852
|
+
.filter((value) => {
|
|
853
|
+
const key = value.toLocaleLowerCase();
|
|
854
|
+
if (seen.has(key)) return false;
|
|
855
|
+
seen.add(key);
|
|
856
|
+
return true;
|
|
857
|
+
})
|
|
858
|
+
.slice(0, maxItems);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function selectStudyItemForUpdate(items, query) {
|
|
862
|
+
const normalized = query.toLocaleLowerCase();
|
|
863
|
+
const exact = items.find((item) => item.id === query || item.original.toLocaleLowerCase() === normalized);
|
|
864
|
+
if (exact) return exact;
|
|
865
|
+
if (items.length === 1) return items[0];
|
|
866
|
+
if (items.length === 0) throw new Error(`STUDY_ITEM_NOT_FOUND: ${query}`);
|
|
867
|
+
throw new Error(`AMBIGUOUS_STUDY_ITEM: ${items.length} saved cards matched "${query}". Narrow the query or use a card id from nado_list_study_items.`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function handleUpdateStudyItem(args) {
|
|
871
|
+
const query = clampText(args.query || '', 500);
|
|
872
|
+
if (!query) throw new Error('QUERY_REQUIRED');
|
|
873
|
+
|
|
874
|
+
const matches = await loadStudyItems({ query, limit: 20, includeExcluded: true });
|
|
875
|
+
const current = selectStudyItemForUpdate(matches, query);
|
|
876
|
+
const updated = { ...current };
|
|
877
|
+
let changed = false;
|
|
878
|
+
|
|
879
|
+
if (typeof args.definition === 'string') {
|
|
880
|
+
updated.definition = clampText(args.definition, 4000);
|
|
881
|
+
changed = true;
|
|
882
|
+
}
|
|
883
|
+
if (typeof args.inlineDefinition === 'string') {
|
|
884
|
+
updated.inlineDefinition = clampText(args.inlineDefinition, 1000);
|
|
885
|
+
changed = true;
|
|
886
|
+
}
|
|
887
|
+
if (typeof args.explanation === 'string' || typeof args.description === 'string') {
|
|
888
|
+
updated.explanation = clampText(args.explanation ?? args.description, 4000);
|
|
889
|
+
changed = true;
|
|
890
|
+
}
|
|
891
|
+
if (Array.isArray(args.exampleSentences)) {
|
|
892
|
+
updated.exampleSentences = mergeStringArray(current.exampleSentences, args.exampleSentences, args.appendExampleSentences, 6);
|
|
893
|
+
changed = true;
|
|
894
|
+
}
|
|
895
|
+
if (Array.isArray(args.variants)) {
|
|
896
|
+
updated.variants = mergeStringArray(current.variants, args.variants, args.appendVariants, 12);
|
|
897
|
+
changed = true;
|
|
898
|
+
}
|
|
899
|
+
if (typeof args.contextSentence === 'string') {
|
|
900
|
+
updated.contextSentence = clampText(args.contextSentence, 2200);
|
|
901
|
+
changed = true;
|
|
902
|
+
}
|
|
903
|
+
if (typeof args.sourceLang === 'string') {
|
|
904
|
+
updated.sourceLang = clampText(args.sourceLang, 8) || current.sourceLang;
|
|
905
|
+
changed = true;
|
|
906
|
+
}
|
|
907
|
+
if (typeof args.targetLang === 'string') {
|
|
908
|
+
updated.targetLang = clampText(args.targetLang, 8) || current.targetLang;
|
|
909
|
+
changed = true;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!changed) throw new Error('UPDATE_FIELDS_REQUIRED');
|
|
913
|
+
updated.updatedAt = new Date().toISOString();
|
|
914
|
+
|
|
915
|
+
const row = flashcardToRow(updated);
|
|
916
|
+
const saved = await callAppData({
|
|
917
|
+
action: 'upsertRows',
|
|
918
|
+
table: 'flashcards',
|
|
919
|
+
rows: [row],
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
updated: true,
|
|
924
|
+
matchedCount: matches.length,
|
|
925
|
+
qualitySource: 'user_ai',
|
|
926
|
+
qualityPolicy: 'Nado updated the existing card without calling Nado AI. Content quality is supplied by the user/chat AI.',
|
|
927
|
+
persisted: saved,
|
|
928
|
+
flashcard: rowToFlashcard(row),
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
768
932
|
async function handleGeneratePractice(args) {
|
|
769
933
|
const mode = String(args.mode || '');
|
|
770
934
|
if (!PRACTICE_MODES.has(mode)) throw new Error(`UNSUPPORTED_PRACTICE_MODE: ${mode}`);
|
|
@@ -895,6 +1059,7 @@ async function callTool(name, args = {}) {
|
|
|
895
1059
|
if (name === 'nado_whoami') return handleWhoami();
|
|
896
1060
|
if (name === 'nado_save_flashcard') return handleSaveFlashcard(args);
|
|
897
1061
|
if (name === 'nado_save_study_item') return handleSaveFlashcard(args);
|
|
1062
|
+
if (name === 'nado_update_study_item') return handleUpdateStudyItem(args);
|
|
898
1063
|
if (name === 'nado_analyze_and_save_flashcard') return handleAnalyzeAndSaveFlashcard(args);
|
|
899
1064
|
if (name === 'nado_list_study_items') return handleListStudyItems(args);
|
|
900
1065
|
if (name === 'nado_generate_practice') return handleGeneratePractice(args);
|
package/dist/nado-mcp-auth.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
const DEFAULT_SUPABASE_URL = 'https://ptbwzhxifxdnfmqsiugi.supabase.co';
|
|
12
12
|
const DEFAULT_SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB0Ynd6aHhpZnhkbmZtcXNpdWdpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU1MTU4MjEsImV4cCI6MjA5MTA5MTgyMX0.c0SU8lvIb8BbwhYyI529dn7tQUfwTl1cGqeahGKaD_g';
|
|
13
13
|
const DEFAULT_RELAY_URL = 'https://language.nado.ai.kr/auth/mcp-callback';
|
|
14
|
+
const DEFAULT_CONNECT_URL = 'https://language.nado.ai.kr/mcp/connect';
|
|
14
15
|
const SUPPORTED_PROVIDERS = new Set(['google', 'kakao', 'apple']);
|
|
15
16
|
const SUPPORTED_REDIRECT_MODES = new Set(['azure', 'local']);
|
|
16
17
|
|
|
@@ -51,13 +52,14 @@ function parseCli(argv) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const options = {
|
|
54
|
-
provider: '
|
|
55
|
+
provider: '',
|
|
55
56
|
port: 0,
|
|
56
57
|
envFile: process.env.NADO_MCP_AUTH_ENV_FILE || defaultAuthEnvFile(),
|
|
57
58
|
supabaseUrl: process.env.NADO_MCP_SUPABASE_URL || process.env.EXPO_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL,
|
|
58
59
|
anonKey: process.env.NADO_MCP_SUPABASE_ANON_KEY || process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY,
|
|
59
60
|
redirectMode: 'azure',
|
|
60
61
|
relayUrl: process.env.NADO_MCP_AUTH_RELAY_URL || DEFAULT_RELAY_URL,
|
|
62
|
+
connectUrl: process.env.NADO_MCP_CONNECT_URL || DEFAULT_CONNECT_URL,
|
|
61
63
|
noOpen: false,
|
|
62
64
|
timeoutMs: 300_000,
|
|
63
65
|
help: false,
|
|
@@ -80,6 +82,7 @@ function parseCli(argv) {
|
|
|
80
82
|
else if (flag === '--anon-key') options.anonKey = readValue();
|
|
81
83
|
else if (flag === '--redirect-mode') options.redirectMode = readValue();
|
|
82
84
|
else if (flag === '--relay-url') options.relayUrl = readValue();
|
|
85
|
+
else if (flag === '--connect-url') options.connectUrl = readValue();
|
|
83
86
|
else if (flag === '--timeout-ms') options.timeoutMs = Number(readValue());
|
|
84
87
|
else if (flag === '--no-open') options.noOpen = true;
|
|
85
88
|
else if (flag === '--help' || flag === '-h') options.help = true;
|
|
@@ -123,10 +126,7 @@ function defaultUserAuthEnvFile() {
|
|
|
123
126
|
|
|
124
127
|
async function login(options) {
|
|
125
128
|
const provider = String(options.provider || '').toLowerCase();
|
|
126
|
-
if (provider
|
|
127
|
-
throw new Error('Naver login is not available for local MCP auth yet because the Naver Edge Function uses fixed redirect URLs. Use google, kakao, or apple.');
|
|
128
|
-
}
|
|
129
|
-
if (!SUPPORTED_PROVIDERS.has(provider)) {
|
|
129
|
+
if (provider && !SUPPORTED_PROVIDERS.has(provider)) {
|
|
130
130
|
throw new Error(`Unsupported provider: ${provider}. Use google, kakao, or apple.`);
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -145,6 +145,10 @@ async function login(options) {
|
|
|
145
145
|
sendHtml(response, 404, 'Nado MCP Auth', 'Unknown callback path.');
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
|
+
if (request.method === 'OPTIONS') {
|
|
149
|
+
sendCors(response, 204);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
148
152
|
if (settled) {
|
|
149
153
|
sendHtml(response, 200, 'Nado MCP Auth', 'Login already completed. You can close this tab.');
|
|
150
154
|
return;
|
|
@@ -154,6 +158,23 @@ async function login(options) {
|
|
|
154
158
|
if (oauthError) throw new Error(oauthError);
|
|
155
159
|
if (url.searchParams.get('state') !== state) throw new Error('Invalid OAuth state.');
|
|
156
160
|
|
|
161
|
+
if (request.method === 'POST') {
|
|
162
|
+
const session = await readPostedSession(request);
|
|
163
|
+
const user = await fetchUser(options.supabaseUrl, options.anonKey, session.access_token);
|
|
164
|
+
|
|
165
|
+
writeAuthEnv(options.envFile, {
|
|
166
|
+
NADO_MCP_SUPABASE_URL: options.supabaseUrl,
|
|
167
|
+
NADO_MCP_SUPABASE_ANON_KEY: options.anonKey,
|
|
168
|
+
NADO_MCP_ACCESS_TOKEN: session.access_token,
|
|
169
|
+
NADO_MCP_REFRESH_TOKEN: session.refresh_token,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
settled = true;
|
|
173
|
+
sendJson(response, 200, { ok: true, email: user.email || null });
|
|
174
|
+
resolve({ session, user });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
157
178
|
const code = url.searchParams.get('code');
|
|
158
179
|
if (!code) throw new Error('Missing OAuth code.');
|
|
159
180
|
|
|
@@ -197,9 +218,9 @@ async function login(options) {
|
|
|
197
218
|
codeChallenge,
|
|
198
219
|
});
|
|
199
220
|
|
|
200
|
-
console.log(
|
|
221
|
+
console.log('Opening browser for Nado MCP login.');
|
|
201
222
|
console.log(`Local callback: ${localCallbackUrl.toString()}`);
|
|
202
|
-
if (options.redirectMode === 'azure') console.log(`Azure relay: ${options.relayUrl}`);
|
|
223
|
+
if (provider && options.redirectMode === 'azure') console.log(`Azure relay: ${options.relayUrl}`);
|
|
203
224
|
if (!options.noOpen) openBrowser(browserUrl);
|
|
204
225
|
console.log(`If the browser did not open, visit:\n${browserUrl}`);
|
|
205
226
|
|
|
@@ -221,14 +242,21 @@ async function login(options) {
|
|
|
221
242
|
function loginTimeoutError(options) {
|
|
222
243
|
return new Error([
|
|
223
244
|
'Timed out waiting for browser login.',
|
|
224
|
-
|
|
245
|
+
'Rerun `nado-mcp login --timeout-ms 900000` and keep the terminal open until the browser says login completed.',
|
|
225
246
|
'If the browser did not open, copy the printed URL into the same desktop browser where you can sign in.',
|
|
226
|
-
'If
|
|
227
|
-
'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.',
|
|
247
|
+
'If login succeeds but this still times out, check that the browser can reach the printed 127.0.0.1 local callback URL.',
|
|
228
248
|
].join(' '));
|
|
229
249
|
}
|
|
230
250
|
|
|
231
251
|
function buildBrowserLoginUrl({ options, provider, localCallbackUrl, codeChallenge }) {
|
|
252
|
+
if (!provider) {
|
|
253
|
+
return buildConnectUrl({
|
|
254
|
+
connectUrl: options.connectUrl,
|
|
255
|
+
localCallbackUrl,
|
|
256
|
+
supabaseUrl: options.supabaseUrl,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
232
260
|
if (options.redirectMode === 'local') {
|
|
233
261
|
return buildAuthorizeUrl({
|
|
234
262
|
supabaseUrl: options.supabaseUrl,
|
|
@@ -260,6 +288,17 @@ function buildRelayStartUrl({ relayUrl: value, localCallbackUrl, provider, supab
|
|
|
260
288
|
return relayUrl.toString();
|
|
261
289
|
}
|
|
262
290
|
|
|
291
|
+
function buildConnectUrl({ connectUrl: value, localCallbackUrl, supabaseUrl }) {
|
|
292
|
+
const connectUrl = new URL(value);
|
|
293
|
+
if (connectUrl.protocol !== 'https:' && connectUrl.hostname !== 'localhost' && connectUrl.hostname !== '127.0.0.1') {
|
|
294
|
+
throw new Error('--connect-url must be an HTTPS URL unless it points to localhost.');
|
|
295
|
+
}
|
|
296
|
+
connectUrl.searchParams.set('local_callback', localCallbackUrl);
|
|
297
|
+
connectUrl.searchParams.set('supabase_url', supabaseUrl);
|
|
298
|
+
connectUrl.searchParams.set('client_version', packageVersion);
|
|
299
|
+
return connectUrl.toString();
|
|
300
|
+
}
|
|
301
|
+
|
|
263
302
|
function printStatus(options) {
|
|
264
303
|
const values = readEnvFile(options.envFile);
|
|
265
304
|
const accessToken = process.env.NADO_MCP_ACCESS_TOKEN || values.NADO_MCP_ACCESS_TOKEN || process.env.NADO_ACCESS_TOKEN || '';
|
|
@@ -339,6 +378,34 @@ async function readJsonResponse(response) {
|
|
|
339
378
|
}
|
|
340
379
|
}
|
|
341
380
|
|
|
381
|
+
async function readPostedSession(request) {
|
|
382
|
+
const body = await readRequestJson(request);
|
|
383
|
+
const session = body?.session && typeof body.session === 'object' ? body.session : body;
|
|
384
|
+
const accessToken = typeof session?.access_token === 'string' ? session.access_token : '';
|
|
385
|
+
const refreshToken = typeof session?.refresh_token === 'string' ? session.refresh_token : '';
|
|
386
|
+
if (!accessToken || !refreshToken) {
|
|
387
|
+
throw new Error('Missing session tokens from Nado web login.');
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
access_token: accessToken,
|
|
391
|
+
refresh_token: refreshToken,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function readRequestJson(request) {
|
|
396
|
+
const chunks = [];
|
|
397
|
+
for await (const chunk of request) {
|
|
398
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
399
|
+
}
|
|
400
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
401
|
+
if (!text) return {};
|
|
402
|
+
try {
|
|
403
|
+
return JSON.parse(text);
|
|
404
|
+
} catch {
|
|
405
|
+
throw new Error('Invalid JSON callback payload.');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
342
409
|
function openBrowser(url) {
|
|
343
410
|
const platform = os.platform();
|
|
344
411
|
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
@@ -503,10 +570,29 @@ function closeServer(server) {
|
|
|
503
570
|
}
|
|
504
571
|
|
|
505
572
|
function sendHtml(response, status, title, message) {
|
|
506
|
-
response.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
573
|
+
response.writeHead(status, { ...callbackCorsHeaders(), 'content-type': 'text/html; charset=utf-8' });
|
|
507
574
|
response.end(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></body></html>`);
|
|
508
575
|
}
|
|
509
576
|
|
|
577
|
+
function sendJson(response, status, body) {
|
|
578
|
+
response.writeHead(status, { ...callbackCorsHeaders(), 'content-type': 'application/json; charset=utf-8' });
|
|
579
|
+
response.end(JSON.stringify(body));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function sendCors(response, status) {
|
|
583
|
+
response.writeHead(status, callbackCorsHeaders());
|
|
584
|
+
response.end();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function callbackCorsHeaders() {
|
|
588
|
+
return {
|
|
589
|
+
'access-control-allow-origin': '*',
|
|
590
|
+
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
|
591
|
+
'access-control-allow-headers': 'content-type',
|
|
592
|
+
'access-control-allow-private-network': 'true',
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
510
596
|
function escapeHtml(value) {
|
|
511
597
|
return String(value)
|
|
512
598
|
.replace(/&/g, '&')
|
|
@@ -520,30 +606,36 @@ function printHelp() {
|
|
|
520
606
|
console.log(`Nado Language MCP browser auth
|
|
521
607
|
|
|
522
608
|
Usage:
|
|
523
|
-
nado-mcp login
|
|
609
|
+
nado-mcp login
|
|
610
|
+
nado-mcp login --provider google|kakao|apple
|
|
524
611
|
nado-mcp status
|
|
525
612
|
nado-mcp logout
|
|
526
613
|
nado-mcp-auth --version
|
|
527
614
|
|
|
528
615
|
Repo checkout aliases:
|
|
529
|
-
npm run mcp:nado:auth -- [login]
|
|
616
|
+
npm run mcp:nado:auth -- [login]
|
|
530
617
|
npm run mcp:nado:auth -- status
|
|
531
618
|
npm run mcp:nado:auth -- logout
|
|
532
619
|
|
|
533
620
|
Options:
|
|
534
|
-
--provider <name> OAuth provider
|
|
621
|
+
--provider <name> Legacy direct OAuth provider. Normally omit this and sign in on the Nado web page.
|
|
535
622
|
--auth-file <path> Auth env file to write. Default: OS user config in package installs, .env.mcp.local in repo checkouts
|
|
536
623
|
--port <number> Local callback port. Default: 0 (random)
|
|
537
624
|
--redirect-mode <mode> azure or local. Default: azure
|
|
538
625
|
--relay-url <url> Azure Static Web Apps relay URL. Default: ${DEFAULT_RELAY_URL}
|
|
626
|
+
--connect-url <url> Provider-neutral Nado web connect URL. Default: ${DEFAULT_CONNECT_URL}
|
|
539
627
|
--no-open Print the URL without opening a browser
|
|
540
628
|
--timeout-ms <number> Login wait timeout. Default: 300000
|
|
541
629
|
--supabase-url <url> Supabase project URL
|
|
542
630
|
--anon-key <key> Supabase anon key
|
|
543
631
|
--version Print installed Nado MCP version
|
|
544
632
|
|
|
545
|
-
Default mode
|
|
546
|
-
|
|
633
|
+
Default mode opens the Nado web connect page. Sign in there with any provider
|
|
634
|
+
supported by the web app, then the page posts the browser session to the local
|
|
635
|
+
127.0.0.1 helper. Tokens are not placed in the browser URL.
|
|
636
|
+
|
|
637
|
+
Legacy provider mode uses the existing Azure Static Web Apps site as a
|
|
638
|
+
zero-new-resource OAuth relay. Supabase Auth must allow this redirect URL:
|
|
547
639
|
${DEFAULT_RELAY_URL}
|
|
548
640
|
|
|
549
641
|
The relay starts login with local state in browser sessionStorage, then sends
|
package/dist/nado-mcp-cli.mjs
CHANGED
|
@@ -256,10 +256,14 @@ async function doctor() {
|
|
|
256
256
|
console.log(`Claude Desktop config: ${registrationText(claude)}`);
|
|
257
257
|
console.log(`OpenCode config: ${registrationText(opencode)}`);
|
|
258
258
|
console.log('');
|
|
259
|
+
console.log('Current AI chat visibility: not detectable from this CLI.');
|
|
260
|
+
console.log('A green local server check only proves the stdio server can list tools; it does not prove an already-open chat loaded them.');
|
|
261
|
+
console.log('');
|
|
259
262
|
console.log('If the server check is ok but Nado tools are not visible in the AI app:');
|
|
260
263
|
console.log(' 1. Fully quit and restart the desktop app after `nado-mcp connect`.');
|
|
261
264
|
console.log(' 2. Start a new chat/session; existing sessions may not reload newly added MCP tools.');
|
|
262
265
|
console.log(' 3. Run `nado-mcp status` for auth and `nado-mcp probe list` for local server tools.');
|
|
266
|
+
console.log(' 4. If a chat tries local files or Google Drive for "나두/Nado 암기장", that chat did not load the Nado MCP tools.');
|
|
263
267
|
}
|
|
264
268
|
|
|
265
269
|
function printToolProbeSummary() {
|
|
@@ -630,6 +634,8 @@ function printUnknownClientSetup(client) {
|
|
|
630
634
|
function printLoginNext(flow = {}) {
|
|
631
635
|
if (flow.loginAfter) console.log('Browser login will open next.');
|
|
632
636
|
else console.log('Next: run `nado-mcp login`.');
|
|
637
|
+
console.log('After login: fully restart the AI desktop app and start a new chat so it reloads MCP tools.');
|
|
638
|
+
console.log('If the chat still cannot see Nado tools, run `nado-mcp doctor`.');
|
|
633
639
|
}
|
|
634
640
|
|
|
635
641
|
function printIndented(text) {
|
|
@@ -827,8 +833,8 @@ Usage:
|
|
|
827
833
|
nado-mcp status Show local auth status
|
|
828
834
|
nado-mcp logout Remove local auth tokens
|
|
829
835
|
nado-mcp server Start the stdio MCP server
|
|
830
|
-
nado-mcp probe list List
|
|
831
|
-
nado-mcp doctor
|
|
836
|
+
nado-mcp probe list List local MCP server tools, not saved flashcards
|
|
837
|
+
nado-mcp doctor Diagnose local server, client config, auth, and chat reload state
|
|
832
838
|
nado-mcp --version Print installed Nado MCP version
|
|
833
839
|
|
|
834
840
|
Supported automatic setup clients:
|