@ainote/mcp 1.1.0 → 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 +482 -5
- 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 +6 -4
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { getAgeIdentity, getAgeRecipients } from './keychain.js';
|
|
6
|
+
|
|
7
|
+
const TMPDIR = os.tmpdir();
|
|
8
|
+
|
|
9
|
+
function ageBinary() {
|
|
10
|
+
const r = spawnSync('age', ['--version'], { encoding: 'utf8' });
|
|
11
|
+
if (r.status !== 0) {
|
|
12
|
+
throw new Error('age binary not found. Install: brew install age');
|
|
13
|
+
}
|
|
14
|
+
return 'age';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function withTmpFile(prefix, body, fn) {
|
|
18
|
+
const tmp = path.join(TMPDIR, `${prefix}.${process.pid}.${Date.now()}`);
|
|
19
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
20
|
+
try {
|
|
21
|
+
return fn(tmp);
|
|
22
|
+
} finally {
|
|
23
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encrypt plaintext for one or more recipients (age public keys).
|
|
29
|
+
* Returns base64 ciphertext.
|
|
30
|
+
*/
|
|
31
|
+
export async function encryptText(plaintext, { recipients } = {}) {
|
|
32
|
+
const recps = recipients || await getAgeRecipients();
|
|
33
|
+
if (!recps || recps.length === 0) {
|
|
34
|
+
throw new Error('No age recipients available. Run sync_init_encryption first.');
|
|
35
|
+
}
|
|
36
|
+
ageBinary();
|
|
37
|
+
return withTmpFile('ainote-enc-in', plaintext, (inputPath) => {
|
|
38
|
+
const args = ['-a']; // armored output
|
|
39
|
+
for (const r of recps) {
|
|
40
|
+
args.push('-r', r);
|
|
41
|
+
}
|
|
42
|
+
args.push(inputPath);
|
|
43
|
+
const r = spawnSync('age', args, { encoding: 'utf8' });
|
|
44
|
+
if (r.status !== 0) {
|
|
45
|
+
throw new Error(`age encrypt failed: ${r.stderr || 'unknown'}`);
|
|
46
|
+
}
|
|
47
|
+
return r.stdout;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decrypt armored age ciphertext with this machine's identity from Keychain.
|
|
53
|
+
*/
|
|
54
|
+
export async function decryptText(ciphertext) {
|
|
55
|
+
ageBinary();
|
|
56
|
+
const identity = await getAgeIdentity();
|
|
57
|
+
if (!identity) {
|
|
58
|
+
throw new Error('No age identity for this machine. Run sync_init_encryption first.');
|
|
59
|
+
}
|
|
60
|
+
return withTmpFile('ainote-id', identity, (idPath) =>
|
|
61
|
+
withTmpFile('ainote-ct', ciphertext, (ctPath) => {
|
|
62
|
+
const r = spawnSync('age', ['-d', '-i', idPath, ctPath], { encoding: 'utf8' });
|
|
63
|
+
if (r.status !== 0) {
|
|
64
|
+
throw new Error(`age decrypt failed: ${r.stderr || 'unknown'}`);
|
|
65
|
+
}
|
|
66
|
+
return r.stdout;
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate a new age keypair. Returns { identity, recipient }.
|
|
73
|
+
* Identity is the private key (AGE-SECRET-KEY-1...), recipient is the public (age1...).
|
|
74
|
+
*/
|
|
75
|
+
export function generateKeypair() {
|
|
76
|
+
ageBinary();
|
|
77
|
+
const r = spawnSync('age-keygen', [], { encoding: 'utf8' });
|
|
78
|
+
if (r.status !== 0) {
|
|
79
|
+
throw new Error(`age-keygen failed: ${r.stderr || 'unknown'}`);
|
|
80
|
+
}
|
|
81
|
+
const out = r.stdout;
|
|
82
|
+
const recpMatch = out.match(/# public key: (age1[a-z0-9]+)/i);
|
|
83
|
+
const idMatch = out.match(/(AGE-SECRET-KEY-1[A-Z0-9]+)/);
|
|
84
|
+
if (!recpMatch || !idMatch) {
|
|
85
|
+
throw new Error('Could not parse age-keygen output');
|
|
86
|
+
}
|
|
87
|
+
return { identity: out, recipient: recpMatch[1], identity_line: idMatch[1] };
|
|
88
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform secret storage for ainote sync (async).
|
|
3
|
+
* - Primary: cross-keychain (macOS Keychain / libsecret / Windows Credential Manager)
|
|
4
|
+
* - Fallback: ~/.config/ainote/age-identity.txt (0600)
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { createRequire } from 'module';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
const SERVICE = 'ainote-sync';
|
|
14
|
+
const ACCOUNT_IDENTITY = 'age-identity';
|
|
15
|
+
const ACCOUNT_RECIPIENTS = 'age-recipients';
|
|
16
|
+
|
|
17
|
+
const FALLBACK_DIR = path.join(os.homedir(), '.config', 'ainote');
|
|
18
|
+
const FALLBACK_IDENTITY = path.join(FALLBACK_DIR, 'age-identity.txt');
|
|
19
|
+
const FALLBACK_RECIPIENTS = path.join(FALLBACK_DIR, 'age-recipients.json');
|
|
20
|
+
|
|
21
|
+
let ck = null;
|
|
22
|
+
function getKeychain() {
|
|
23
|
+
if (ck !== null) return ck;
|
|
24
|
+
try {
|
|
25
|
+
ck = require('cross-keychain');
|
|
26
|
+
} catch {
|
|
27
|
+
ck = false;
|
|
28
|
+
}
|
|
29
|
+
return ck;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fsRead(p) {
|
|
33
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fsWrite(p, value) {
|
|
37
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
38
|
+
fs.writeFileSync(p, value, { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readSecret(account, fallbackPath) {
|
|
42
|
+
const kc = getKeychain();
|
|
43
|
+
if (kc && typeof kc.getPassword === 'function') {
|
|
44
|
+
try {
|
|
45
|
+
const v = await kc.getPassword(SERVICE, account);
|
|
46
|
+
if (v) return v;
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
return fsRead(fallbackPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function writeSecret(account, value, fallbackPath) {
|
|
53
|
+
const kc = getKeychain();
|
|
54
|
+
if (kc && typeof kc.setPassword === 'function') {
|
|
55
|
+
try {
|
|
56
|
+
await kc.setPassword(SERVICE, account, value);
|
|
57
|
+
return 'keychain';
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
fsWrite(fallbackPath, value);
|
|
61
|
+
return `file:${fallbackPath}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getAgeIdentity() {
|
|
65
|
+
return readSecret(ACCOUNT_IDENTITY, FALLBACK_IDENTITY);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function setAgeIdentity(identityText) {
|
|
69
|
+
return writeSecret(ACCOUNT_IDENTITY, identityText, FALLBACK_IDENTITY);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getAgeRecipients() {
|
|
73
|
+
const raw = await readSecret(ACCOUNT_RECIPIENTS, FALLBACK_RECIPIENTS);
|
|
74
|
+
if (!raw) return [];
|
|
75
|
+
try {
|
|
76
|
+
const v = JSON.parse(raw);
|
|
77
|
+
return Array.isArray(v) ? v : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function setAgeRecipients(list) {
|
|
84
|
+
return writeSecret(ACCOUNT_RECIPIENTS, JSON.stringify(list), FALLBACK_RECIPIENTS);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function addRecipient(recipient) {
|
|
88
|
+
const current = await getAgeRecipients();
|
|
89
|
+
if (!current.includes(recipient)) {
|
|
90
|
+
current.push(recipient);
|
|
91
|
+
await setAgeRecipients(current);
|
|
92
|
+
}
|
|
93
|
+
return current;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function storageBackend() {
|
|
97
|
+
return getKeychain() ? 'cross-keychain' : 'file-fallback';
|
|
98
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cross-platform path mapper for ainote multi-device sync.
|
|
8
|
+
*
|
|
9
|
+
* Problem:
|
|
10
|
+
* local_path is stored from the ORIGINAL machine (e.g. macOS).
|
|
11
|
+
* When pulled on a DIFFERENT platform (e.g. WSL), paths don't match.
|
|
12
|
+
*
|
|
13
|
+
* Platform path patterns:
|
|
14
|
+
* macOS: HOME=/Users/<user> project key: -Users-<user>
|
|
15
|
+
* WSL: HOME=/home/<user> project key: -mnt-c-Users-<winuser> (CWD=/mnt/c/Users/<winuser>)
|
|
16
|
+
* Linux: HOME=/home/<user> project key: -home-<user>
|
|
17
|
+
* Windows Native: HOME=C:\Users\<user> project key: -C-Users-<user> or -Users-<user>
|
|
18
|
+
*
|
|
19
|
+
* Claude Code encodes CWD into project key by replacing / with -.
|
|
20
|
+
* The "global" memory lives under the home/CWD project key.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── Platform detection ──
|
|
24
|
+
|
|
25
|
+
function detectPlatform() {
|
|
26
|
+
if (process.platform === 'darwin') return 'macos';
|
|
27
|
+
if (process.platform === 'win32') return 'windows';
|
|
28
|
+
|
|
29
|
+
// Linux — check for WSL
|
|
30
|
+
try {
|
|
31
|
+
const ver = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
|
|
32
|
+
if (ver.includes('microsoft') || ver.includes('wsl')) return 'wsl';
|
|
33
|
+
} catch { /* ignore */ }
|
|
34
|
+
|
|
35
|
+
return 'linux';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CURRENT_PLATFORM = detectPlatform();
|
|
39
|
+
const HOME = os.homedir();
|
|
40
|
+
|
|
41
|
+
// ── Windows home detection for WSL ──
|
|
42
|
+
|
|
43
|
+
function detectWindowsHome() {
|
|
44
|
+
if (CURRENT_PLATFORM !== 'wsl') return null;
|
|
45
|
+
|
|
46
|
+
// Method 1: WSLHOME env var (user-set)
|
|
47
|
+
if (process.env.WSLHOME) return process.env.WSLHOME;
|
|
48
|
+
|
|
49
|
+
// Method 2: cmd.exe to get USERPROFILE
|
|
50
|
+
try {
|
|
51
|
+
const profile = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf8', timeout: 3000 })
|
|
52
|
+
.trim().replace(/\r?\n/g, '');
|
|
53
|
+
if (profile && !profile.includes('%')) {
|
|
54
|
+
// Convert C:\Users\Owner → /mnt/c/Users/Owner
|
|
55
|
+
return profile.replace(/^([A-Z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, '/');
|
|
56
|
+
}
|
|
57
|
+
} catch { /* cmd.exe might not be available */ }
|
|
58
|
+
|
|
59
|
+
// Method 3: scan /mnt/c/Users/
|
|
60
|
+
try {
|
|
61
|
+
const usersDir = '/mnt/c/Users';
|
|
62
|
+
const skip = new Set(['Public', 'Default', 'Default User', 'All Users', 'desktop.ini']);
|
|
63
|
+
const entries = fs.readdirSync(usersDir).filter(e => {
|
|
64
|
+
if (skip.has(e)) return false;
|
|
65
|
+
try { return fs.statSync(path.join(usersDir, e)).isDirectory(); } catch { return false; }
|
|
66
|
+
});
|
|
67
|
+
if (entries.length === 1) return path.join(usersDir, entries[0]);
|
|
68
|
+
// Multiple users — try matching linux username
|
|
69
|
+
const linuxUser = os.userInfo().username;
|
|
70
|
+
const match = entries.find(e => e.toLowerCase() === linuxUser.toLowerCase());
|
|
71
|
+
if (match) return path.join(usersDir, match);
|
|
72
|
+
// Return first non-admin looking one
|
|
73
|
+
if (entries.length > 0) return path.join(usersDir, entries[0]);
|
|
74
|
+
} catch { /* not WSL or no /mnt/c */ }
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Cache Windows home (lazy)
|
|
80
|
+
let _winHome;
|
|
81
|
+
let _winHomeResolved = false;
|
|
82
|
+
|
|
83
|
+
function getWindowsHome() {
|
|
84
|
+
if (!_winHomeResolved) {
|
|
85
|
+
_winHome = detectWindowsHome();
|
|
86
|
+
_winHomeResolved = true;
|
|
87
|
+
}
|
|
88
|
+
return _winHome;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Claude project key ──
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the current platform's "home" project key.
|
|
95
|
+
* This is what Claude Code uses for the global memory directory.
|
|
96
|
+
*/
|
|
97
|
+
function currentHomeProjectKey() {
|
|
98
|
+
if (CURRENT_PLATFORM === 'wsl') {
|
|
99
|
+
const winHome = getWindowsHome();
|
|
100
|
+
if (winHome) return winHome.replace(/\//g, '-');
|
|
101
|
+
}
|
|
102
|
+
// macOS: -Users-seunghan, Linux: -home-seunghan
|
|
103
|
+
return HOME.replace(/\//g, '-');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Home-dir project key detection ──
|
|
107
|
+
// These regex patterns match the "home directory" project keys from each platform.
|
|
108
|
+
|
|
109
|
+
const HOME_KEY_PATTERNS = [
|
|
110
|
+
/^-Users-[^-]+$/, // macOS: -Users-seunghan
|
|
111
|
+
/^-mnt-[a-z]-Users-[^-]+$/, // WSL: -mnt-c-Users-Owner
|
|
112
|
+
/^-home-[^-]+$/, // Linux: -home-seunghan
|
|
113
|
+
/^-[A-Z]-Users-[^-]+$/, // Windows Native: -C-Users-Owner
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// Project key patterns (home key + project name suffix)
|
|
117
|
+
const PROJECT_KEY_EXTRACTORS = [
|
|
118
|
+
{ pattern: /^-Users-[^-]+-(.+)$/, platform: 'macos' }, // -Users-seunghan-launchcrew
|
|
119
|
+
{ pattern: /^-mnt-[a-z]-Users-[^-]+-(.+)$/, platform: 'wsl' }, // -mnt-c-Users-Owner-launchcrew
|
|
120
|
+
{ pattern: /^-home-[^-]+-(.+)$/, platform: 'linux' }, // -home-seunghan-launchcrew
|
|
121
|
+
{ pattern: /^-[A-Z]-Users-[^-]+-(.+)$/, platform: 'windows' }, // -C-Users-Owner-launchcrew
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function isHomeProjectKey(key) {
|
|
125
|
+
return HOME_KEY_PATTERNS.some(p => p.test(key));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function extractProjectName(key) {
|
|
129
|
+
for (const { pattern } of PROJECT_KEY_EXTRACTORS) {
|
|
130
|
+
const m = key.match(pattern);
|
|
131
|
+
if (m) return m[1];
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Home directory prefix patterns ──
|
|
137
|
+
|
|
138
|
+
const HOME_PREFIX_PATTERNS = [
|
|
139
|
+
/^\/Users\/[^/]+/, // macOS
|
|
140
|
+
/^\/mnt\/[a-z]\/Users\/[^/]+/, // WSL (Windows files)
|
|
141
|
+
/^\/home\/[^/]+/, // Linux / WSL native
|
|
142
|
+
/^[A-Z]:\\Users\\[^\\]+/, // Windows Native
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// ── Main path mapping ──
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Transform a stored local_path to work on the current platform.
|
|
149
|
+
*
|
|
150
|
+
* Examples (stored on macOS, pulled on WSL):
|
|
151
|
+
* ~/.claude/projects/-Users-seunghan/memory/MEMORY.md
|
|
152
|
+
* → ~/.claude/projects/-mnt-c-Users-Owner/memory/MEMORY.md
|
|
153
|
+
* → /home/seunghan/.claude/projects/-mnt-c-Users-Owner/memory/MEMORY.md
|
|
154
|
+
*
|
|
155
|
+
* /Users/seunghan/launchcrew/CLAUDE.md
|
|
156
|
+
* → /home/seunghan/launchcrew/CLAUDE.md
|
|
157
|
+
*/
|
|
158
|
+
export function mapPathForCurrentPlatform(storedPath) {
|
|
159
|
+
if (!storedPath) return storedPath;
|
|
160
|
+
|
|
161
|
+
let p = storedPath;
|
|
162
|
+
|
|
163
|
+
// Step 1: Expand ~ to current HOME
|
|
164
|
+
p = p.replace(/^~/, HOME);
|
|
165
|
+
|
|
166
|
+
// Step 2: Replace any foreign home directory prefix with current HOME
|
|
167
|
+
for (const pattern of HOME_PREFIX_PATTERNS) {
|
|
168
|
+
if (pattern.test(p)) {
|
|
169
|
+
p = p.replace(pattern, HOME);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Step 3: Map Claude project keys
|
|
175
|
+
// Pattern: .../.claude/projects/<KEY>/...
|
|
176
|
+
const claudeProjectRegex = /(\/\.claude\/projects\/)([-\w]+)(\/)/;
|
|
177
|
+
const match = p.match(claudeProjectRegex);
|
|
178
|
+
if (match) {
|
|
179
|
+
const storedKey = match[2];
|
|
180
|
+
const myHomeKey = currentHomeProjectKey();
|
|
181
|
+
|
|
182
|
+
if (isHomeProjectKey(storedKey)) {
|
|
183
|
+
// Home project key → replace with current platform's home key
|
|
184
|
+
p = p.replace(claudeProjectRegex, `$1${myHomeKey}$3`);
|
|
185
|
+
} else {
|
|
186
|
+
// Project-specific key → extract project name + append to current home key
|
|
187
|
+
const projectName = extractProjectName(storedKey);
|
|
188
|
+
if (projectName) {
|
|
189
|
+
const newKey = `${myHomeKey}-${projectName}`;
|
|
190
|
+
p = p.replace(claudeProjectRegex, `$1${newKey}$3`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return p;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Normalize a local_path to canonical form before storing.
|
|
200
|
+
* Replaces absolute home path with ~ for portability.
|
|
201
|
+
*/
|
|
202
|
+
export function canonicalizePath(localPath) {
|
|
203
|
+
if (!localPath) return localPath;
|
|
204
|
+
return localPath.replace(new RegExp(`^${escapeRegex(HOME)}`), '~');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function escapeRegex(str) {
|
|
208
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get current platform info (for diagnostics / tool output).
|
|
213
|
+
*/
|
|
214
|
+
export function getPlatformInfo() {
|
|
215
|
+
return {
|
|
216
|
+
platform: CURRENT_PLATFORM,
|
|
217
|
+
home: HOME,
|
|
218
|
+
homeProjectKey: currentHomeProjectKey(),
|
|
219
|
+
windowsHome: CURRENT_PLATFORM === 'wsl' ? getWindowsHome() : undefined,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -114,34 +114,75 @@ Returns structured data with all task fields including location, dates, and cate
|
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// Shared field definitions — keep create and update in sync so MCP exposes
|
|
118
|
+
// the same surface for both operations. Server-side strong params
|
|
119
|
+
// (Api::McpTasksController#task_params) accepts this full set.
|
|
120
|
+
const TASK_FIELD_SCHEMA = {
|
|
121
|
+
content: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'Task content'
|
|
124
|
+
},
|
|
125
|
+
is_important: {
|
|
126
|
+
type: 'boolean',
|
|
127
|
+
description: 'Mark task as important'
|
|
128
|
+
},
|
|
129
|
+
due_date: {
|
|
130
|
+
type: 'string',
|
|
131
|
+
description: 'Due date in ISO format (e.g., 2026-04-21T15:00:00+09:00)'
|
|
132
|
+
},
|
|
133
|
+
due_time: {
|
|
134
|
+
type: 'string',
|
|
135
|
+
description: 'Time of day in HH:MM (24h). Optional when due_date already carries a time.'
|
|
136
|
+
},
|
|
137
|
+
start_date: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
description: 'Start date for multi-day events (ISO format). For single-day tasks, use due_date.'
|
|
140
|
+
},
|
|
141
|
+
is_all_day: {
|
|
142
|
+
type: 'boolean',
|
|
143
|
+
description: 'Mark as all-day event — suppresses time-of-day rendering.'
|
|
144
|
+
},
|
|
145
|
+
category_id: {
|
|
146
|
+
type: 'string',
|
|
147
|
+
description: 'Category ID. Must be owned by or shared to this user.'
|
|
148
|
+
},
|
|
149
|
+
notes: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
description: 'Free-form notes / details attached to the task.'
|
|
152
|
+
},
|
|
153
|
+
location: {
|
|
154
|
+
type: 'string',
|
|
155
|
+
description: 'Human-readable location (e.g., "Starbucks Gangnam"). For GPS use location_lat/lng.'
|
|
156
|
+
},
|
|
157
|
+
location_lat: {
|
|
158
|
+
type: 'number',
|
|
159
|
+
description: 'GPS latitude. Pair with location_lng.'
|
|
160
|
+
},
|
|
161
|
+
location_lng: {
|
|
162
|
+
type: 'number',
|
|
163
|
+
description: 'GPS longitude. Pair with location_lat.'
|
|
164
|
+
},
|
|
165
|
+
travel_time: {
|
|
166
|
+
type: 'number',
|
|
167
|
+
description: 'Minutes of travel time before due_date to allow for. Used by reminder scheduling.'
|
|
168
|
+
},
|
|
169
|
+
repeat_rule: {
|
|
170
|
+
type: 'string',
|
|
171
|
+
description: 'Recurrence rule. Accepted values depend on server (e.g., "daily", "weekly", "monthly", or RRULE). Creates a recurring series.'
|
|
172
|
+
},
|
|
173
|
+
notification_minutes_before: {
|
|
174
|
+
type: 'number',
|
|
175
|
+
description: 'Schedule a reminder N minutes before due_date. Pass null/omit to clear existing MCP reminder on update. Requires due_date.'
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
117
179
|
function createTaskDefinition() {
|
|
118
180
|
return {
|
|
119
181
|
name: 'create_task',
|
|
120
|
-
description: 'Create a new task in AI Note with optional notification',
|
|
182
|
+
description: 'Create a new task in AI Note with optional notification, location, recurrence, and details.',
|
|
121
183
|
inputSchema: {
|
|
122
184
|
type: 'object',
|
|
123
|
-
properties: {
|
|
124
|
-
content: {
|
|
125
|
-
type: 'string',
|
|
126
|
-
description: 'Task content'
|
|
127
|
-
},
|
|
128
|
-
is_important: {
|
|
129
|
-
type: 'boolean',
|
|
130
|
-
description: 'Mark task as important'
|
|
131
|
-
},
|
|
132
|
-
due_date: {
|
|
133
|
-
type: 'string',
|
|
134
|
-
description: 'Due date in ISO format'
|
|
135
|
-
},
|
|
136
|
-
category_id: {
|
|
137
|
-
type: 'string',
|
|
138
|
-
description: 'Category ID'
|
|
139
|
-
},
|
|
140
|
-
notification_minutes_before: {
|
|
141
|
-
type: 'number',
|
|
142
|
-
description: 'Set notification X minutes before due_date (e.g., 5 for 5 minutes before, 60 for 1 hour before). Requires due_date to be set.'
|
|
143
|
-
}
|
|
144
|
-
},
|
|
185
|
+
properties: { ...TASK_FIELD_SCHEMA },
|
|
145
186
|
required: ['content']
|
|
146
187
|
}
|
|
147
188
|
};
|
|
@@ -150,7 +191,7 @@ function createTaskDefinition() {
|
|
|
150
191
|
function updateTaskDefinition() {
|
|
151
192
|
return {
|
|
152
193
|
name: 'update_task',
|
|
153
|
-
description: 'Update an existing task',
|
|
194
|
+
description: 'Update an existing task — any subset of fields. Pass notification_minutes_before to reschedule the reminder (or null to clear it).',
|
|
154
195
|
inputSchema: {
|
|
155
196
|
type: 'object',
|
|
156
197
|
properties: {
|
|
@@ -158,18 +199,11 @@ function updateTaskDefinition() {
|
|
|
158
199
|
type: 'string',
|
|
159
200
|
description: 'Task ID'
|
|
160
201
|
},
|
|
161
|
-
content: {
|
|
162
|
-
type: 'string',
|
|
163
|
-
description: 'New task content'
|
|
164
|
-
},
|
|
165
|
-
is_important: {
|
|
166
|
-
type: 'boolean',
|
|
167
|
-
description: 'Update important status'
|
|
168
|
-
},
|
|
169
202
|
completed_at: {
|
|
170
203
|
type: 'string',
|
|
171
204
|
description: 'Mark as completed (ISO format) or null to uncomplete'
|
|
172
|
-
}
|
|
205
|
+
},
|
|
206
|
+
...TASK_FIELD_SCHEMA
|
|
173
207
|
},
|
|
174
208
|
required: ['id']
|
|
175
209
|
}
|
package/lib/utils/api-client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { getConfig } from '../core/config-manager.js';
|
|
3
3
|
|
|
4
|
+
const LOGIN_HINT = 'Please run `ainote-mcp login` to re-authenticate.';
|
|
5
|
+
|
|
4
6
|
function buildHeaders(authContext, config) {
|
|
5
7
|
const headers = {
|
|
6
8
|
'Content-Type': 'application/json'
|
|
@@ -22,7 +24,7 @@ function buildHeaders(authContext, config) {
|
|
|
22
24
|
} else {
|
|
23
25
|
const apiKey = authContext?.apiKey ?? config.api.key;
|
|
24
26
|
if (!apiKey) {
|
|
25
|
-
throw new Error(
|
|
27
|
+
throw new Error(`AINOTE_API_KEY environment variable is required for MCP authentication. ${LOGIN_HINT}`);
|
|
26
28
|
}
|
|
27
29
|
headers.Authorization = `McpKey ${apiKey}`;
|
|
28
30
|
}
|
|
@@ -54,6 +56,37 @@ function createToolCallRequest(toolName, arguments_) {
|
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
function isBearerAuth(authContext) {
|
|
60
|
+
return authContext?.type === 'oauth';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function tryRefreshAndUpdateHeaders(axiosInstance, config) {
|
|
64
|
+
// Only Bearer (access_token) flows can refresh. mcp_key flows cannot.
|
|
65
|
+
try {
|
|
66
|
+
const { refreshAccessToken } = await import('../auth/refresh.js');
|
|
67
|
+
const { getCredentials, setCredentials, clearCredentials } = await import('../auth/token-store.js');
|
|
68
|
+
const creds = await getCredentials(config.api.url);
|
|
69
|
+
if (!creds || !creds.refreshToken) {
|
|
70
|
+
return { refreshed: false, reason: 'no_refresh_token' };
|
|
71
|
+
}
|
|
72
|
+
const fresh = await refreshAccessToken({ apiUrl: config.api.url, refreshToken: creds.refreshToken });
|
|
73
|
+
const expiresAt = fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined;
|
|
74
|
+
await setCredentials(config.api.url, {
|
|
75
|
+
accessToken: fresh.access_token,
|
|
76
|
+
refreshToken: fresh.refresh_token,
|
|
77
|
+
expiresAt
|
|
78
|
+
});
|
|
79
|
+
axiosInstance.defaults.headers.common.Authorization = `Bearer ${fresh.access_token}`;
|
|
80
|
+
return { refreshed: true };
|
|
81
|
+
} catch (err) {
|
|
82
|
+
try {
|
|
83
|
+
const { clearCredentials } = await import('../auth/token-store.js');
|
|
84
|
+
await clearCredentials(config.api.url);
|
|
85
|
+
} catch { /* ignore */ }
|
|
86
|
+
return { refreshed: false, reason: err.message };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
export function createApiClient(authContext) {
|
|
58
91
|
const config = getConfig();
|
|
59
92
|
const headers = buildHeaders(authContext, config);
|
|
@@ -63,6 +96,46 @@ export function createApiClient(authContext) {
|
|
|
63
96
|
headers
|
|
64
97
|
});
|
|
65
98
|
|
|
99
|
+
// Intercept 401s on bearer-authed requests and try one refresh + retry.
|
|
100
|
+
axiosInstance.interceptors.response.use(
|
|
101
|
+
(response) => response,
|
|
102
|
+
async (error) => {
|
|
103
|
+
const status = error.response?.status;
|
|
104
|
+
const originalRequest = error.config || {};
|
|
105
|
+
if (status !== 401 || originalRequest._retriedRefresh) {
|
|
106
|
+
return Promise.reject(error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Determine which auth scheme was in play for the original request.
|
|
110
|
+
const sentAuth = originalRequest.headers?.Authorization || originalRequest.headers?.authorization || '';
|
|
111
|
+
const isBearer = isBearerAuth(authContext) || /^Bearer\s/i.test(sentAuth);
|
|
112
|
+
const isMcpKey = /^McpKey\s/i.test(sentAuth);
|
|
113
|
+
|
|
114
|
+
if (isBearer) {
|
|
115
|
+
const result = await tryRefreshAndUpdateHeaders(axiosInstance, config);
|
|
116
|
+
if (result.refreshed) {
|
|
117
|
+
originalRequest._retriedRefresh = true;
|
|
118
|
+
originalRequest.headers = {
|
|
119
|
+
...(originalRequest.headers || {}),
|
|
120
|
+
Authorization: axiosInstance.defaults.headers.common.Authorization
|
|
121
|
+
};
|
|
122
|
+
return axiosInstance.request(originalRequest);
|
|
123
|
+
}
|
|
124
|
+
const wrapped = new Error(`Authentication failed and refresh was not possible. ${LOGIN_HINT}`);
|
|
125
|
+
wrapped.cause = error;
|
|
126
|
+
return Promise.reject(wrapped);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isMcpKey) {
|
|
130
|
+
const wrapped = new Error(`MCP key was rejected by the server. ${LOGIN_HINT}`);
|
|
131
|
+
wrapped.cause = error;
|
|
132
|
+
return Promise.reject(wrapped);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Promise.reject(error);
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
66
139
|
// JSON-RPC helper 메서드 추가
|
|
67
140
|
axiosInstance.callTool = async (toolName, arguments_) => {
|
|
68
141
|
const rpcRequest = createToolCallRequest(toolName, arguments_);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ainote/mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for AI Note - Tasks
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "MCP server for AI Note - Tasks, Dev Docs, and multi-device file sync (CLAUDE.md, memory files, Cursor rules, Windsurf rules)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -55,8 +55,10 @@
|
|
|
55
55
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
56
56
|
"axios": "^1.6.0",
|
|
57
57
|
"cors": "^2.8.5",
|
|
58
|
+
"cross-keychain": "^1.1.0",
|
|
58
59
|
"express": "^5.1.0",
|
|
59
|
-
"morgan": "^1.10.0"
|
|
60
|
+
"morgan": "^1.10.0",
|
|
61
|
+
"open": "^10.2.0"
|
|
60
62
|
},
|
|
61
63
|
"devDependencies": {
|
|
62
64
|
"@jest/globals": "^29.7.0",
|
|
@@ -95,4 +97,4 @@
|
|
|
95
97
|
"access": "public",
|
|
96
98
|
"registry": "https://registry.npmjs.org/"
|
|
97
99
|
}
|
|
98
|
-
}
|
|
100
|
+
}
|