@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,495 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SUPABASE_URL = 'https://ptbwzhxifxdnfmqsiugi.supabase.co';
|
|
12
|
+
const DEFAULT_SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB0Ynd6aHhpZnhkbmZtcXNpdWdpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU1MTU4MjEsImV4cCI6MjA5MTA5MTgyMX0.c0SU8lvIb8BbwhYyI529dn7tQUfwTl1cGqeahGKaD_g';
|
|
13
|
+
const DEFAULT_RELAY_URL = 'https://language.nado.ai.kr/auth/mcp-callback';
|
|
14
|
+
const SUPPORTED_PROVIDERS = new Set(['google', 'kakao', 'apple']);
|
|
15
|
+
const SUPPORTED_REDIRECT_MODES = new Set(['azure', 'local']);
|
|
16
|
+
|
|
17
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const repoRoot = path.resolve(scriptDir, '..');
|
|
19
|
+
|
|
20
|
+
const { command, options } = parseCli(process.argv.slice(2));
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
if (options.help || command === 'help') {
|
|
24
|
+
printHelp();
|
|
25
|
+
} else if (command === 'login') {
|
|
26
|
+
await login(options);
|
|
27
|
+
} else if (command === 'status') {
|
|
28
|
+
printStatus(options);
|
|
29
|
+
} else if (command === 'logout') {
|
|
30
|
+
logout(options);
|
|
31
|
+
} else {
|
|
32
|
+
throw new Error(`Unknown command: ${command}`);
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`Nado MCP auth failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseCli(argv) {
|
|
40
|
+
let command = 'login';
|
|
41
|
+
let args = argv;
|
|
42
|
+
if (argv[0] && !argv[0].startsWith('-')) {
|
|
43
|
+
command = argv[0];
|
|
44
|
+
args = argv.slice(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const options = {
|
|
48
|
+
provider: 'google',
|
|
49
|
+
port: 0,
|
|
50
|
+
envFile: process.env.NADO_MCP_AUTH_ENV_FILE || defaultAuthEnvFile(),
|
|
51
|
+
supabaseUrl: process.env.NADO_MCP_SUPABASE_URL || process.env.EXPO_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL,
|
|
52
|
+
anonKey: process.env.NADO_MCP_SUPABASE_ANON_KEY || process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY,
|
|
53
|
+
redirectMode: 'azure',
|
|
54
|
+
relayUrl: process.env.NADO_MCP_AUTH_RELAY_URL || DEFAULT_RELAY_URL,
|
|
55
|
+
noOpen: false,
|
|
56
|
+
timeoutMs: 300_000,
|
|
57
|
+
help: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
61
|
+
const arg = args[i];
|
|
62
|
+
const [flag, inlineValue] = arg.includes('=') ? arg.split(/=(.*)/s, 2) : [arg, undefined];
|
|
63
|
+
const readValue = () => {
|
|
64
|
+
if (inlineValue !== undefined) return inlineValue;
|
|
65
|
+
i += 1;
|
|
66
|
+
if (i >= args.length) throw new Error(`Missing value for ${flag}`);
|
|
67
|
+
return args[i];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (flag === '--provider') options.provider = readValue();
|
|
71
|
+
else if (flag === '--port') options.port = Number(readValue());
|
|
72
|
+
else if (flag === '--auth-file') options.envFile = resolvePath(readValue());
|
|
73
|
+
else if (flag === '--supabase-url') options.supabaseUrl = readValue();
|
|
74
|
+
else if (flag === '--anon-key') options.anonKey = readValue();
|
|
75
|
+
else if (flag === '--redirect-mode') options.redirectMode = readValue();
|
|
76
|
+
else if (flag === '--relay-url') options.relayUrl = readValue();
|
|
77
|
+
else if (flag === '--timeout-ms') options.timeoutMs = Number(readValue());
|
|
78
|
+
else if (flag === '--no-open') options.noOpen = true;
|
|
79
|
+
else if (flag === '--help' || flag === '-h') options.help = true;
|
|
80
|
+
else throw new Error(`Unknown option: ${arg}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
options.supabaseUrl = normalizeUrl(options.supabaseUrl);
|
|
84
|
+
options.redirectMode = String(options.redirectMode || '').toLowerCase();
|
|
85
|
+
if (!Number.isFinite(options.port) || options.port < 0 || options.port > 65535) {
|
|
86
|
+
throw new Error('--port must be between 0 and 65535');
|
|
87
|
+
}
|
|
88
|
+
if (!SUPPORTED_REDIRECT_MODES.has(options.redirectMode)) {
|
|
89
|
+
throw new Error('--redirect-mode must be azure or local');
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1000) {
|
|
92
|
+
throw new Error('--timeout-ms must be at least 1000');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { command, options };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function defaultAuthEnvFile() {
|
|
99
|
+
if (isRepoCheckout()) return path.join(repoRoot, '.env.mcp.local');
|
|
100
|
+
return defaultUserAuthEnvFile();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRepoCheckout() {
|
|
104
|
+
return existsSync(path.join(repoRoot, 'mcp', 'nado-language-server.mjs'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultUserAuthEnvFile() {
|
|
108
|
+
if (process.platform === 'win32') {
|
|
109
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Nado', 'MCP', 'auth.env');
|
|
110
|
+
}
|
|
111
|
+
if (process.platform === 'darwin') {
|
|
112
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Nado', 'MCP', 'auth.env');
|
|
113
|
+
}
|
|
114
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'nado', 'mcp', 'auth.env');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function login(options) {
|
|
118
|
+
const provider = String(options.provider || '').toLowerCase();
|
|
119
|
+
if (provider === 'naver') {
|
|
120
|
+
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.');
|
|
121
|
+
}
|
|
122
|
+
if (!SUPPORTED_PROVIDERS.has(provider)) {
|
|
123
|
+
throw new Error(`Unsupported provider: ${provider}. Use google, kakao, or apple.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const codeVerifier = base64Url(randomBytes(48));
|
|
127
|
+
const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest());
|
|
128
|
+
const state = base64Url(randomBytes(24));
|
|
129
|
+
|
|
130
|
+
let settled = false;
|
|
131
|
+
let server;
|
|
132
|
+
|
|
133
|
+
const sessionPromise = new Promise((resolve, reject) => {
|
|
134
|
+
server = http.createServer(async (request, response) => {
|
|
135
|
+
try {
|
|
136
|
+
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
|
137
|
+
if (url.pathname !== '/callback') {
|
|
138
|
+
sendHtml(response, 404, 'Nado MCP Auth', 'Unknown callback path.');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (settled) {
|
|
142
|
+
sendHtml(response, 200, 'Nado MCP Auth', 'Login already completed. You can close this tab.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const oauthError = url.searchParams.get('error_description') || url.searchParams.get('error');
|
|
147
|
+
if (oauthError) throw new Error(oauthError);
|
|
148
|
+
if (url.searchParams.get('state') !== state) throw new Error('Invalid OAuth state.');
|
|
149
|
+
|
|
150
|
+
const code = url.searchParams.get('code');
|
|
151
|
+
if (!code) throw new Error('Missing OAuth code.');
|
|
152
|
+
|
|
153
|
+
const session = await exchangeCodeForSession({
|
|
154
|
+
supabaseUrl: options.supabaseUrl,
|
|
155
|
+
anonKey: options.anonKey,
|
|
156
|
+
code,
|
|
157
|
+
codeVerifier,
|
|
158
|
+
});
|
|
159
|
+
const user = await fetchUser(options.supabaseUrl, options.anonKey, session.access_token);
|
|
160
|
+
|
|
161
|
+
writeAuthEnv(options.envFile, {
|
|
162
|
+
NADO_MCP_SUPABASE_URL: options.supabaseUrl,
|
|
163
|
+
NADO_MCP_SUPABASE_ANON_KEY: options.anonKey,
|
|
164
|
+
NADO_MCP_ACCESS_TOKEN: session.access_token,
|
|
165
|
+
NADO_MCP_REFRESH_TOKEN: session.refresh_token,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
settled = true;
|
|
169
|
+
sendHtml(response, 200, 'Nado MCP Auth', 'Login completed. You can close this tab and return to the terminal.');
|
|
170
|
+
resolve({ session, user });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
settled = true;
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
sendHtml(response, 500, 'Nado MCP Auth Failed', message);
|
|
175
|
+
reject(error);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await listen(server, Number(options.port || 0));
|
|
181
|
+
const address = server.address();
|
|
182
|
+
if (!address || typeof address === 'string') throw new Error('Failed to allocate local callback port.');
|
|
183
|
+
|
|
184
|
+
const localCallbackUrl = new URL(`http://127.0.0.1:${address.port}/callback`);
|
|
185
|
+
localCallbackUrl.searchParams.set('state', state);
|
|
186
|
+
const redirectTo = buildRedirectTo(options, localCallbackUrl.toString());
|
|
187
|
+
const authUrl = buildAuthorizeUrl({
|
|
188
|
+
supabaseUrl: options.supabaseUrl,
|
|
189
|
+
provider,
|
|
190
|
+
redirectTo,
|
|
191
|
+
codeChallenge,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
console.log(`Opening browser for Nado MCP login (${provider}).`);
|
|
195
|
+
console.log(`Local callback: ${localCallbackUrl.toString()}`);
|
|
196
|
+
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}`);
|
|
199
|
+
|
|
200
|
+
let timeoutId;
|
|
201
|
+
const timeout = new Promise((_, reject) => {
|
|
202
|
+
timeoutId = setTimeout(() => reject(new Error('Timed out waiting for browser login.')), options.timeoutMs);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const { user } = await Promise.race([sessionPromise, timeout]);
|
|
207
|
+
console.log(`Authenticated: ${user.email || user.id || 'unknown user'}`);
|
|
208
|
+
console.log(`Saved MCP auth tokens to ${options.envFile}`);
|
|
209
|
+
} finally {
|
|
210
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
211
|
+
await closeServer(server);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildRedirectTo(options, localCallbackUrl) {
|
|
216
|
+
if (options.redirectMode === 'local') return localCallbackUrl;
|
|
217
|
+
|
|
218
|
+
const relayUrl = new URL(options.relayUrl);
|
|
219
|
+
if (relayUrl.protocol !== 'https:') {
|
|
220
|
+
throw new Error('--relay-url must be an HTTPS URL when --redirect-mode azure is used.');
|
|
221
|
+
}
|
|
222
|
+
relayUrl.searchParams.set('local_callback', localCallbackUrl);
|
|
223
|
+
return relayUrl.toString();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function printStatus(options) {
|
|
227
|
+
const values = readEnvFile(options.envFile);
|
|
228
|
+
const accessToken = process.env.NADO_MCP_ACCESS_TOKEN || values.NADO_MCP_ACCESS_TOKEN || process.env.NADO_ACCESS_TOKEN || '';
|
|
229
|
+
const refreshToken = process.env.NADO_MCP_REFRESH_TOKEN || values.NADO_MCP_REFRESH_TOKEN || process.env.NADO_REFRESH_TOKEN || '';
|
|
230
|
+
const email = process.env.NADO_MCP_EMAIL || values.NADO_MCP_EMAIL || '';
|
|
231
|
+
|
|
232
|
+
console.log(`Env file: ${options.envFile}`);
|
|
233
|
+
console.log(`Access token: ${accessToken ? `present${tokenExpiryText(accessToken)}` : 'missing'}`);
|
|
234
|
+
console.log(`Refresh token: ${refreshToken ? 'present' : 'missing'}`);
|
|
235
|
+
console.log(`Email/password fallback: ${email ? `configured for ${email}` : 'missing'}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function logout(options) {
|
|
239
|
+
if (!existsSync(options.envFile)) {
|
|
240
|
+
console.log(`No auth env file found at ${options.envFile}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
removeEnvKeys(options.envFile, [
|
|
244
|
+
'NADO_MCP_ACCESS_TOKEN',
|
|
245
|
+
'NADO_MCP_REFRESH_TOKEN',
|
|
246
|
+
]);
|
|
247
|
+
console.log(`Removed MCP auth tokens from ${options.envFile}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildAuthorizeUrl({ supabaseUrl, provider, redirectTo, codeChallenge }) {
|
|
251
|
+
const params = new URLSearchParams({
|
|
252
|
+
provider,
|
|
253
|
+
redirect_to: redirectTo,
|
|
254
|
+
code_challenge: codeChallenge,
|
|
255
|
+
code_challenge_method: 's256',
|
|
256
|
+
});
|
|
257
|
+
if (provider === 'kakao') params.set('scope', 'account_email');
|
|
258
|
+
return `${supabaseUrl}/auth/v1/authorize?${params.toString()}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function exchangeCodeForSession({ supabaseUrl, anonKey, code, codeVerifier }) {
|
|
262
|
+
const response = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=pkce`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: {
|
|
265
|
+
apikey: anonKey,
|
|
266
|
+
'Content-Type': 'application/json',
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify({
|
|
269
|
+
auth_code: code,
|
|
270
|
+
code_verifier: codeVerifier,
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
const body = await readJsonResponse(response);
|
|
274
|
+
if (!response.ok || !body.access_token || !body.refresh_token) {
|
|
275
|
+
throw new Error(`Token exchange failed (${response.status}): ${body.error_description || body.error || body.msg || 'missing session tokens'}`);
|
|
276
|
+
}
|
|
277
|
+
return body;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function fetchUser(supabaseUrl, anonKey, accessToken) {
|
|
281
|
+
const response = await fetch(`${supabaseUrl}/auth/v1/user`, {
|
|
282
|
+
method: 'GET',
|
|
283
|
+
headers: {
|
|
284
|
+
apikey: anonKey,
|
|
285
|
+
Authorization: `Bearer ${accessToken}`,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
const body = await readJsonResponse(response);
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
throw new Error(`User validation failed (${response.status}): ${body.error_description || body.error || body.msg || 'unknown error'}`);
|
|
291
|
+
}
|
|
292
|
+
return body;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function readJsonResponse(response) {
|
|
296
|
+
const text = await response.text();
|
|
297
|
+
if (!text) return {};
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(text);
|
|
300
|
+
} catch {
|
|
301
|
+
return { raw: text };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function openBrowser(url) {
|
|
306
|
+
const platform = os.platform();
|
|
307
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
308
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
309
|
+
try {
|
|
310
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
311
|
+
child.on('error', () => {
|
|
312
|
+
console.error('Could not open the browser automatically. Use the printed URL.');
|
|
313
|
+
});
|
|
314
|
+
child.unref();
|
|
315
|
+
} catch {
|
|
316
|
+
console.error('Could not open the browser automatically. Use the printed URL.');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function writeAuthEnv(filePath, updates) {
|
|
321
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
322
|
+
const nextUpdates = Object.fromEntries(
|
|
323
|
+
Object.entries(updates).filter(([, value]) => typeof value === 'string' && value.length > 0),
|
|
324
|
+
);
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
const existingLines = existsSync(filePath) ? readFileSync(filePath, 'utf8').split(/\r?\n/) : [];
|
|
327
|
+
const lines = existingLines.map((line) => {
|
|
328
|
+
const parsed = parseEnvLine(line);
|
|
329
|
+
if (!parsed || !(parsed.key in nextUpdates)) return line;
|
|
330
|
+
seen.add(parsed.key);
|
|
331
|
+
return `${parsed.key}=${formatEnvValue(nextUpdates[parsed.key])}`;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
for (const [key, value] of Object.entries(nextUpdates)) {
|
|
335
|
+
if (!seen.has(key)) lines.push(`${key}=${formatEnvValue(value)}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
writeFileSync(filePath, `${trimTrailingEmptyLines(lines).join('\n')}\n`, { mode: 0o600 });
|
|
339
|
+
try {
|
|
340
|
+
chmodSync(filePath, 0o600);
|
|
341
|
+
} catch {
|
|
342
|
+
// Best effort. Some filesystems do not support chmod.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function removeEnvKeys(filePath, keys) {
|
|
347
|
+
const remove = new Set(keys);
|
|
348
|
+
const lines = readFileSync(filePath, 'utf8')
|
|
349
|
+
.split(/\r?\n/)
|
|
350
|
+
.filter((line) => {
|
|
351
|
+
const parsed = parseEnvLine(line);
|
|
352
|
+
return !parsed || !remove.has(parsed.key);
|
|
353
|
+
});
|
|
354
|
+
writeFileSync(filePath, `${trimTrailingEmptyLines(lines).join('\n')}\n`, { mode: 0o600 });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readEnvFile(filePath) {
|
|
358
|
+
if (!existsSync(filePath)) return {};
|
|
359
|
+
const values = {};
|
|
360
|
+
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) {
|
|
361
|
+
const parsed = parseEnvLine(line);
|
|
362
|
+
if (parsed) values[parsed.key] = parsed.value;
|
|
363
|
+
}
|
|
364
|
+
return values;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseEnvLine(line) {
|
|
368
|
+
const trimmed = line.trim();
|
|
369
|
+
if (!trimmed || trimmed.startsWith('#')) return null;
|
|
370
|
+
|
|
371
|
+
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
|
|
372
|
+
const separator = withoutExport.indexOf('=');
|
|
373
|
+
if (separator <= 0) return null;
|
|
374
|
+
|
|
375
|
+
const key = withoutExport.slice(0, separator).trim();
|
|
376
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
|
|
377
|
+
|
|
378
|
+
let value = withoutExport.slice(separator + 1).trim();
|
|
379
|
+
const quote = value[0];
|
|
380
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
381
|
+
value = value.slice(1, -1);
|
|
382
|
+
} else {
|
|
383
|
+
value = value.replace(/\s+#.*$/, '').trim();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return { key, value };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function trimTrailingEmptyLines(lines) {
|
|
390
|
+
const copy = [...lines];
|
|
391
|
+
while (copy.length > 0 && copy[copy.length - 1] === '') copy.pop();
|
|
392
|
+
return copy;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function tokenExpiryText(token) {
|
|
396
|
+
const expiresAt = jwtExpiresAtMs(token);
|
|
397
|
+
if (!expiresAt) return '';
|
|
398
|
+
return `, expires ${new Date(expiresAt).toISOString()}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function jwtExpiresAtMs(token) {
|
|
402
|
+
const parts = String(token || '').split('.');
|
|
403
|
+
if (parts.length < 2) return null;
|
|
404
|
+
try {
|
|
405
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
406
|
+
const exp = Number(payload.exp);
|
|
407
|
+
return Number.isFinite(exp) ? exp * 1000 : null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatEnvValue(value) {
|
|
414
|
+
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
|
|
415
|
+
return JSON.stringify(value);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function base64Url(buffer) {
|
|
419
|
+
return Buffer.from(buffer).toString('base64url');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function normalizeUrl(value) {
|
|
423
|
+
return String(value || '').replace(/\/+$/, '');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function resolvePath(value) {
|
|
427
|
+
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function listen(server, port) {
|
|
431
|
+
return new Promise((resolve, reject) => {
|
|
432
|
+
server.once('error', reject);
|
|
433
|
+
server.listen(port, '127.0.0.1', () => {
|
|
434
|
+
server.off('error', reject);
|
|
435
|
+
resolve();
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function closeServer(server) {
|
|
441
|
+
return new Promise((resolve) => {
|
|
442
|
+
if (!server.listening) {
|
|
443
|
+
resolve();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
server.close(() => resolve());
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sendHtml(response, status, title, message) {
|
|
451
|
+
response.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
452
|
+
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>`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function escapeHtml(value) {
|
|
456
|
+
return String(value)
|
|
457
|
+
.replace(/&/g, '&')
|
|
458
|
+
.replace(/</g, '<')
|
|
459
|
+
.replace(/>/g, '>')
|
|
460
|
+
.replace(/"/g, '"')
|
|
461
|
+
.replace(/'/g, ''');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function printHelp() {
|
|
465
|
+
console.log(`Nado Language MCP browser auth
|
|
466
|
+
|
|
467
|
+
Usage:
|
|
468
|
+
nado-mcp login [--provider google|kakao|apple]
|
|
469
|
+
nado-mcp status
|
|
470
|
+
nado-mcp logout
|
|
471
|
+
|
|
472
|
+
Repo checkout aliases:
|
|
473
|
+
npm run mcp:nado:auth -- [login] [--provider google|kakao|apple]
|
|
474
|
+
npm run mcp:nado:auth -- status
|
|
475
|
+
npm run mcp:nado:auth -- logout
|
|
476
|
+
|
|
477
|
+
Options:
|
|
478
|
+
--provider <name> OAuth provider for login. Default: google
|
|
479
|
+
--auth-file <path> Auth env file to write. Default: OS user config in package installs, .env.mcp.local in repo checkouts
|
|
480
|
+
--port <number> Local callback port. Default: 0 (random)
|
|
481
|
+
--redirect-mode <mode> azure or local. Default: azure
|
|
482
|
+
--relay-url <url> Azure Static Web Apps relay URL. Default: ${DEFAULT_RELAY_URL}
|
|
483
|
+
--no-open Print the URL without opening a browser
|
|
484
|
+
--timeout-ms <number> Login wait timeout. Default: 300000
|
|
485
|
+
--supabase-url <url> Supabase project URL
|
|
486
|
+
--anon-key <key> Supabase anon key
|
|
487
|
+
|
|
488
|
+
Default mode uses the existing Azure Static Web Apps site as a zero-new-resource
|
|
489
|
+
OAuth relay. Supabase Auth must allow this redirect URL:
|
|
490
|
+
${DEFAULT_RELAY_URL}
|
|
491
|
+
|
|
492
|
+
The optional local mode requires Supabase Auth to allow:
|
|
493
|
+
http://127.0.0.1:*/callback
|
|
494
|
+
`);
|
|
495
|
+
}
|