@ainote/mcp 1.3.2 → 1.3.3

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.
@@ -1,274 +1,285 @@
1
- /**
2
- * RFC 8628 Device Authorization Grant — CLI client.
3
- *
4
- * Talks to the ainote backend endpoints:
5
- * POST /api/auth/cli/device/start
6
- * POST /api/auth/cli/device/poll
7
- * POST /api/auth/cli/device/cancel
8
- *
9
- * Per the plan, the server returns 200 + JSON body with a `status` field for
10
- * polling state (authorization_pending, slow_down, access_denied, expired_token,
11
- * invalid_grant) and a token payload (no `status`) on success. We never use
12
- * HTTP error codes for polling state.
13
- */
14
- import crypto from 'node:crypto';
15
- import axios from 'axios';
16
-
17
- export class DeviceFlowError extends Error {
18
- constructor(message, code) {
19
- super(message);
20
- this.name = 'DeviceFlowError';
21
- this.code = code;
22
- }
23
- }
24
-
25
- export class DeviceAccessDeniedError extends DeviceFlowError {
26
- constructor(message = 'Authorization was denied by the user.') {
27
- super(message, 'access_denied');
28
- this.name = 'DeviceAccessDeniedError';
29
- }
30
- }
31
-
32
- export class DeviceExpiredError extends DeviceFlowError {
33
- constructor(message = 'Device code expired before authorization completed.') {
34
- super(message, 'expired_token');
35
- this.name = 'DeviceExpiredError';
36
- }
37
- }
38
-
39
- export class DevicePkceError extends DeviceFlowError {
40
- constructor(message = 'PKCE verification failed (invalid_grant).') {
41
- super(message, 'invalid_grant');
42
- this.name = 'DevicePkceError';
43
- }
44
- }
45
-
46
- export class DeviceNetworkError extends DeviceFlowError {
47
- constructor(message, cause) {
48
- super(message, 'network_error');
49
- this.name = 'DeviceNetworkError';
50
- this.cause = cause;
51
- }
52
- }
53
-
54
- function generatePkce() {
55
- const verifier = crypto.randomBytes(32).toString('base64url');
56
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
57
- return { verifier, challenge };
58
- }
59
-
60
- function sleep(seconds) {
61
- return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
62
- }
63
-
64
- /**
65
- * Request a device + user code from the server.
66
- *
67
- * @param {Object} args
68
- * @param {string} args.apiUrl - API base URL (no trailing slash).
69
- * @param {string} args.clientName - Friendly client identifier (e.g. "ainote-mcp CLI").
70
- * @param {string[]|string} [args.scopes] - Requested scopes.
71
- * @returns {Promise<{device_code: string, user_code: string, verification_uri: string,
72
- * verification_uri_complete?: string, expires_in: number, interval: number,
73
- * code_verifier: string}>}
74
- */
75
- export async function requestDeviceCode({ apiUrl, clientName, scopes }) {
76
- const { verifier, challenge } = generatePkce();
77
-
78
- const body = {
79
- client_name: clientName,
80
- scopes,
81
- code_challenge: challenge,
82
- code_challenge_method: 'S256'
83
- };
84
-
85
- let response;
86
- try {
87
- response = await axios.post(`${apiUrl}/api/auth/cli/device/start`, body, {
88
- headers: { 'Content-Type': 'application/json' },
89
- timeout: 30000
90
- });
91
- } catch (error) {
92
- if (error.response) {
93
- const data = error.response.data;
94
- const msg = (data && (data.error_description || data.error || data.message)) ||
95
- `Device authorization request failed (HTTP ${error.response.status}).`;
96
- throw new DeviceFlowError(msg, 'start_failed');
97
- }
98
- throw new DeviceNetworkError(`Could not reach ${apiUrl}: ${error.message}`, error);
99
- }
100
-
101
- // The ainote backend wraps API responses in a standard envelope
102
- // ({ success, status:'success', data: <payload>, meta }) — see render_success.
103
- // RFC 8628 device endpoints live behind that envelope, so unwrap before reading.
104
- const raw = response.data || {};
105
- const data = (raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object')
106
- ? raw.data
107
- : raw;
108
-
109
- if (!data.device_code || !data.user_code || !data.verification_uri) {
110
- throw new DeviceFlowError('Malformed response from device/start: missing required fields.', 'invalid_response');
111
- }
112
-
113
- return {
114
- device_code: data.device_code,
115
- user_code: data.user_code,
116
- verification_uri: data.verification_uri,
117
- verification_uri_complete: data.verification_uri_complete,
118
- expires_in: Number(data.expires_in) || 900,
119
- interval: Number(data.interval) || 5,
120
- code_verifier: verifier
121
- };
122
- }
123
-
124
- /**
125
- * Poll the device/poll endpoint until terminal state.
126
- *
127
- * The server returns HTTP 200 with a body whose shape signals state:
128
- * - `{ error: 'authorization_pending' }` → keep waiting
129
- * - `{ error: 'slow_down', interval?: n }` → bump interval by 5s and wait
130
- * - `{ error: 'access_denied' }` → throw DeviceAccessDeniedError
131
- * - `{ error: 'expired_token' }` → throw DeviceExpiredError
132
- * - `{ error: 'invalid_grant' }` → throw DevicePkceError
133
- * - `{ access_token, ... }` → success, resolve token bundle
134
- *
135
- * The ainote backend wraps these in `{ success, status:'success', data: <body>, meta }`,
136
- * so we unwrap `response.data.data` when present.
137
- *
138
- * If the server ever does return non-2xx, we attempt to map the body shape
139
- * the same way (RFC 8628 §3.5 permits 400 + error JSON, but our backend per
140
- * the plan returns 200; we tolerate both).
141
- *
142
- * @param {Object} args
143
- * @param {string} args.apiUrl
144
- * @param {string} args.deviceCode
145
- * @param {string} args.codeVerifier - PKCE verifier captured during start.
146
- * @param {number} args.interval - Initial poll interval in seconds.
147
- * @param {number} args.expiresIn - Total lifetime in seconds.
148
- * @param {(info: {elapsed: number, interval: number}) => void} [args.onTick]
149
- * @returns {Promise<{access_token: string, refresh_token?: string, mcp_key?: string,
150
- * expires_in?: number, user?: Object, scope?: string}>}
151
- */
152
- export async function pollForToken({
153
- apiUrl,
154
- deviceCode,
155
- codeVerifier,
156
- interval,
157
- expiresIn,
158
- onTick
159
- }) {
160
- let currentInterval = Math.max(1, Number(interval) || 5);
161
- const totalBudget = Math.max(1, Number(expiresIn) || 900);
162
- const startedAt = Date.now();
163
-
164
- while (true) {
165
- const elapsedBeforeWait = Math.floor((Date.now() - startedAt) / 1000);
166
-
167
- if (elapsedBeforeWait >= totalBudget) {
168
- throw new DeviceExpiredError();
169
- }
170
-
171
- if (typeof onTick === 'function') {
172
- try {
173
- onTick({ elapsed: elapsedBeforeWait, interval: currentInterval });
174
- } catch { /* swallow UX callback errors */ }
175
- }
176
-
177
- await sleep(currentInterval);
178
-
179
- const elapsedAfterWait = Math.floor((Date.now() - startedAt) / 1000);
180
- if (elapsedAfterWait >= totalBudget) {
181
- throw new DeviceExpiredError();
182
- }
183
-
184
- let body;
185
- try {
186
- const response = await axios.post(
187
- `${apiUrl}/api/auth/cli/device/poll`,
188
- { device_code: deviceCode, code_verifier: codeVerifier },
189
- {
190
- headers: { 'Content-Type': 'application/json' },
191
- timeout: 30000,
192
- // Per the plan the server returns 200 with status. Don't throw on
193
- // 4xx so we can read the body uniformly.
194
- validateStatus: (s) => s >= 200 && s < 500
195
- }
196
- );
197
- const raw = response.data || {};
198
- // Unwrap ainote API envelope ({ success, data: <payload>, meta }) if present.
199
- body = (raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object')
200
- ? raw.data
201
- : raw;
202
- } catch (error) {
203
- // Network/5xx — transient. Wait and retry rather than fail the whole flow.
204
- // But if we're out of budget, give up.
205
- if (Math.floor((Date.now() - startedAt) / 1000) >= totalBudget) {
206
- throw new DeviceExpiredError();
207
- }
208
- continue;
209
- }
210
-
211
- // RFC 8628 §3.5 + ainote service: polling state lives in `error`, success has no `error`.
212
- // Also accept legacy `status` field for forward-compat with any wrapper changes.
213
- const pollState = body.error || body.status;
214
-
215
- if (!pollState && body.access_token) {
216
- return {
217
- access_token: body.access_token,
218
- refresh_token: body.refresh_token,
219
- mcp_key: body.mcp_key,
220
- expires_in: body.expires_in,
221
- scope: body.scope,
222
- user: body.user
223
- };
224
- }
225
-
226
- switch (pollState) {
227
- case 'authorization_pending':
228
- continue;
229
- case 'slow_down': {
230
- const serverInterval = Number(body.interval);
231
- if (Number.isFinite(serverInterval) && serverInterval > currentInterval) {
232
- currentInterval = serverInterval;
233
- } else {
234
- currentInterval += 5;
235
- }
236
- continue;
237
- }
238
- case 'access_denied':
239
- throw new DeviceAccessDeniedError(body.error_description);
240
- case 'expired_token':
241
- throw new DeviceExpiredError(body.error_description);
242
- case 'invalid_grant':
243
- throw new DevicePkceError(body.error_description);
244
- default: {
245
- const message = body.error_description ||
246
- body.error ||
247
- body.message ||
248
- `Unexpected poll response: ${JSON.stringify(body).slice(0, 200)}`;
249
- throw new DeviceFlowError(message, pollState || 'unknown');
250
- }
251
- }
252
- }
253
- }
254
-
255
- /**
256
- * Best-effort cancellation. Failures are swallowed — the server will GC
257
- * the pending request via TTL anyway.
258
- */
259
- export async function cancel({ apiUrl, deviceCode }) {
260
- if (!deviceCode) return;
261
- try {
262
- await axios.post(
263
- `${apiUrl}/api/auth/cli/device/cancel`,
264
- { device_code: deviceCode },
265
- {
266
- headers: { 'Content-Type': 'application/json' },
267
- timeout: 5000,
268
- validateStatus: () => true
269
- }
270
- );
271
- } catch {
272
- // intentionally ignored
273
- }
274
- }
1
+ /**
2
+ * RFC 8628 Device Authorization Grant — CLI client.
3
+ *
4
+ * Talks to the ainote backend endpoints:
5
+ * POST /api/auth/cli/device/start
6
+ * POST /api/auth/cli/device/poll
7
+ * POST /api/auth/cli/device/cancel
8
+ *
9
+ * Per the plan, the server returns 200 + JSON body with a `status` field for
10
+ * polling state (authorization_pending, slow_down, access_denied, expired_token,
11
+ * invalid_grant) and a token payload (no `status`) on success. We never use
12
+ * HTTP error codes for polling state.
13
+ */
14
+ import crypto from 'node:crypto';
15
+ import axios from 'axios';
16
+
17
+ export class DeviceFlowError extends Error {
18
+ constructor(message, code) {
19
+ super(message);
20
+ this.name = 'DeviceFlowError';
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ export class DeviceAccessDeniedError extends DeviceFlowError {
26
+ constructor(message = 'Authorization was denied by the user.') {
27
+ super(message, 'access_denied');
28
+ this.name = 'DeviceAccessDeniedError';
29
+ }
30
+ }
31
+
32
+ export class DeviceExpiredError extends DeviceFlowError {
33
+ constructor(message = 'Device code expired before authorization completed.') {
34
+ super(message, 'expired_token');
35
+ this.name = 'DeviceExpiredError';
36
+ }
37
+ }
38
+
39
+ export class DevicePkceError extends DeviceFlowError {
40
+ constructor(message = 'PKCE verification failed (invalid_grant).') {
41
+ super(message, 'invalid_grant');
42
+ this.name = 'DevicePkceError';
43
+ }
44
+ }
45
+
46
+ export class DeviceNetworkError extends DeviceFlowError {
47
+ constructor(message, cause) {
48
+ super(message, 'network_error');
49
+ this.name = 'DeviceNetworkError';
50
+ this.cause = cause;
51
+ }
52
+ }
53
+
54
+ function generatePkce() {
55
+ const verifier = crypto.randomBytes(32).toString('base64url');
56
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
57
+ return { verifier, challenge };
58
+ }
59
+
60
+ function sleep(seconds) {
61
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
62
+ }
63
+
64
+ /**
65
+ * Request a device + user code from the server.
66
+ *
67
+ * @param {Object} args
68
+ * @param {string} args.apiUrl - API base URL (no trailing slash).
69
+ * @param {string} args.clientName - Friendly client identifier (e.g. "ainote-mcp CLI").
70
+ * @param {string[]|string} [args.scopes] - Requested scopes.
71
+ * @returns {Promise<{device_code: string, user_code: string, verification_uri: string,
72
+ * verification_uri_complete?: string, expires_in: number, interval: number,
73
+ * code_verifier: string}>}
74
+ */
75
+ export async function requestDeviceCode({ apiUrl, clientName, scopes }) {
76
+ const { verifier, challenge } = generatePkce();
77
+
78
+ const body = {
79
+ client_name: clientName,
80
+ scopes,
81
+ code_challenge: challenge,
82
+ code_challenge_method: 'S256'
83
+ };
84
+
85
+ let response;
86
+ try {
87
+ response = await axios.post(`${apiUrl}/api/auth/cli/device/start`, body, {
88
+ headers: { 'Content-Type': 'application/json' },
89
+ timeout: 30000
90
+ });
91
+ } catch (error) {
92
+ if (error.response) {
93
+ const data = error.response.data;
94
+ const msg = (data && (data.error_description || data.error || data.message)) ||
95
+ `Device authorization request failed (HTTP ${error.response.status}).`;
96
+ throw new DeviceFlowError(msg, 'start_failed');
97
+ }
98
+ throw new DeviceNetworkError(`Could not reach ${apiUrl}: ${error.message}`, error);
99
+ }
100
+
101
+ // The ainote backend wraps API responses in a standard envelope
102
+ // ({ success, status:'success', data: <payload>, meta }) — see render_success.
103
+ // RFC 8628 device endpoints live behind that envelope, so unwrap before reading.
104
+ const raw = response.data || {};
105
+ const data = (raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object')
106
+ ? raw.data
107
+ : raw;
108
+
109
+ if (!data.device_code || !data.user_code || !data.verification_uri) {
110
+ throw new DeviceFlowError('Malformed response from device/start: missing required fields.', 'invalid_response');
111
+ }
112
+
113
+ return {
114
+ device_code: data.device_code,
115
+ user_code: data.user_code,
116
+ verification_uri: data.verification_uri,
117
+ verification_uri_complete: data.verification_uri_complete,
118
+ expires_in: Number(data.expires_in) || 900,
119
+ interval: Number(data.interval) || 5,
120
+ code_verifier: verifier
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Poll the device/poll endpoint until terminal state.
126
+ *
127
+ * The server returns HTTP 200 with a body whose shape signals state:
128
+ * - `{ error: 'authorization_pending' }` → keep waiting
129
+ * - `{ error: 'slow_down', interval?: n }` → bump interval by 5s and wait
130
+ * - `{ error: 'access_denied' }` → throw DeviceAccessDeniedError
131
+ * - `{ error: 'expired_token' }` → throw DeviceExpiredError
132
+ * - `{ error: 'invalid_grant' }` → throw DevicePkceError
133
+ * - `{ access_token, ... }` → success, resolve token bundle
134
+ *
135
+ * The ainote backend wraps these in `{ success, status:'success', data: <body>, meta }`,
136
+ * so we unwrap `response.data.data` when present.
137
+ *
138
+ * If the server ever does return non-2xx, we attempt to map the body shape
139
+ * the same way (RFC 8628 §3.5 permits 400 + error JSON, but our backend per
140
+ * the plan returns 200; we tolerate both).
141
+ *
142
+ * @param {Object} args
143
+ * @param {string} args.apiUrl
144
+ * @param {string} args.deviceCode
145
+ * @param {string} args.codeVerifier - PKCE verifier captured during start.
146
+ * @param {number} args.interval - Initial poll interval in seconds.
147
+ * @param {number} args.expiresIn - Total lifetime in seconds.
148
+ * @param {(info: {elapsed: number, interval: number}) => void} [args.onTick]
149
+ * @returns {Promise<{access_token: string, refresh_token?: string, mcp_key?: string,
150
+ * expires_in?: number, user?: Object, scope?: string}>}
151
+ */
152
+ export async function pollForToken({
153
+ apiUrl,
154
+ deviceCode,
155
+ codeVerifier,
156
+ interval,
157
+ expiresIn,
158
+ onTick
159
+ }) {
160
+ let currentInterval = Math.max(1, Number(interval) || 5);
161
+ const totalBudget = Math.max(1, Number(expiresIn) || 900);
162
+ const startedAt = Date.now();
163
+
164
+ while (true) {
165
+ const elapsedBeforeWait = Math.floor((Date.now() - startedAt) / 1000);
166
+
167
+ if (elapsedBeforeWait >= totalBudget) {
168
+ throw new DeviceExpiredError();
169
+ }
170
+
171
+ if (typeof onTick === 'function') {
172
+ try {
173
+ onTick({ elapsed: elapsedBeforeWait, interval: currentInterval });
174
+ } catch { /* swallow UX callback errors */ }
175
+ }
176
+
177
+ await sleep(currentInterval);
178
+
179
+ const elapsedAfterWait = Math.floor((Date.now() - startedAt) / 1000);
180
+ if (elapsedAfterWait >= totalBudget) {
181
+ throw new DeviceExpiredError();
182
+ }
183
+
184
+ let body;
185
+ try {
186
+ const response = await axios.post(
187
+ `${apiUrl}/api/auth/cli/device/poll`,
188
+ { device_code: deviceCode, code_verifier: codeVerifier },
189
+ {
190
+ headers: { 'Content-Type': 'application/json' },
191
+ timeout: 30000,
192
+ // Per the plan the server returns 200 with status. Don't throw on
193
+ // 4xx so we can read the body uniformly.
194
+ validateStatus: (s) => s >= 200 && s < 500
195
+ }
196
+ );
197
+ const raw = response.data || {};
198
+ // Unwrap ainote API envelope ({ success, data: <payload>, meta }) if present.
199
+ body = (raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object')
200
+ ? raw.data
201
+ : raw;
202
+ } catch (error) {
203
+ // Network/5xx — transient. Wait and retry rather than fail the whole flow.
204
+ // But if we're out of budget, give up.
205
+ if (Math.floor((Date.now() - startedAt) / 1000) >= totalBudget) {
206
+ throw new DeviceExpiredError();
207
+ }
208
+ continue;
209
+ }
210
+
211
+ // Success is authoritatively signalled by the presence of an access token.
212
+ // The ainote server also sets `status: "complete"` on the success payload,
213
+ // so we must detect success BEFORE deriving pollState from `status` —
214
+ // otherwise "complete" falls through to the switch's default branch and is
215
+ // mis-reported as an "Unexpected poll response".
216
+ const succeeded =
217
+ body.access_token &&
218
+ !body.error &&
219
+ body.status !== 'authorization_pending' &&
220
+ body.status !== 'slow_down';
221
+
222
+ if (succeeded) {
223
+ return {
224
+ access_token: body.access_token,
225
+ refresh_token: body.refresh_token,
226
+ mcp_key: body.mcp_key,
227
+ expires_in: body.expires_in,
228
+ scope: body.scope,
229
+ user: body.user
230
+ };
231
+ }
232
+
233
+ // RFC 8628 §3.5 + ainote service: pending/error state lives in `error`.
234
+ // Fall back to `status` for pending states only (success handled above).
235
+ const pollState = body.error || body.status;
236
+
237
+ switch (pollState) {
238
+ case 'authorization_pending':
239
+ continue;
240
+ case 'slow_down': {
241
+ const serverInterval = Number(body.interval);
242
+ if (Number.isFinite(serverInterval) && serverInterval > currentInterval) {
243
+ currentInterval = serverInterval;
244
+ } else {
245
+ currentInterval += 5;
246
+ }
247
+ continue;
248
+ }
249
+ case 'access_denied':
250
+ throw new DeviceAccessDeniedError(body.error_description);
251
+ case 'expired_token':
252
+ throw new DeviceExpiredError(body.error_description);
253
+ case 'invalid_grant':
254
+ throw new DevicePkceError(body.error_description);
255
+ default: {
256
+ const message = body.error_description ||
257
+ body.error ||
258
+ body.message ||
259
+ `Unexpected poll response: ${JSON.stringify(body).slice(0, 200)}`;
260
+ throw new DeviceFlowError(message, pollState || 'unknown');
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Best-effort cancellation. Failures are swallowed — the server will GC
268
+ * the pending request via TTL anyway.
269
+ */
270
+ export async function cancel({ apiUrl, deviceCode }) {
271
+ if (!deviceCode) return;
272
+ try {
273
+ await axios.post(
274
+ `${apiUrl}/api/auth/cli/device/cancel`,
275
+ { device_code: deviceCode },
276
+ {
277
+ headers: { 'Content-Type': 'application/json' },
278
+ timeout: 5000,
279
+ validateStatus: () => true
280
+ }
281
+ );
282
+ } catch {
283
+ // intentionally ignored
284
+ }
285
+ }
@@ -1,40 +1,40 @@
1
- import { getConfig } from '../core/config.js';
2
-
3
- /**
4
- * Synchronous variant: only consults env var. Kept for callers that cannot
5
- * await (e.g. the stdio bootstrap path that hasn't loaded the async config).
6
- */
7
- export function authenticateWithMcpKey() {
8
- const config = getConfig();
9
-
10
- if (!config.api.key) {
11
- throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
12
- }
13
-
14
- return {
15
- type: 'mcpKey',
16
- apiKey: config.api.key
17
- };
18
- }
19
-
20
- /**
21
- * Async variant: falls back to the local CLI token store (populated by
22
- * `ainote-mcp login`) when no env var is set.
23
- */
24
- export async function authenticateWithMcpKeyAsync() {
25
- const envKey = process.env.AINOTE_API_KEY;
26
- if (envKey) {
27
- return { type: 'mcpKey', apiKey: envKey };
28
- }
29
-
30
- try {
31
- const { getCredentials } = await import('./token-store.js');
32
- const baseUrl = process.env.AINOTE_API_URL || 'https://ainote-5muq.onrender.com';
33
- const creds = await getCredentials(baseUrl);
34
- if (creds && creds.mcpKey) {
35
- return { type: 'mcpKey', apiKey: creds.mcpKey };
36
- }
37
- } catch { /* fall through */ }
38
-
39
- throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
40
- }
1
+ import { getConfig } from '../core/config.js';
2
+
3
+ /**
4
+ * Synchronous variant: only consults env var. Kept for callers that cannot
5
+ * await (e.g. the stdio bootstrap path that hasn't loaded the async config).
6
+ */
7
+ export function authenticateWithMcpKey() {
8
+ const config = getConfig();
9
+
10
+ if (!config.api.key) {
11
+ throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
12
+ }
13
+
14
+ return {
15
+ type: 'mcpKey',
16
+ apiKey: config.api.key
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Async variant: falls back to the local CLI token store (populated by
22
+ * `ainote-mcp login`) when no env var is set.
23
+ */
24
+ export async function authenticateWithMcpKeyAsync() {
25
+ const envKey = process.env.AINOTE_API_KEY;
26
+ if (envKey) {
27
+ return { type: 'mcpKey', apiKey: envKey };
28
+ }
29
+
30
+ try {
31
+ const { getCredentials } = await import('./token-store.js');
32
+ const baseUrl = process.env.AINOTE_API_URL || 'https://ainote-5muq.onrender.com';
33
+ const creds = await getCredentials(baseUrl);
34
+ if (creds && creds.mcpKey) {
35
+ return { type: 'mcpKey', apiKey: creds.mcpKey };
36
+ }
37
+ } catch { /* fall through */ }
38
+
39
+ throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
40
+ }