@ainote/mcp 1.1.1 → 1.2.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 +71 -4
- package/index.js +29 -4
- package/lib/auth/device-flow.js +274 -0
- package/lib/auth/mcp-key.js +27 -1
- package/lib/auth/refresh.js +78 -0
- package/lib/auth/token-store.js +286 -0
- package/lib/cli/login.js +238 -0
- package/lib/cli/logout.js +39 -0
- package/lib/cli/whoami.js +77 -0
- package/lib/core/config-manager.js +40 -1
- package/lib/tools/dev-doc-tools.js +397 -9
- package/lib/tools/internal/claude-scanner.js +150 -0
- package/lib/tools/internal/encryption.js +88 -0
- package/lib/tools/internal/keychain.js +98 -0
- package/lib/tools/internal/path-mapper.js +221 -0
- package/lib/tools/shared-tools.js +67 -33
- package/lib/utils/api-client.js +74 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A Model Context Protocol (MCP) server that connects AI assistants to your AI Not
|
|
|
11
11
|
|
|
12
12
|
- [Access Methods](#access-methods)
|
|
13
13
|
- [Features](#features)
|
|
14
|
+
- [Authentication](#authentication)
|
|
14
15
|
- [Prerequisites](#prerequisites)
|
|
15
16
|
- [Installation](#installation)
|
|
16
17
|
- [Configuration](#configuration)
|
|
@@ -41,7 +42,7 @@ AI Note MCP server is available through three transport options to cover differe
|
|
|
41
42
|
- **Best for**: Users wanting to expose AI Note tools to ChatGPT without deploying infrastructure
|
|
42
43
|
|
|
43
44
|
### 3. **Hosted HTTP Endpoint** - For Platform Integration
|
|
44
|
-
- **URL**: `https://api.ainote.dev/mcp`
|
|
45
|
+
- **URL**: `https://api.ainote.dev/api/mcp`
|
|
45
46
|
- **Protocol**: JSON-RPC 2.0 over HTTP
|
|
46
47
|
- **Usage**: Platform integrations (Kakao PlayMCP, etc.)
|
|
47
48
|
- **Setup**: No installation required, direct API access
|
|
@@ -53,7 +54,10 @@ All transports expose the same tool catalog. Choose the option that matches your
|
|
|
53
54
|
|
|
54
55
|
- 📝 **Task Management**: Create, update, delete, and list tasks directly from Claude Desktop
|
|
55
56
|
- 📄 **Dev Docs**: Manage CLAUDE.md, Cursor rules, Windsurf rules, and dev documents centrally via MCP
|
|
56
|
-
- 🗂️ **Dev Category Hierarchy**: Auto-organized under `dev/` with subcategories (claude, cursor, windsurf, copilot, docs)
|
|
57
|
+
- 🗂️ **Dev Category Hierarchy**: Auto-organized under `dev/` with subcategories (claude, cursor, windsurf, copilot, docs, skills, agents, commands, hooks, mcp)
|
|
58
|
+
- 🔄 **Claude Environment Sync**: Push/pull `~/.claude/` skills, agents, commands, hooks across machines (macOS/WSL/Linux)
|
|
59
|
+
- 🔐 **End-to-End Encryption**: `mcp` category (mcpServers + API keys) auto-encrypted client-side with age; server never sees plaintext
|
|
60
|
+
- 🪪 **OS Keychain Integration**: Per-machine age identity stored in macOS Keychain / libsecret / Credential Manager (file fallback supported)
|
|
57
61
|
- 🏷️ **Category Support**: Organize tasks with categories
|
|
58
62
|
- 🔍 **Advanced Filtering**: 18+ filter options including date ranges, location, overdue tasks
|
|
59
63
|
- ⭐ **Priority Management**: Mark tasks as important
|
|
@@ -128,6 +132,69 @@ These tools also work inside Claude (no API key needed):
|
|
|
128
132
|
| `login_and_get_key` | Login + get MCP key (existing account) |
|
|
129
133
|
| `get_setup_guide` | Setup instructions |
|
|
130
134
|
|
|
135
|
+
## Authentication
|
|
136
|
+
|
|
137
|
+
The CLI supports browser-based login as a more secure alternative to manually pasting API keys into your MCP config. Tokens and the MCP key are stored in the OS keychain.
|
|
138
|
+
|
|
139
|
+
### Quick start
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
ainote-mcp login # browser-based, recommended
|
|
143
|
+
ainote-mcp whoami # verify current login
|
|
144
|
+
ainote-mcp logout # revoke + clear keychain
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### How it works
|
|
148
|
+
|
|
149
|
+
`ainote-mcp login` runs an [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) OAuth 2.0 Device Authorization Grant. The CLI generates a PKCE pair locally, opens your browser to sign in via the AI Note web app, and polls the backend until you approve. On success it stores an access token, a refresh token, and an MCP key in the OS keychain. The default MCP stdio mode automatically uses the stored MCP key — no `AINOTE_API_KEY` env var needed.
|
|
150
|
+
|
|
151
|
+
### Storage locations
|
|
152
|
+
|
|
153
|
+
| OS | Location |
|
|
154
|
+
|----------|---------------------------------------------------------------------------|
|
|
155
|
+
| macOS | Keychain Access — service `@ainote/cli` |
|
|
156
|
+
| Linux | libsecret (gnome-keyring / kwallet) — service `@ainote/cli` |
|
|
157
|
+
| Windows | Credential Manager — target `@ainote/cli` |
|
|
158
|
+
| Fallback | `${XDG_CONFIG_HOME:-~/.config}/ainote/credentials.json` (mode `0600`) |
|
|
159
|
+
|
|
160
|
+
The fallback file is only used when no system keychain is available (e.g. headless servers without `libsecret`).
|
|
161
|
+
|
|
162
|
+
### CLI flags
|
|
163
|
+
|
|
164
|
+
- `--scope=mcp,read,write` — comma-separated scopes requested for the token (default: `mcp,read,write`)
|
|
165
|
+
- `--no-browser` — print the verification URL instead of auto-opening a browser (use this on SSH/CI)
|
|
166
|
+
|
|
167
|
+
### Environment overrides
|
|
168
|
+
|
|
169
|
+
- `AINOTE_API_URL` — backend URL (default `https://ainote-5muq.onrender.com`)
|
|
170
|
+
- `AINOTE_API_KEY` — pre-provisioned MCP key. When set, the login flow is bypassed entirely and this key is used as-is.
|
|
171
|
+
|
|
172
|
+
### Headless / CI usage
|
|
173
|
+
|
|
174
|
+
For SSH sessions, devcontainers, or CI runners that cannot open a browser, use `--no-browser`. The CLI will print a short user code and a verification URL; open the URL on any device (laptop, phone) signed in to your AI Note account, approve the request, then return to the terminal:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
$ ainote-mcp login --no-browser
|
|
178
|
+
Open this URL in any browser:
|
|
179
|
+
https://ainote.dev/oauth/cli/device?user_code=BCDF-GHJK
|
|
180
|
+
Waiting for approval...
|
|
181
|
+
✓ Logged in as you@example.com
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
After login, subsequent invocations (`ainote-mcp`, `ainote-mcp whoami`) work normally with no further interaction.
|
|
185
|
+
|
|
186
|
+
### Troubleshooting
|
|
187
|
+
|
|
188
|
+
| Symptom | Fix |
|
|
189
|
+
|-----------------------------------------------|-------------------------------------------------------------------------------------------|
|
|
190
|
+
| `LOGIN_HINT` error on any subcommand | Run `ainote-mcp login` |
|
|
191
|
+
| macOS Keychain prompt every run | Open Keychain Access, find `@ainote/cli`, set access control to "Always allow" for `node` |
|
|
192
|
+
| Browser does not open | Re-run with `--no-browser` and copy the URL manually |
|
|
193
|
+
| Persistent 401 after login | Tokens were revoked server-side. Run `ainote-mcp login` again |
|
|
194
|
+
| `libsecret not available` on Linux | Install `gnome-keyring` / `libsecret-1-0`, or rely on the file fallback (still encrypted at-rest by the OS) |
|
|
195
|
+
|
|
196
|
+
See [`docs/architecture/CLI_AUTH.md`](../docs/architecture/CLI_AUTH.md) for the full protocol specification, sequence diagram, and security model.
|
|
197
|
+
|
|
131
198
|
## Prerequisites
|
|
132
199
|
|
|
133
200
|
- Node.js >= 18.0.0
|
|
@@ -244,7 +311,7 @@ For platform integrations and services that prefer HTTP over stdio, AI Note prov
|
|
|
244
311
|
|
|
245
312
|
### Endpoint URL
|
|
246
313
|
```
|
|
247
|
-
POST https://api.ainote.dev/mcp
|
|
314
|
+
POST https://api.ainote.dev/api/mcp
|
|
248
315
|
Content-Type: application/json
|
|
249
316
|
```
|
|
250
317
|
|
|
@@ -287,7 +354,7 @@ Authorization: Bearer YOUR_API_KEY
|
|
|
287
354
|
This HTTP endpoint provides identical functionality to the stdio version, making AI Note MCP accessible to a broader range of platforms and integration scenarios.
|
|
288
355
|
|
|
289
356
|
### 4. **Streaming API** - For Large Data Operations
|
|
290
|
-
- **URL**: `POST https://api.ainote.dev/mcp/stream`
|
|
357
|
+
- **URL**: `POST https://api.ainote.dev/api/mcp/stream`
|
|
291
358
|
- **Protocol**: Server-Sent Events (SSE)
|
|
292
359
|
- **Usage**: Bulk operations, large task lists, real-time progress
|
|
293
360
|
- **Best for**: Applications that need to handle large datasets or show progress indicators
|
package/index.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* AI Note MCP Server
|
|
4
|
-
*
|
|
3
|
+
* AI Note MCP Server — Claude Desktop integration (stdio transport).
|
|
4
|
+
*
|
|
5
|
+
* Also supports CLI subcommands:
|
|
6
|
+
* signup — interactive account creation
|
|
7
|
+
* login — device-authorization grant (RFC 8628)
|
|
8
|
+
* logout — revoke + clear local credentials
|
|
9
|
+
* whoami — show currently authenticated user
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
const subcommand = process.argv[2];
|
|
@@ -9,19 +14,39 @@ const subcommand = process.argv[2];
|
|
|
9
14
|
if (subcommand === 'signup') {
|
|
10
15
|
const { runSignup } = await import('./lib/cli/signup.js');
|
|
11
16
|
await runSignup();
|
|
17
|
+
} else if (subcommand === 'login') {
|
|
18
|
+
const { run } = await import('./lib/cli/login.js');
|
|
19
|
+
await run();
|
|
20
|
+
} else if (subcommand === 'logout') {
|
|
21
|
+
const { run } = await import('./lib/cli/logout.js');
|
|
22
|
+
await run();
|
|
23
|
+
} else if (subcommand === 'whoami') {
|
|
24
|
+
const { run } = await import('./lib/cli/whoami.js');
|
|
25
|
+
await run();
|
|
12
26
|
} else {
|
|
13
27
|
// Default: start MCP stdio server
|
|
14
28
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
15
29
|
const { resolveAuthContext } = await import('./lib/auth/strategy-factory.js');
|
|
16
30
|
const { createClaudeServer } = await import('./lib/core/server-factory.js');
|
|
31
|
+
const { getConfigAsync } = await import('./lib/core/config-manager.js');
|
|
32
|
+
|
|
33
|
+
// Hydrate AINOTE_API_KEY from the token store BEFORE auth context resolution
|
|
34
|
+
// so existing sync `authenticateWithMcpKey()` paths see the stored credential.
|
|
35
|
+
if (!process.env.AINOTE_API_KEY) {
|
|
36
|
+
try {
|
|
37
|
+
const cfg = await getConfigAsync();
|
|
38
|
+
if (cfg.api.key) {
|
|
39
|
+
process.env.AINOTE_API_KEY = cfg.api.key;
|
|
40
|
+
}
|
|
41
|
+
} catch { /* anonymous fallback handled below */ }
|
|
42
|
+
}
|
|
17
43
|
|
|
18
44
|
const transport = new StdioServerTransport();
|
|
19
45
|
const defaultAuthContext = resolveAuthContext('stdio');
|
|
20
46
|
|
|
21
47
|
if (defaultAuthContext.type === 'anonymous') {
|
|
22
48
|
console.error('[ainote-mcp] No AINOTE_API_KEY found. Starting in onboarding mode.');
|
|
23
|
-
console.error('[ainote-mcp]
|
|
24
|
-
console.error('[ainote-mcp] Or run: npx @ainote/mcp signup');
|
|
49
|
+
console.error('[ainote-mcp] Run `npx @ainote/mcp login` (browser flow) or `npx @ainote/mcp signup`.');
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
const { server } = createClaudeServer({ defaultAuthContext });
|
|
@@ -0,0 +1,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
|
+
// 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
|
+
}
|
package/lib/auth/mcp-key.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { getConfig } from '../core/config.js';
|
|
2
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
|
+
*/
|
|
3
7
|
export function authenticateWithMcpKey() {
|
|
4
8
|
const config = getConfig();
|
|
5
9
|
|
|
6
10
|
if (!config.api.key) {
|
|
7
|
-
throw new Error('AINOTE_API_KEY is required to authenticate MCP requests');
|
|
11
|
+
throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
return {
|
|
@@ -12,3 +16,25 @@ export function authenticateWithMcpKey() {
|
|
|
12
16
|
apiKey: config.api.key
|
|
13
17
|
};
|
|
14
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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access token rotation against the ainote backend.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: POST /api/auth/token/refresh
|
|
5
|
+
* Body: { refresh_token: "rt_..." }
|
|
6
|
+
* Response: { access_token, refresh_token, expires_in }
|
|
7
|
+
*
|
|
8
|
+
* Concurrency: an in-process mutex (module-level Promise) ensures that
|
|
9
|
+
* parallel callers wait on a single in-flight refresh instead of issuing
|
|
10
|
+
* duplicate requests (which would otherwise burn through refresh-token
|
|
11
|
+
* rotation and invalidate the family).
|
|
12
|
+
*/
|
|
13
|
+
import axios from 'axios';
|
|
14
|
+
|
|
15
|
+
let inFlight = null;
|
|
16
|
+
|
|
17
|
+
export class RefreshError extends Error {
|
|
18
|
+
constructor(message, { status, body } = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'RefreshError';
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.body = body;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function performRefresh({ apiUrl, refreshToken }) {
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await axios.post(
|
|
30
|
+
`${apiUrl}/api/auth/token/refresh`,
|
|
31
|
+
{ refresh_token: refreshToken },
|
|
32
|
+
{
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
timeout: 30000,
|
|
35
|
+
validateStatus: (s) => s >= 200 && s < 500
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new RefreshError(`Refresh request failed: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (response.status < 200 || response.status >= 300) {
|
|
43
|
+
const body = response.data || {};
|
|
44
|
+
const msg = body.error_description || body.error || body.message ||
|
|
45
|
+
`Refresh failed with HTTP ${response.status}`;
|
|
46
|
+
throw new RefreshError(msg, { status: response.status, body });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = response.data || {};
|
|
50
|
+
if (!body.access_token) {
|
|
51
|
+
throw new RefreshError('Refresh response missing access_token', { body });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
access_token: body.access_token,
|
|
55
|
+
refresh_token: body.refresh_token || refreshToken,
|
|
56
|
+
expires_in: Number(body.expires_in) || undefined
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Rotate the access token. Concurrent callers share one in-flight request.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} args
|
|
64
|
+
* @param {string} args.apiUrl
|
|
65
|
+
* @param {string} args.refreshToken
|
|
66
|
+
* @returns {Promise<{access_token: string, refresh_token: string, expires_in?: number}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function refreshAccessToken({ apiUrl, refreshToken }) {
|
|
69
|
+
if (!refreshToken) {
|
|
70
|
+
throw new RefreshError('No refresh token available — login required.');
|
|
71
|
+
}
|
|
72
|
+
if (inFlight) return inFlight;
|
|
73
|
+
inFlight = performRefresh({ apiUrl, refreshToken })
|
|
74
|
+
.finally(() => {
|
|
75
|
+
inFlight = null;
|
|
76
|
+
});
|
|
77
|
+
return inFlight;
|
|
78
|
+
}
|