fortynote-mcp 1.1.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 +57 -0
- package/bin/fortynote-mcp.js +30 -0
- package/lib/client.js +86 -0
- package/lib/crypto.js +13 -0
- package/lib/server.js +62 -0
- package/package.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# fortynote-mcp
|
|
2
|
+
|
|
3
|
+
Connect your AI tools — Claude Desktop, Cursor, VS Code, Zed, any MCP client — to your **FortyNote** notes.
|
|
4
|
+
|
|
5
|
+
FortyNote notes are end-to-end encrypted, so this connector runs **on your own computer**: it signs in once,
|
|
6
|
+
unlocks your notes locally, and offers them to the AI app you choose through the Model Context Protocol.
|
|
7
|
+
Nothing is decrypted on FortyNote's servers, and FortyNote itself contains no AI.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
**Easiest:** in FortyNote open **Settings → Connect to AI tools → Create connection key**. The screen gives you a one-click
|
|
12
|
+
install for Claude Desktop (an extension file — no Node.js, no terminal), Cursor and VS Code, or a config snippet for other apps.
|
|
13
|
+
|
|
14
|
+
**Manual (terminal):**
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
npx fortynote-mcp login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Enter your FortyNote username and password (or `npx fortynote-mcp login --recovery-key` to use your recovery key).
|
|
21
|
+
If two-factor authentication is on, you'll be asked for a code. Your password is used once to unlock the notes
|
|
22
|
+
and is not stored; the session token and decryption key are saved to `~/.fortynote/mcp.json`, readable only by your user.
|
|
23
|
+
|
|
24
|
+
Then add FortyNote to your AI app's MCP configuration (`npx fortynote-mcp config` prints it):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "mcpServers": { "fortynote": { "command": "npx", "args": ["-y", "fortynote-mcp"] } } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Claude Desktop**: Settings → Developer → Edit Config → paste into `claude_desktop_config.json`, restart Claude.
|
|
31
|
+
- **Cursor**: Settings → MCP → Add new server, or `.cursor/mcp.json`.
|
|
32
|
+
- **VS Code (Copilot agent mode)**: `.vscode/mcp.json` → `"servers": { "fortynote": { "command": "npx", "args": ["-y", "fortynote-mcp"] } }`.
|
|
33
|
+
|
|
34
|
+
Read-only (the AI can search and read but never write): `"args": ["-y", "fortynote-mcp", "--read-only"]`.
|
|
35
|
+
|
|
36
|
+
## What the AI can do
|
|
37
|
+
|
|
38
|
+
| Tool | What it does |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `search_notes` | find notes by words in title, body or tags, optionally within a notebook |
|
|
41
|
+
| `read_note` | the full note as Markdown-style text |
|
|
42
|
+
| `list_notebooks`, `list_tags` | your structure, with counts |
|
|
43
|
+
| `list_tasks` | open tasks and checklist items across notes |
|
|
44
|
+
| `list_events` | calendar events in a date range (incl. subscribed calendars) |
|
|
45
|
+
| `create_note` | new note from Markdown (headings, lists, checklists, bold, links) — encrypted locally |
|
|
46
|
+
| `append_to_note` | add to an existing note |
|
|
47
|
+
|
|
48
|
+
Attachments are not exposed (only their names). The connector refreshes automatically when your notes change.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
`npx fortynote-mcp login` · `status` · `config` · `logout` (removes the local key) · `--read-only`
|
|
53
|
+
|
|
54
|
+
## Privacy
|
|
55
|
+
|
|
56
|
+
The connector talks only to your FortyNote server and to the AI app on your machine over stdio. What the AI app
|
|
57
|
+
does with the text it reads is governed by that app's own privacy terms — choose one you trust.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* fortynote-mcp — connect Claude Desktop, Cursor, VS Code and other MCP clients to your FortyNote notes.
|
|
3
|
+
Usage: npx fortynote-mcp login sign in once (password or recovery key stay on this machine)
|
|
4
|
+
npx fortynote-mcp run the MCP server (what your AI app launches)
|
|
5
|
+
npx fortynote-mcp status | logout | config */
|
|
6
|
+
import readline from 'node:readline'; import { stdin, stdout, stderr } from 'node:process';
|
|
7
|
+
import { readConfig, login, logout, DEFAULT_URL, CONFIG_FILE, Store, configFromEnv } from '../lib/client.js';
|
|
8
|
+
import { serve } from '../lib/server.js';
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2); const cmd = args.find(a => !a.startsWith('--')) || 'serve';
|
|
11
|
+
const opt = k => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : (process.env['FORTYNOTE_' + k.toUpperCase().replace(/-/g, '_')] || undefined); };
|
|
12
|
+
function ask(q, hidden) { return new Promise(res => { const rl = readline.createInterface({ input: stdin, output: stdout, terminal: true }); if (hidden) rl._writeToOutput = () => {}; stdout.write(q); rl.question('', a => { rl.close(); if (hidden) stdout.write('\n'); res(a); }); }); }
|
|
13
|
+
|
|
14
|
+
(async () => {
|
|
15
|
+
if (cmd === 'login') {
|
|
16
|
+
const url = opt('url') || DEFAULT_URL; const username = opt('username') || await ask('FortyNote username: ');
|
|
17
|
+
const useRk = args.includes('--recovery-key'); const password = useRk ? null : (opt('password') || await ask('Password (not stored, used once to unlock): ', true)); const recoveryKey = useRk ? (opt('key') || await ask('Recovery key: ')) : null;
|
|
18
|
+
try { const cfg = await login({ url, username, password, recoveryKey, code: opt('code'), askCode: msg => ask((msg || 'Two-factor code') + ': ') });
|
|
19
|
+
stdout.write(`\nSigned in as @${cfg.user} on ${cfg.url}.\nSession + decryption key saved to ${CONFIG_FILE} (readable only by your user account).\n\nAdd this to your AI app's MCP config, e.g. Claude Desktop → Settings → Developer → Edit config:\n\n${JSON.stringify({ mcpServers: { fortynote: { command: 'npx', args: ['-y', 'fortynote-mcp'] } } }, null, 2)}\n\nRead-only mode: add "--read-only" to args.\n`); }
|
|
20
|
+
catch (e) { stderr.write('Sign-in failed: ' + (e.message || e) + '\n'); process.exit(1); }
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let cfg; try { cfg = configFromEnv() || readConfig(); } catch (e) { stderr.write('fortynote-mcp: ' + e.message + '\n'); process.exit(1); }
|
|
24
|
+
if (cmd === 'logout') { if (cfg) await logout(cfg); stdout.write('Signed out; local key removed.\n'); return; }
|
|
25
|
+
if (cmd === 'config') { stdout.write(JSON.stringify({ mcpServers: { fortynote: { command: 'npx', args: ['-y', 'fortynote-mcp'] } } }, null, 2) + '\n'); return; }
|
|
26
|
+
if (cmd === 'status') { if (!cfg) { stdout.write('Not signed in. Run: npx fortynote-mcp login\n'); process.exit(1); } try { const s = await new Store(cfg).open(); await s.refresh(true); stdout.write(`@${cfg.user} on ${cfg.url}: ${s.live().length} notes, ${Object.keys(s.data.notebooks || {}).length} notebooks.\n`); } catch (e) { stderr.write('Cannot reach your notes: ' + (e.message || e) + (e.status === 401 ? '\nRun: npx fortynote-mcp login' : '') + '\n'); process.exit(1); } return; }
|
|
27
|
+
if (cmd !== 'serve') { stderr.write(`Unknown command "${cmd}". Use: login | status | logout | config\n`); process.exit(1); }
|
|
28
|
+
if (!cfg) { stderr.write('fortynote-mcp: no connection key. In FortyNote open Settings → Connect to AI tools and create one (or run: npx fortynote-mcp login).\n'); process.exit(1); }
|
|
29
|
+
await serve(cfg);
|
|
30
|
+
})();
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* FortyNote API client + local decryption. Keeps a decrypted in-memory cache that is refreshed when the account's revision changes. */
|
|
2
|
+
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
|
|
3
|
+
import { deriveKey, unwrapMK, importMK, exportMK, encJSON, decJSON, deriveAuth, normRK, b64 } from './crypto.js';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_DIR = process.env.FORTYNOTE_HOME || path.join(os.homedir(), '.fortynote');
|
|
6
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, 'mcp.json');
|
|
7
|
+
export const DEFAULT_URL = 'https://app.fortynote.com';
|
|
8
|
+
|
|
9
|
+
export function readConfig() { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (e) { return null; } }
|
|
10
|
+
export function writeConfig(cfg) { fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 }); try { fs.chmodSync(CONFIG_FILE, 0o600); } catch (e) {} }
|
|
11
|
+
export function clearConfig() { try { fs.unlinkSync(CONFIG_FILE); } catch (e) {} }
|
|
12
|
+
/* connection key made by the FortyNote app: fnk1.<base64url JSON {u: server url, t: connector session token, k: raw master key (base64)}> */
|
|
13
|
+
export function parseConnectKey(key) { const m = /^fnk1\.([A-Za-z0-9_-]+)$/.exec(String(key || '').trim()); if (!m) return null; try { const j = JSON.parse(Buffer.from(m[1], 'base64url').toString('utf8')); if (!j.u || !j.t || !j.k) return null; return { url: String(j.u).replace(/\/$/, ''), token: j.t, mk: j.k, user: j.n || 'me', uid: j.i || null, created: Date.now(), viaKey: true }; } catch (e) { return null; } }
|
|
14
|
+
export function configFromEnv() { const key = process.env.FORTYNOTE_CONNECT_KEY || (() => { const i = process.argv.indexOf('--key'); return i >= 0 ? process.argv[i + 1] : null; })(); if (!key) return null; const cfg = parseConnectKey(key); if (!cfg) throw new Error('The connection key is not valid. Create a new one in FortyNote → Settings → Connect to AI tools.'); return cfg; }
|
|
15
|
+
|
|
16
|
+
class ApiError extends Error { constructor(status, msg) { super(msg || 'HTTP ' + status); this.status = status; } }
|
|
17
|
+
async function call(url, token, p, opts = {}) {
|
|
18
|
+
const res = await fetch(url.replace(/\/$/, '') + '/api' + p, { ...opts, headers: { ...(token ? { authorization: 'Bearer ' + token } : {}), ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) } });
|
|
19
|
+
if (!res.ok) { let msg = ''; try { msg = (await res.json()).error; } catch (e) {} throw new ApiError(res.status, msg); }
|
|
20
|
+
return res;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ---- interactive login: password (or recovery key) never leaves this machine; only the derived auth key is sent ---- */
|
|
24
|
+
export async function login({ url = DEFAULT_URL, username, password, recoveryKey, code, askCode }) {
|
|
25
|
+
const uname = String(username || '').trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9._-]{2,31}$/.test(uname)) throw new Error('Enter your FortyNote username.');
|
|
26
|
+
const body = recoveryKey ? { username: uname, client: 'mcp', recoveryAuth: await deriveAuth(normRK(recoveryKey), 'recover:' + uname) } : { username: uname, client: 'mcp', authKey: await deriveAuth(password, uname) };
|
|
27
|
+
let j; for (let i = 0; i < 4; i++) { try { j = await (await call(url, null, '/login', { method: 'POST', body: JSON.stringify(code ? { ...body, code } : body) })).json(); break; } catch (e) { if (e.status !== 428 || !askCode) throw e; code = await askCode(e.message); } }
|
|
28
|
+
if (!j) throw new Error('Sign-in cancelled.');
|
|
29
|
+
const h = await (await call(url, j.token, '/header')).json();
|
|
30
|
+
let mk; if (recoveryKey) { if (!h.rwrapped) throw new Error('This account has no recovery key on file.'); mk = await unwrapMK(h.rwrapped, await deriveKey(normRK(recoveryKey), b64.dec(h.rsalt))); } else mk = await unwrapMK(h.wrapped, await deriveKey(password, b64.dec(h.salt), h.iter || 600000));
|
|
31
|
+
await decJSON(mk, h.verifier); /* throws if the key is wrong */
|
|
32
|
+
const cfg = { url: url.replace(/\/$/, ''), user: j.username || uname, uid: j.uid, token: j.token, mk: await exportMK(mk), created: Date.now() };
|
|
33
|
+
writeConfig(cfg); return cfg;
|
|
34
|
+
}
|
|
35
|
+
export async function logout(cfg) { try { await call(cfg.url, cfg.token, '/logout', { method: 'POST' }); } catch (e) {} clearConfig(); }
|
|
36
|
+
|
|
37
|
+
/* ---- store ---- */
|
|
38
|
+
export class Store {
|
|
39
|
+
constructor(cfg) { this.cfg = cfg; this.mk = null; this.rev = null; this.notes = {}; this.etags = {}; this.data = null; this.loaded = false; }
|
|
40
|
+
async open() { this.mk = await importMK(this.cfg.mk); return this; }
|
|
41
|
+
api(p, opts) { return call(this.cfg.url, this.cfg.token, p, opts); }
|
|
42
|
+
async refresh(force = false) {
|
|
43
|
+
const mf = await (await this.api('/manifest')).json(); /* { rev, notes: {id: etag}, data: etag, header: etag } */
|
|
44
|
+
if (!force && this.loaded && mf.rev === this.rev) return false;
|
|
45
|
+
const want = Object.keys(mf.notes || {}).filter(id => !this.etags[id] || this.etags[id] !== mf.notes[id]); for (const id of Object.keys(this.notes)) if (!mf.notes[id]) { delete this.notes[id]; delete this.etags[id]; }
|
|
46
|
+
for (let i = 0; i < want.length; i += 40) { const j = await (await this.api('/notes/batch', { method: 'POST', body: JSON.stringify({ ids: want.slice(i, i + 40) }) })).json(); for (const [id, doc] of Object.entries(j.notes || {})) { try { this.notes[id] = await decJSON(this.mk, doc); this.etags[id] = j.etags[id]; } catch (e) { /* team note or corrupt: skip */ } } }
|
|
47
|
+
if (!this.data || mf.data !== this.dataEtag) { try { const d = await (await this.api('/data')).json(); this.data = await decJSON(this.mk, d); this.dataEtag = mf.data; } catch (e) { this.data = this.data || { notebooks: {}, events: {} }; } }
|
|
48
|
+
this.rev = mf.rev; this.loaded = true; return true;
|
|
49
|
+
}
|
|
50
|
+
live() { return Object.values(this.notes).filter(n => !n.deleted); }
|
|
51
|
+
notebookName(id) { const nb = this.data && this.data.notebooks && this.data.notebooks[id]; return nb ? nb.name : 'Notebook'; }
|
|
52
|
+
notebookByName(name) { const nbs = Object.values((this.data && this.data.notebooks) || {}); const q = String(name || '').trim().toLowerCase(); return nbs.find(n => n.name.toLowerCase() === q) || nbs.find(n => n.name.toLowerCase().includes(q)) || null; }
|
|
53
|
+
defaultNotebook() { const nbs = Object.values((this.data && this.data.notebooks) || {}); const set = this.data && this.data.settings && this.data.settings.defaultNb; return (set && nbs.find(n => n.id === set)) || nbs.sort((a, b) => (a.created || 0) - (b.created || 0))[0] || null; }
|
|
54
|
+
async saveNote(n) { const doc = await encJSON(this.mk, n); const body = JSON.stringify(doc); if (body.length > 240000) throw new Error('Note too large (over 240 KB of text).'); const j = await (await this.api('/notes/' + n.id, { method: 'PUT', body })).json(); this.notes[n.id] = n; this.etags[n.id] = j.etag; this.rev = j.rev; return n; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---- html <-> text helpers ---- */
|
|
58
|
+
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
59
|
+
export function htmlToText(html) {
|
|
60
|
+
let s = String(html || '');
|
|
61
|
+
s = s.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '').replace(/<div class="task"[^>]*data-done="(0|1)"[^>]*>[\s\S]*?<span class="tt">([\s\S]*?)<\/span>[\s\S]*?<\/div>/gi, (m, d, t) => `\n- [${d === '1' ? 'x' : ' '}] ${t.replace(/<[^>]+>/g, '')}\n`);
|
|
62
|
+
s = s.replace(/<li[^>]*data-done="1"[^>]*>/gi, '\n- [x] ').replace(/<li[^>]*>\s*<input[^>]*checked[^>]*>/gi, '\n- [x] ').replace(/<li[^>]*>\s*<input[^>]*>/gi, '\n- [ ] ').replace(/<li[^>]*>/gi, '\n- ');
|
|
63
|
+
s = s.replace(/<h1[^>]*>/gi, '\n# ').replace(/<h2[^>]*>/gi, '\n## ').replace(/<h3[^>]*>/gi, '\n### ').replace(/<\/(p|div|h[1-6]|li|tr|blockquote|pre|details|summary)>/gi, '\n').replace(/<br\s*\/?>/gi, '\n').replace(/<hr[^>]*>/gi, '\n---\n').replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, '**$2**').replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, '_$2_').replace(/<code>([\s\S]*?)<\/code>/gi, '`$1`');
|
|
64
|
+
s = s.replace(/<a [^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (m, h, t) => /^#note\//.test(h) ? t : `[${t.replace(/<[^>]+>/g, '')}](${h})`).replace(/<img [^>]*alt="([^"]*)"[^>]*>/gi, '[image: $1]').replace(/<img[^>]*>/gi, '[image]').replace(/<div class="pdfcard"[\s\S]*?<b>([^<]*)<\/b>[\s\S]*?<\/div>\s*<\/div>/gi, '[attachment: $1]');
|
|
65
|
+
s = s.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\u200B/g, '');
|
|
66
|
+
return s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
67
|
+
}
|
|
68
|
+
export function textToHtml(md) {
|
|
69
|
+
const lines = String(md || '').replace(/\r/g, '').split('\n'); const out = []; let list = null, para = [], code = false;
|
|
70
|
+
const inline = t => esc(t).replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/(^|[^*])\*([^*]+)\*/g, '$1<i>$2</i>').replace(/_([^_]+)_/g, '<i>$1</i>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
71
|
+
const flushP = () => { if (para.length) { out.push('<p>' + para.map(inline).join('<br>') + '</p>'); para = []; } }; const flushL = () => { if (list) { out.push(list.tag === 'checklist' ? `<ul class="checklist">${list.items.join('')}</ul>` : `<${list.tag}>${list.items.join('')}</${list.tag}>`); list = null; } };
|
|
72
|
+
for (const raw of lines) { const l = raw;
|
|
73
|
+
if (/^```/.test(l)) { flushP(); flushL(); if (code) { out.push('</code></pre>'); code = false; } else { out.push('<pre><code>'); code = true; } continue; } if (code) { out.push(esc(l) + '\n'); continue; }
|
|
74
|
+
let m; if ((m = /^(#{1,3})\s+(.*)$/.exec(l))) { flushP(); flushL(); out.push(`<h${m[1].length}>${inline(m[2])}</h${m[1].length}>`); continue; }
|
|
75
|
+
if ((m = /^\s*[-*]\s+\[( |x|X)\]\s+(.*)$/.exec(l))) { flushP(); if (!list || list.tag !== 'checklist') { flushL(); list = { tag: 'checklist', items: [] }; } const done = m[1] !== ' '; list.items.push(`<li data-done="${done ? 1 : 0}"><input type="checkbox"${done ? ' checked' : ''}><span>${inline(m[2])}</span></li>`); continue; }
|
|
76
|
+
if ((m = /^\s*[-*]\s+(.*)$/.exec(l))) { flushP(); if (!list || list.tag !== 'ul') { flushL(); list = { tag: 'ul', items: [] }; } list.items.push(`<li>${inline(m[1])}</li>`); continue; }
|
|
77
|
+
if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(l))) { flushP(); if (!list || list.tag !== 'ol') { flushL(); list = { tag: 'ol', items: [] }; } list.items.push(`<li>${inline(m[1])}</li>`); continue; }
|
|
78
|
+
if ((m = /^>\s?(.*)$/.exec(l))) { flushP(); flushL(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); continue; }
|
|
79
|
+
if (/^\s*(-{3,}|\*{3,})\s*$/.test(l)) { flushP(); flushL(); out.push('<hr>'); continue; }
|
|
80
|
+
if (!l.trim()) { flushP(); flushL(); continue; }
|
|
81
|
+
flushL(); para.push(l);
|
|
82
|
+
}
|
|
83
|
+
flushP(); flushL(); if (code) out.push('</code></pre>'); return out.join('');
|
|
84
|
+
}
|
|
85
|
+
export const stripText = html => htmlToText(html).replace(/\s+/g, ' ').trim();
|
|
86
|
+
export const newId = () => 'n_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Same primitives as the FortyNote web app (WebCrypto), so the connector can unwrap the master key locally. */
|
|
2
|
+
const te = new TextEncoder(), td = new TextDecoder();
|
|
3
|
+
export const b64 = { enc: buf => Buffer.from(buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf).toString('base64'), dec: s => new Uint8Array(Buffer.from(s, 'base64')) };
|
|
4
|
+
export const rnd = n => crypto.getRandomValues(new Uint8Array(n));
|
|
5
|
+
export const KDF_ITER = 600000;
|
|
6
|
+
export async function deriveKey(password, salt, iter = KDF_ITER) { const base = await crypto.subtle.importKey('raw', te.encode(password.normalize('NFKC')), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations: iter, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); }
|
|
7
|
+
export async function unwrapMK(w, pwKey) { const raw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64.dec(w.iv) }, pwKey, b64.dec(w.ct)); return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); }
|
|
8
|
+
export async function importMK(rawB64) { return crypto.subtle.importKey('raw', b64.dec(rawB64), { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); }
|
|
9
|
+
export async function exportMK(mk) { return b64.enc(await crypto.subtle.exportKey('raw', mk)); }
|
|
10
|
+
export async function encJSON(mk, obj) { const iv = rnd(12); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, mk, te.encode(JSON.stringify(obj))); return { iv: b64.enc(iv), ct: b64.enc(ct) }; }
|
|
11
|
+
export async function decJSON(mk, doc) { return JSON.parse(td.decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64.dec(doc.iv) }, mk, b64.dec(doc.ct)))); }
|
|
12
|
+
export async function deriveAuth(secret, context, iter = 120000) { const salt = await crypto.subtle.digest('SHA-256', te.encode('azizi-auth:' + context)); const base = await crypto.subtle.importKey('raw', te.encode(String(secret).normalize('NFKC')), 'PBKDF2', false, ['deriveBits']); return b64.enc(await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: iter, hash: 'SHA-256' }, base, 256)); }
|
|
13
|
+
export const normRK = s => String(s || '').toUpperCase().replace(/[^A-Z2-9]/g, '').replace(/(.{4})(?=.)/g, '$1-');
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* Minimal MCP server over stdio (JSON-RPC 2.0, newline-delimited). No dependencies. */
|
|
2
|
+
import { Store, htmlToText, textToHtml, stripText, newId } from './client.js';
|
|
3
|
+
|
|
4
|
+
const PROTOCOL = '2024-11-05';
|
|
5
|
+
const READ_ONLY = /^(1|on|true|yes)$/i.test(process.env.FORTYNOTE_MCP_READONLY || process.env.FORTYNOTE_READ_ONLY || '') || process.argv.includes('--read-only');
|
|
6
|
+
|
|
7
|
+
const TOOLS = [
|
|
8
|
+
{ name: 'search_notes', description: 'Search the user\'s FortyNote notes by words in the title, body or tags. Returns matching notes (newest first) with a short snippet. Use read_note to get the full text.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Words to look for (all must match). Leave empty to list recent notes.' }, notebook: { type: 'string', description: 'Only notes in this notebook (name)' }, tag: { type: 'string', description: 'Only notes with this tag' }, limit: { type: 'integer', description: 'Max results (default 20, max 100)' } } } },
|
|
9
|
+
{ name: 'read_note', description: 'Read one note in full: title, notebook, tags, dates and the body as Markdown-style text.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Note id from search_notes' } }, required: ['id'] } },
|
|
10
|
+
{ name: 'list_notebooks', description: 'List the user\'s notebooks with note counts.', inputSchema: { type: 'object', properties: {} } },
|
|
11
|
+
{ name: 'list_tags', description: 'List all tags with counts.', inputSchema: { type: 'object', properties: {} } },
|
|
12
|
+
{ name: 'list_tasks', description: 'List open (unchecked) tasks and checklist items across all notes, with the note they live in and any due date.', inputSchema: { type: 'object', properties: { include_done: { type: 'boolean', description: 'Also include completed items' }, limit: { type: 'integer' } } } },
|
|
13
|
+
{ name: 'list_events', description: 'List calendar events in a date range (the user\'s own events plus subscribed calendars).', inputSchema: { type: 'object', properties: { from: { type: 'string', description: 'ISO date, default today' }, to: { type: 'string', description: 'ISO date, default 14 days from now' } } } },
|
|
14
|
+
...(READ_ONLY ? [] : [
|
|
15
|
+
{ name: 'create_note', description: 'Create a new note. Content may use simple Markdown (headings #, lists -, checklists - [ ], bold **, links). The note is encrypted on this machine before it is stored.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, notebook: { type: 'string', description: 'Notebook name (default: the user\'s default notebook)' }, tags: { type: 'array', items: { type: 'string' } } }, required: ['title', 'content'] } },
|
|
16
|
+
{ name: 'append_to_note', description: 'Append Markdown-style content to the end of an existing note.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' } }, required: ['id', 'content'] } },
|
|
17
|
+
]),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const text = t => ({ content: [{ type: 'text', text: t }] });
|
|
21
|
+
const fmtDate = ms => ms ? new Date(ms).toISOString().slice(0, 16).replace('T', ' ') : '';
|
|
22
|
+
const snippet = (n, q) => { const t = (n.text || stripText(n.html)); if (!q) return t.slice(0, 160); const i = t.toLowerCase().indexOf(q.toLowerCase()); const s = Math.max(0, i - 60); return (s ? '…' : '') + t.slice(s, s + 180) + (t.length > s + 180 ? '…' : ''); };
|
|
23
|
+
|
|
24
|
+
export async function serve(cfg) {
|
|
25
|
+
const store = await new Store(cfg).open();
|
|
26
|
+
const tagsOf = n => (n.tags || []).map(t => String(t).toLowerCase());
|
|
27
|
+
async function run(name, a = {}) {
|
|
28
|
+
await store.refresh();
|
|
29
|
+
if (name === 'search_notes') {
|
|
30
|
+
const words = String(a.query || '').toLowerCase().split(/\s+/).filter(Boolean); const nb = a.notebook ? store.notebookByName(a.notebook) : null; if (a.notebook && !nb) return text(`No notebook called "${a.notebook}". Notebooks: ${Object.values(store.data.notebooks || {}).map(x => x.name).join(', ')}`);
|
|
31
|
+
const tag = a.tag ? String(a.tag).toLowerCase() : null; const limit = Math.min(100, Math.max(1, +a.limit || 20));
|
|
32
|
+
const hits = store.live().filter(n => (!nb || n.notebook === nb.id) && (!tag || tagsOf(n).includes(tag)) && words.every(w => ((n.title || '') + ' ' + (n.text || stripText(n.html)) + ' ' + tagsOf(n).join(' ')).toLowerCase().includes(w))).sort((x, y) => (y.updated || 0) - (x.updated || 0)).slice(0, limit);
|
|
33
|
+
if (!hits.length) return text('No notes match.');
|
|
34
|
+
return text(hits.map(n => `• ${n.title || 'Untitled'} [id: ${n.id}]\n ${store.notebookName(n.notebook)}${n.tags && n.tags.length ? ' · tags: ' + n.tags.join(', ') : ''} · updated ${fmtDate(n.updated)}\n ${snippet(n, words[0] || '')}`).join('\n\n'));
|
|
35
|
+
}
|
|
36
|
+
if (name === 'read_note') { const n = store.notes[a.id]; if (!n || n.deleted) return text('No note with that id.'); return text(`# ${n.title || 'Untitled'}\nNotebook: ${store.notebookName(n.notebook)}${n.tags && n.tags.length ? '\nTags: ' + n.tags.join(', ') : ''}\nCreated: ${fmtDate(n.created)} · Updated: ${fmtDate(n.updated)}\nid: ${n.id}\n\n${htmlToText(n.html)}`); }
|
|
37
|
+
if (name === 'list_notebooks') { const counts = {}; for (const n of store.live()) counts[n.notebook] = (counts[n.notebook] || 0) + 1; const nbs = Object.values(store.data.notebooks || {}).sort((x, y) => x.name.localeCompare(y.name)); return text(nbs.map(x => `• ${x.name}${x.stack ? ` (stack: ${x.stack})` : ''} — ${counts[x.id] || 0} notes`).join('\n') || 'No notebooks.'); }
|
|
38
|
+
if (name === 'list_tags') { const counts = {}; for (const n of store.live()) for (const t of n.tags || []) counts[t] = (counts[t] || 0) + 1; const ts = Object.entries(counts).sort((x, y) => y[1] - x[1]); return text(ts.map(([t, c]) => `• ${t} — ${c}`).join('\n') || 'No tags.'); }
|
|
39
|
+
if (name === 'list_tasks') { const out = []; for (const n of store.live()) { const re = /<div class="task"[^>]*data-done="(0|1)"[^>]*>([\s\S]*?)<\/div>\s*(?=<|$)/gi; let m; while ((m = re.exec(n.html || ''))) { const done = m[1] === '1'; if (done && !a.include_done) continue; const tt = (/<span class="tt">([\s\S]*?)<\/span>/.exec(m[2]) || [])[1] || ''; const due = (/data-due="(\d+)"/.exec(m[0]) || [])[1]; out.push({ done, text: stripText(tt), due: due ? +due : null, note: n }); } const re2 = /<li[^>]*data-done="(0|1)"[^>]*>([\s\S]*?)<\/li>/gi; while ((m = re2.exec(n.html || ''))) { const done = m[1] === '1'; if (done && !a.include_done) continue; out.push({ done, text: stripText(m[2]), due: null, note: n }); } } out.sort((x, y) => (x.due || Infinity) - (y.due || Infinity)); const lim = Math.min(200, +a.limit || 50); return text(out.slice(0, lim).map(t => `- [${t.done ? 'x' : ' '}] ${t.text}${t.due ? ' (due ' + new Date(t.due).toISOString().slice(0, 10) + ')' : ''} — in "${t.note.title || 'Untitled'}" [id: ${t.note.id}]`).join('\n') || 'No open tasks.'); }
|
|
40
|
+
if (name === 'list_events') { const from = a.from ? Date.parse(a.from) : Date.now() - 864e5 / 24; const to = a.to ? Date.parse(a.to) + 864e5 : Date.now() + 14 * 864e5; const evs = Object.values((store.data && store.data.events) || {}).filter(e => e.start >= from && e.start <= to).sort((x, y) => x.start - y.start); return text(evs.map(e => `• ${new Date(e.start).toISOString().slice(0, e.allDay ? 10 : 16).replace('T', ' ')}${e.end && !e.allDay ? '–' + new Date(e.end).toISOString().slice(11, 16) : ''} ${e.title}${e.loc ? ' @ ' + e.loc : ''}${e.sub ? ' (external calendar)' : ''}`).join('\n') || 'No events in that range.'); }
|
|
41
|
+
if (name === 'create_note') { if (READ_ONLY) throw new Error('read-only'); const nb = a.notebook ? store.notebookByName(a.notebook) : store.defaultNotebook(); if (!nb) throw new Error('No notebook found.'); const now = Date.now(); const html = textToHtml(a.content); const n = { id: newId(), title: String(a.title || '').slice(0, 300), html, text: stripText(html), notebook: nb.id, tags: (a.tags || []).map(t => String(t).trim()).filter(Boolean).slice(0, 20), created: now, updated: now, pinned: false, deleted: false, reminder: null, history: [], task: false, via: 'mcp' }; await store.saveNote(n); return text(`Created "${n.title}" in ${nb.name} [id: ${n.id}]`); }
|
|
42
|
+
if (name === 'append_to_note') { if (READ_ONLY) throw new Error('read-only'); const n = store.notes[a.id]; if (!n || n.deleted) return text('No note with that id.'); n.html = (n.html || '') + textToHtml(a.content); n.text = stripText(n.html); n.updated = Date.now(); await store.saveNote(n); return text(`Appended to "${n.title || 'Untitled'}".`); }
|
|
43
|
+
throw new Error('Unknown tool: ' + name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const out = msg => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
47
|
+
const handle = async req => {
|
|
48
|
+
const { id, method, params } = req;
|
|
49
|
+
try {
|
|
50
|
+
if (method === 'initialize') return out({ jsonrpc: '2.0', id, result: { protocolVersion: params && params.protocolVersion && /^\d{4}-\d{2}-\d{2}$/.test(params.protocolVersion) ? params.protocolVersion : PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'fortynote', version: '1.0.0' }, instructions: `FortyNote notes for @${cfg.user}. Notes are end-to-end encrypted; this connector decrypts them locally.${READ_ONLY ? ' Read-only mode.' : ''}` } });
|
|
51
|
+
if (method === 'ping') return out({ jsonrpc: '2.0', id, result: {} });
|
|
52
|
+
if (method === 'tools/list') return out({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
53
|
+
if (method === 'tools/call') { try { const r = await run(params.name, params.arguments || {}); return out({ jsonrpc: '2.0', id, result: r }); } catch (e) { return out({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'Error: ' + (e.message || e) }], isError: true } }); } }
|
|
54
|
+
if (method && method.startsWith('notifications/')) return; /* no response for notifications */
|
|
55
|
+
if (id !== undefined) out({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } });
|
|
56
|
+
} catch (e) { if (id !== undefined) out({ jsonrpc: '2.0', id, error: { code: -32603, message: e.message || String(e) } }); }
|
|
57
|
+
};
|
|
58
|
+
let buf = ''; process.stdin.setEncoding('utf8');
|
|
59
|
+
process.stdin.on('data', chunk => { buf += chunk; let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch (e) { out({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); continue; } handle(msg); } });
|
|
60
|
+
process.stdin.on('end', () => process.exit(0));
|
|
61
|
+
try { await store.refresh(true); process.stderr.write(`fortynote-mcp: ready (@${cfg.user}, ${store.live().length} notes${READ_ONLY ? ', read-only' : ''})\n`); } catch (e) { process.stderr.write('fortynote-mcp: could not load notes — ' + (e.message || e) + (e.status === 401 ? (cfg.viaKey ? ' — the connection key was revoked or expired; create a new one in FortyNote → Settings → Connect to AI tools' : ' (run: npx fortynote-mcp login)') : '') + '\n'); }
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fortynote-mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Connect AI tools (Claude Desktop, Cursor, VS Code, …) to your FortyNote notes with MCP. Runs on your own machine: notes are decrypted locally and never leave it unencrypted.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "fortynote-mcp": "bin/fortynote-mcp.js" },
|
|
7
|
+
"files": ["bin", "lib", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": ["fortynote", "mcp", "notes", "model-context-protocol", "claude", "end-to-end-encryption"],
|
|
11
|
+
"homepage": "https://fortynote.com"
|
|
12
|
+
}
|