@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.
@@ -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
  }
@@ -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('AINOTE_API_KEY environment variable is required for MCP authentication');
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainote/mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
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",
@@ -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
+ }