@ainote/mcp 1.3.1 → 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.
- package/LICENSE +20 -20
- package/README-ko.md +374 -374
- package/README.md +800 -777
- package/index.js +61 -56
- package/lib/auth/device-flow.js +285 -274
- package/lib/auth/mcp-key.js +40 -40
- package/lib/auth/oauth.js +18 -18
- package/lib/auth/refresh.js +78 -78
- package/lib/auth/strategy-factory.js +31 -31
- package/lib/auth/token-store.js +302 -286
- package/lib/cli/call.js +211 -0
- package/lib/cli/login.js +238 -238
- package/lib/cli/logout.js +39 -39
- package/lib/cli/signup.js +118 -118
- package/lib/cli/whoami.js +77 -77
- package/lib/core/api-client.js +1 -1
- package/lib/core/config-manager.js +106 -106
- package/lib/core/config.js +1 -1
- package/lib/core/mcp-server.js +2 -2
- package/lib/core/server-factory.js +82 -82
- package/lib/core/tool-registry.js +31 -31
- package/lib/tools/chatgpt-tools.js +93 -93
- package/lib/tools/dev-doc-tools.js +643 -643
- package/lib/tools/index.js +5 -5
- package/lib/tools/internal/claude-scanner.js +150 -150
- package/lib/tools/internal/encryption.js +88 -88
- package/lib/tools/internal/formatters.js +80 -80
- package/lib/tools/internal/keychain.js +98 -98
- package/lib/tools/internal/path-mapper.js +221 -221
- package/lib/tools/onboarding-tools.js +124 -124
- package/lib/tools/shared-tools.js +280 -280
- package/lib/utils/api-client.js +152 -152
- package/package.json +101 -101
- package/server.js +99 -99
package/lib/auth/device-flow.js
CHANGED
|
@@ -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
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
case '
|
|
239
|
-
|
|
240
|
-
case '
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
package/lib/auth/mcp-key.js
CHANGED
|
@@ -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
|
+
}
|