@ainote/mcp 1.1.1 → 1.3.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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Credential persistence for the CLI.
3
+ *
4
+ * Primary: OS keychain via `cross-keychain` (macOS Keychain, libsecret,
5
+ * Windows Credential Manager). Loaded lazily via createRequire so
6
+ * import never fails on platforms without it.
7
+ * Fallback: ${XDG_CONFIG_HOME:-~/.config}/ainote/credentials.json with
8
+ * directory mode 0o700 and file mode 0o600.
9
+ *
10
+ * Schema (file):
11
+ * {
12
+ * "<apiUrlHost>": {
13
+ * "mcp_key": "...",
14
+ * "access_token": "at_...",
15
+ * "refresh_token": "rt_...",
16
+ * "expires_at": 1700000000000 // ms since epoch
17
+ * },
18
+ * ...
19
+ * }
20
+ *
21
+ * Each environment (host) is namespaced so multiple ainote deployments
22
+ * (prod / staging / localhost) can coexist on one machine.
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import os from 'node:os';
27
+ import url from 'node:url';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const require = createRequire(import.meta.url);
31
+
32
+ const SERVICE = '@ainote/cli';
33
+ const FIELDS = ['mcp_key', 'access_token', 'refresh_token', 'expires_at'];
34
+
35
+ let keychainModule = null;
36
+ let keychainProbed = false;
37
+
38
+ function getKeychain() {
39
+ if (keychainProbed) return keychainModule;
40
+ keychainProbed = true;
41
+ try {
42
+ keychainModule = require('cross-keychain');
43
+ } catch (err) {
44
+ process.stderr.write(`[ainote-mcp] cross-keychain unavailable, using file fallback: ${err.message}\n`);
45
+ keychainModule = null;
46
+ }
47
+ return keychainModule;
48
+ }
49
+
50
+ function getCredentialsDir() {
51
+ const xdg = process.env.XDG_CONFIG_HOME;
52
+ const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
53
+ return path.join(base, 'ainote');
54
+ }
55
+
56
+ function getCredentialsFile() {
57
+ return path.join(getCredentialsDir(), 'credentials.json');
58
+ }
59
+
60
+ function apiHost(apiUrl) {
61
+ try {
62
+ const parsed = new url.URL(apiUrl);
63
+ return parsed.host || apiUrl;
64
+ } catch {
65
+ return apiUrl;
66
+ }
67
+ }
68
+
69
+ function accountFor(apiUrl, field) {
70
+ return `${apiHost(apiUrl)}:${field}`;
71
+ }
72
+
73
+ function readFileStore() {
74
+ const file = getCredentialsFile();
75
+ try {
76
+ const raw = fs.readFileSync(file, 'utf8');
77
+ const parsed = JSON.parse(raw);
78
+ return parsed && typeof parsed === 'object' ? parsed : {};
79
+ } catch (err) {
80
+ if (err.code !== 'ENOENT') {
81
+ process.stderr.write(`[ainote-mcp] credentials.json read failed: ${err.message}\n`);
82
+ }
83
+ return {};
84
+ }
85
+ }
86
+
87
+ function writeFileStore(store) {
88
+ const dir = getCredentialsDir();
89
+ try {
90
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
91
+ } catch (err) {
92
+ if (err.code !== 'EEXIST') throw err;
93
+ }
94
+ // Try to tighten directory perms even if mkdir didn't get to chmod (older Node).
95
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
96
+
97
+ const file = getCredentialsFile();
98
+ const tmp = `${file}.tmp`;
99
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
100
+ try { fs.chmodSync(tmp, 0o600); } catch { /* best effort */ }
101
+ fs.renameSync(tmp, file);
102
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
103
+ }
104
+
105
+ async function keychainGet(apiUrl, field) {
106
+ const kc = getKeychain();
107
+ if (!kc || typeof kc.getPassword !== 'function') return null;
108
+ try {
109
+ const value = await kc.getPassword(SERVICE, accountFor(apiUrl, field));
110
+ return value || null;
111
+ } catch (err) {
112
+ process.stderr.write(`[ainote-mcp] keychain read failed (${field}): ${err.message}\n`);
113
+ return null;
114
+ }
115
+ }
116
+
117
+ async function keychainSet(apiUrl, field, value) {
118
+ const kc = getKeychain();
119
+ if (!kc || typeof kc.setPassword !== 'function') return false;
120
+ try {
121
+ if (value === null || value === undefined || value === '') {
122
+ if (typeof kc.deletePassword === 'function') {
123
+ await kc.deletePassword(SERVICE, accountFor(apiUrl, field));
124
+ }
125
+ return true;
126
+ }
127
+ await kc.setPassword(SERVICE, accountFor(apiUrl, field), String(value));
128
+ return true;
129
+ } catch (err) {
130
+ process.stderr.write(`[ainote-mcp] keychain write failed (${field}): ${err.message}\n`);
131
+ return false;
132
+ }
133
+ }
134
+
135
+ async function keychainDelete(apiUrl, field) {
136
+ const kc = getKeychain();
137
+ if (!kc || typeof kc.deletePassword !== 'function') return false;
138
+ try {
139
+ await kc.deletePassword(SERVICE, accountFor(apiUrl, field));
140
+ return true;
141
+ } catch (err) {
142
+ if (err && /not found/i.test(err.message)) return true;
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Read credentials for the given API URL. Returns `null` if nothing is stored.
149
+ *
150
+ * Tries keychain first for each field; falls back to file. If both yield
151
+ * nothing, returns null. Mixed sources (keychain mcp_key + file refresh) are
152
+ * permitted and merged.
153
+ *
154
+ * @returns {Promise<null | {mcpKey?: string, accessToken?: string,
155
+ * refreshToken?: string, expiresAt?: number, source: 'keychain'|'file'|'mixed'}>}
156
+ */
157
+ export async function getCredentials(apiUrl) {
158
+ const host = apiHost(apiUrl);
159
+
160
+ const fileStore = readFileStore();
161
+ const fileEntry = fileStore[host] || {};
162
+
163
+ const kc = getKeychain();
164
+ const result = {};
165
+ let usedKeychain = false;
166
+ let usedFile = false;
167
+
168
+ for (const field of FIELDS) {
169
+ let value = null;
170
+ if (kc) {
171
+ value = await keychainGet(apiUrl, field);
172
+ if (value !== null && value !== undefined) usedKeychain = true;
173
+ }
174
+ if (value === null || value === undefined) {
175
+ if (fileEntry[field] !== undefined && fileEntry[field] !== null) {
176
+ value = fileEntry[field];
177
+ usedFile = true;
178
+ }
179
+ }
180
+ if (value !== null && value !== undefined && value !== '') {
181
+ result[field] = value;
182
+ }
183
+ }
184
+
185
+ if (Object.keys(result).length === 0) return null;
186
+
187
+ const out = {
188
+ mcpKey: result.mcp_key,
189
+ accessToken: result.access_token,
190
+ refreshToken: result.refresh_token,
191
+ expiresAt: result.expires_at ? Number(result.expires_at) : undefined,
192
+ source: usedKeychain && usedFile ? 'mixed' : usedKeychain ? 'keychain' : 'file'
193
+ };
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * Persist credentials. Writes to keychain when available; ALWAYS also writes
199
+ * a minimal file shadow when keychain write fails for any field, so the user
200
+ * can still authenticate after a partial-failure scenario.
201
+ *
202
+ * @param {string} apiUrl
203
+ * @param {{mcpKey?: string, accessToken?: string, refreshToken?: string, expiresAt?: number|string}} creds
204
+ * @returns {Promise<{storage: 'keychain'|'file'|'mixed', file?: string}>}
205
+ */
206
+ export async function setCredentials(apiUrl, creds) {
207
+ const host = apiHost(apiUrl);
208
+ const mapping = {
209
+ mcp_key: creds.mcpKey,
210
+ access_token: creds.accessToken,
211
+ refresh_token: creds.refreshToken,
212
+ expires_at: creds.expiresAt === undefined ? undefined : String(creds.expiresAt)
213
+ };
214
+
215
+ let keychainOk = true;
216
+ let anyKeychain = false;
217
+
218
+ for (const [field, value] of Object.entries(mapping)) {
219
+ if (value === undefined) continue;
220
+ const ok = await keychainSet(apiUrl, field, value);
221
+ if (ok) anyKeychain = true;
222
+ else keychainOk = false;
223
+ }
224
+
225
+ let fileWritten = false;
226
+ if (!anyKeychain || !keychainOk) {
227
+ // Write or merge file store so credentials survive even if some keychain
228
+ // writes failed.
229
+ const store = readFileStore();
230
+ const entry = { ...(store[host] || {}) };
231
+ for (const [field, value] of Object.entries(mapping)) {
232
+ if (value === undefined) continue;
233
+ if (value === null || value === '') delete entry[field];
234
+ else entry[field] = field === 'expires_at' ? Number(value) : value;
235
+ }
236
+ store[host] = entry;
237
+ writeFileStore(store);
238
+ fileWritten = true;
239
+ }
240
+
241
+ let storage;
242
+ if (anyKeychain && !fileWritten) storage = 'keychain';
243
+ else if (!anyKeychain && fileWritten) storage = 'file';
244
+ else storage = 'mixed';
245
+
246
+ return {
247
+ storage,
248
+ file: fileWritten ? getCredentialsFile() : undefined
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Remove credentials for the given API URL from both keychain and file.
254
+ * Best-effort: errors are swallowed and the caller can assume cleared state.
255
+ */
256
+ export async function clearCredentials(apiUrl) {
257
+ const host = apiHost(apiUrl);
258
+
259
+ for (const field of FIELDS) {
260
+ await keychainDelete(apiUrl, field);
261
+ }
262
+
263
+ try {
264
+ const store = readFileStore();
265
+ if (store[host]) {
266
+ delete store[host];
267
+ if (Object.keys(store).length === 0) {
268
+ try { fs.unlinkSync(getCredentialsFile()); } catch { /* ignore */ }
269
+ } else {
270
+ writeFileStore(store);
271
+ }
272
+ }
273
+ } catch (err) {
274
+ process.stderr.write(`[ainote-mcp] file credentials clear failed: ${err.message}\n`);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Internal helper for diagnostics. Not part of public API.
280
+ */
281
+ export function _credentialsLocations() {
282
+ return {
283
+ keychainService: SERVICE,
284
+ file: getCredentialsFile()
285
+ };
286
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `ainote-mcp login` — interactive device-authorization grant flow.
3
+ *
4
+ * UX:
5
+ * 1. Request device code from server.
6
+ * 2. Display the user_code in an ASCII box.
7
+ * 3. Print verification URL and try to open it in a browser (unless --no-browser).
8
+ * 4. Spin while polling for token.
9
+ * 5. On success, persist to keychain/file.
10
+ *
11
+ * Flags:
12
+ * --scope=mcp,read,write Comma-separated scope list (default: mcp,read,write).
13
+ * --no-browser Don't auto-open the verification URL.
14
+ * --client-name=<name> Override client_name shown on the consent screen.
15
+ *
16
+ * SIGINT cancels the in-flight authorization (best effort) and exits 1.
17
+ */
18
+ import process from 'node:process';
19
+ import os from 'node:os';
20
+ import {
21
+ requestDeviceCode,
22
+ pollForToken,
23
+ cancel,
24
+ DeviceAccessDeniedError,
25
+ DeviceExpiredError,
26
+ DevicePkceError,
27
+ DeviceNetworkError
28
+ } from '../auth/device-flow.js';
29
+ import { setCredentials, _credentialsLocations } from '../auth/token-store.js';
30
+
31
+ const DEFAULT_API_URL = 'https://ainote-5muq.onrender.com';
32
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
33
+
34
+ function parseArgs(argv) {
35
+ const args = {
36
+ scope: 'mcp,read,write',
37
+ noBrowser: false,
38
+ clientName: `ainote-mcp CLI (${os.hostname()})`
39
+ };
40
+ for (const arg of argv) {
41
+ if (arg === '--no-browser') {
42
+ args.noBrowser = true;
43
+ } else if (arg.startsWith('--scope=')) {
44
+ args.scope = arg.slice('--scope='.length);
45
+ } else if (arg === '--scope') {
46
+ // next-token form is not supported in this minimal parser
47
+ args.scope = args.scope;
48
+ } else if (arg.startsWith('--client-name=')) {
49
+ args.clientName = arg.slice('--client-name='.length);
50
+ }
51
+ }
52
+ return args;
53
+ }
54
+
55
+ function drawUserCodeBox(userCode) {
56
+ const padded = ` ${userCode} `;
57
+ const width = padded.length;
58
+ const top = `┌${'─'.repeat(width)}┐`;
59
+ const mid = `│${padded}│`;
60
+ const bot = `└${'─'.repeat(width)}┘`;
61
+ return `${top}\n${mid}\n${bot}`;
62
+ }
63
+
64
+ class Spinner {
65
+ constructor() {
66
+ this.frame = 0;
67
+ this.suffix = '';
68
+ this.timer = null;
69
+ this.active = false;
70
+ }
71
+
72
+ start(initialSuffix = '') {
73
+ if (this.active) return;
74
+ this.active = true;
75
+ this.suffix = initialSuffix;
76
+ this.timer = setInterval(() => this._render(), 100);
77
+ this._render();
78
+ }
79
+
80
+ update(suffix) {
81
+ this.suffix = suffix;
82
+ }
83
+
84
+ _render() {
85
+ const frame = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
86
+ this.frame += 1;
87
+ if (process.stderr.isTTY) {
88
+ process.stderr.write(`\r${frame} ${this.suffix}\x1b[K`);
89
+ }
90
+ }
91
+
92
+ stop(finalLine) {
93
+ if (!this.active) return;
94
+ this.active = false;
95
+ if (this.timer) {
96
+ clearInterval(this.timer);
97
+ this.timer = null;
98
+ }
99
+ if (process.stderr.isTTY) {
100
+ process.stderr.write('\r\x1b[K');
101
+ }
102
+ if (finalLine) {
103
+ process.stderr.write(`${finalLine}\n`);
104
+ }
105
+ }
106
+ }
107
+
108
+ async function tryOpenBrowser(uri) {
109
+ try {
110
+ const mod = await import('open');
111
+ const open = mod.default || mod;
112
+ await open(uri);
113
+ return true;
114
+ } catch (err) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ export async function run(argv = process.argv.slice(3)) {
120
+ const apiUrl = (process.env.AINOTE_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
121
+ const args = parseArgs(argv);
122
+ const scopes = args.scope.split(',').map((s) => s.trim()).filter(Boolean);
123
+
124
+ console.log('');
125
+ console.log(` AI Note MCP — Login`);
126
+ console.log(` API: ${apiUrl}`);
127
+ console.log('');
128
+
129
+ let device;
130
+ try {
131
+ device = await requestDeviceCode({
132
+ apiUrl,
133
+ clientName: args.clientName,
134
+ scopes
135
+ });
136
+ } catch (err) {
137
+ if (err instanceof DeviceNetworkError) {
138
+ console.error(` Network error: ${err.message}`);
139
+ } else {
140
+ console.error(` Failed to start login: ${err.message}`);
141
+ }
142
+ process.exit(1);
143
+ return;
144
+ }
145
+
146
+ console.log(` Visit the URL below and enter the code:`);
147
+ console.log('');
148
+ console.log(drawUserCodeBox(device.user_code));
149
+ console.log('');
150
+ console.log(` ${device.verification_uri}`);
151
+ if (device.verification_uri_complete && device.verification_uri_complete !== device.verification_uri) {
152
+ console.log(` (direct link: ${device.verification_uri_complete})`);
153
+ }
154
+ console.log('');
155
+
156
+ if (!args.noBrowser) {
157
+ const target = device.verification_uri_complete || device.verification_uri;
158
+ const opened = await tryOpenBrowser(target);
159
+ if (opened) {
160
+ console.log(' Opening your browser…');
161
+ } else {
162
+ console.log(' (Could not open browser automatically — please open the URL above.)');
163
+ }
164
+ console.log('');
165
+ }
166
+
167
+ const spinner = new Spinner();
168
+ spinner.start(`Waiting for authorization… (0s elapsed, next poll in ${device.interval}s)`);
169
+
170
+ let cancelled = false;
171
+ const sigintHandler = async () => {
172
+ if (cancelled) return;
173
+ cancelled = true;
174
+ spinner.stop('Cancelled.');
175
+ await cancel({ apiUrl, deviceCode: device.device_code });
176
+ process.exit(1);
177
+ };
178
+ process.once('SIGINT', sigintHandler);
179
+
180
+ try {
181
+ const tokens = await pollForToken({
182
+ apiUrl,
183
+ deviceCode: device.device_code,
184
+ codeVerifier: device.code_verifier,
185
+ interval: device.interval,
186
+ expiresIn: device.expires_in,
187
+ onTick: ({ elapsed, interval }) => {
188
+ spinner.update(`Waiting for authorization… (${elapsed}s elapsed, next poll in ${interval}s)`);
189
+ }
190
+ });
191
+
192
+ spinner.stop();
193
+ process.removeListener('SIGINT', sigintHandler);
194
+
195
+ const expiresAt = tokens.expires_in
196
+ ? Date.now() + Number(tokens.expires_in) * 1000
197
+ : undefined;
198
+
199
+ const persisted = await setCredentials(apiUrl, {
200
+ mcpKey: tokens.mcp_key,
201
+ accessToken: tokens.access_token,
202
+ refreshToken: tokens.refresh_token,
203
+ expiresAt
204
+ });
205
+
206
+ const email = tokens.user?.email;
207
+ const locationDesc = persisted.storage === 'keychain'
208
+ ? 'OS keychain'
209
+ : persisted.storage === 'mixed'
210
+ ? `OS keychain + ${persisted.file}`
211
+ : persisted.file || _credentialsLocations().file;
212
+
213
+ console.log('');
214
+ if (email) {
215
+ console.log(` ✓ Logged in as ${email}.`);
216
+ } else {
217
+ console.log(` ✓ Logged in.`);
218
+ }
219
+ console.log(` Credentials saved to ${locationDesc}.`);
220
+ console.log('');
221
+ } catch (err) {
222
+ spinner.stop();
223
+ process.removeListener('SIGINT', sigintHandler);
224
+
225
+ if (err instanceof DeviceAccessDeniedError) {
226
+ console.error(' Access denied. The authorization request was rejected.');
227
+ } else if (err instanceof DeviceExpiredError) {
228
+ console.error(' Code expired. Please run `ainote-mcp login` again.');
229
+ } else if (err instanceof DevicePkceError) {
230
+ console.error(' Authentication failed (PKCE mismatch). Please retry.');
231
+ } else if (err instanceof DeviceNetworkError) {
232
+ console.error(` Network error: ${err.message}`);
233
+ } else {
234
+ console.error(` Login failed: ${err.message}`);
235
+ }
236
+ process.exit(1);
237
+ }
238
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `ainote-mcp logout` — revoke server-side tokens and clear local credentials.
3
+ */
4
+ import process from 'node:process';
5
+ import axios from 'axios';
6
+ import { getCredentials, clearCredentials } from '../auth/token-store.js';
7
+
8
+ const DEFAULT_API_URL = 'https://ainote-5muq.onrender.com';
9
+
10
+ export async function run() {
11
+ const apiUrl = (process.env.AINOTE_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
12
+
13
+ const creds = await getCredentials(apiUrl);
14
+
15
+ if (!creds) {
16
+ console.log(' No credentials found. Already logged out.');
17
+ return;
18
+ }
19
+
20
+ if (creds.refreshToken) {
21
+ try {
22
+ await axios.post(
23
+ `${apiUrl}/api/auth/token/revoke`,
24
+ { refresh_token: creds.refreshToken },
25
+ {
26
+ headers: { 'Content-Type': 'application/json' },
27
+ timeout: 10000,
28
+ validateStatus: () => true
29
+ }
30
+ );
31
+ } catch (err) {
32
+ // Server-side revoke is best-effort. Continue with local clear.
33
+ process.stderr.write(`[ainote-mcp] Server revoke failed (continuing): ${err.message}\n`);
34
+ }
35
+ }
36
+
37
+ await clearCredentials(apiUrl);
38
+ console.log(' Logged out.');
39
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `ainote-mcp whoami` — show the currently authenticated user.
3
+ *
4
+ * Strategy:
5
+ * 1. Read credentials from the token store.
6
+ * 2. If we have an access_token, hit GET /api/users/me with Bearer auth and
7
+ * print the response.
8
+ * 3. If the API call fails (404, 401, network) but we have a stored mcp_key,
9
+ * print a minimal local-only summary so the user at least sees that
10
+ * credentials exist for this host.
11
+ */
12
+ import process from 'node:process';
13
+ import axios from 'axios';
14
+ import { getCredentials } from '../auth/token-store.js';
15
+
16
+ const DEFAULT_API_URL = 'https://ainote-5muq.onrender.com';
17
+
18
+ function summarizeUser(user) {
19
+ if (!user || typeof user !== 'object') return null;
20
+ const parts = [];
21
+ if (user.email) parts.push(user.email);
22
+ else if (user.name) parts.push(user.name);
23
+ else if (user.id) parts.push(`user #${user.id}`);
24
+ return parts.join(' ');
25
+ }
26
+
27
+ export async function run() {
28
+ const apiUrl = (process.env.AINOTE_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
29
+ const creds = await getCredentials(apiUrl);
30
+
31
+ if (!creds) {
32
+ console.error(' Not logged in. Run `ainote-mcp login`.');
33
+ process.exit(1);
34
+ return;
35
+ }
36
+
37
+ let identity = null;
38
+
39
+ if (creds.accessToken) {
40
+ try {
41
+ const response = await axios.get(`${apiUrl}/api/users/me`, {
42
+ headers: {
43
+ Authorization: `Bearer ${creds.accessToken}`,
44
+ Accept: 'application/json'
45
+ },
46
+ timeout: 15000,
47
+ validateStatus: () => true
48
+ });
49
+ if (response.status >= 200 && response.status < 300) {
50
+ const body = response.data || {};
51
+ identity = summarizeUser(body.user || body.data || body) || summarizeUser(body);
52
+ }
53
+ } catch {
54
+ // fall through to local-only fallback below
55
+ }
56
+ }
57
+
58
+ if (identity) {
59
+ console.log(` Logged in as ${identity}`);
60
+ } else {
61
+ // Local-only fallback: report what we have.
62
+ const host = (() => {
63
+ try { return new URL(apiUrl).host; } catch { return apiUrl; }
64
+ })();
65
+ console.log(` Credentials present for ${host}`);
66
+ if (creds.mcpKey) console.log(` MCP key: ${creds.mcpKey.slice(0, 8)}…`);
67
+ if (creds.expiresAt) {
68
+ const remaining = Math.floor((creds.expiresAt - Date.now()) / 1000);
69
+ if (remaining > 0) console.log(` Access token expires in ~${remaining}s`);
70
+ else console.log(` Access token expired (refresh on next call).`);
71
+ }
72
+ if (!creds.accessToken && !creds.mcpKey) {
73
+ console.error(' Could not verify identity. Try `ainote-mcp login` again.');
74
+ process.exit(1);
75
+ }
76
+ }
77
+ }
@@ -39,6 +39,14 @@ const parseList = (value) => {
39
39
  .filter((item) => item.length > 0);
40
40
  };
41
41
 
42
+ /**
43
+ * Synchronous config snapshot. Reads env vars only — used by the JSON-RPC
44
+ * stdio entry path that cannot await.
45
+ *
46
+ * Note: stored CLI credentials (mcp_key from `ainote-mcp login`) are loaded
47
+ * asynchronously by `getConfigAsync()` below. Callers that can await should
48
+ * prefer the async form.
49
+ */
42
50
  export function getConfig() {
43
51
  const features = {
44
52
  chatgptSupport: parseBoolean(process.env.AINOTE_ENABLE_CHATGPT_SUPPORT, true),
@@ -58,10 +66,41 @@ export function getConfig() {
58
66
  allowedOrigins: parseList(process.env.AINOTE_MCP_ALLOWED_ORIGINS),
59
67
  allowedHosts: parseList(process.env.AINOTE_MCP_ALLOWED_HOSTS)
60
68
  },
61
- features
69
+ features,
70
+ requiresLogin: !process.env.AINOTE_API_KEY
62
71
  };
63
72
  }
64
73
 
74
+ /**
75
+ * Async config that consults the local token store for an mcp_key when the
76
+ * environment variable is unset. Used by the stdio entry path that runs
77
+ * `await getConfigAsync()` at startup.
78
+ */
79
+ export async function getConfigAsync() {
80
+ const base = getConfig();
81
+
82
+ if (base.api.key) {
83
+ return { ...base, requiresLogin: false };
84
+ }
85
+
86
+ try {
87
+ const { getCredentials } = await import('../auth/token-store.js');
88
+ const creds = await getCredentials(base.api.url);
89
+ if (creds && creds.mcpKey) {
90
+ return {
91
+ ...base,
92
+ api: { ...base.api, key: creds.mcpKey },
93
+ requiresLogin: false,
94
+ api_key_source: 'token-store'
95
+ };
96
+ }
97
+ } catch (err) {
98
+ process.stderr.write(`[ainote-mcp] token store unavailable: ${err.message}\n`);
99
+ }
100
+
101
+ return { ...base, requiresLogin: true };
102
+ }
103
+
65
104
  export function getFeatureFlags() {
66
105
  return getConfig().features;
67
106
  }