1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-06-18 20:16:34 +02:00

Compare commits

...

17 Commits

Author SHA1 Message Date
Laurent Cozic 59b27a0b51 Merge branch 'dev' into malformed_id 2026-06-18 09:39:41 +01:00
renovate[bot] d37b6f3751 fix(deps): update dependency tar to v7.5.12 (#15708)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 09:18:03 +01:00
Henry Heino 7c36f326ac Server: Make LDAP login logic safer (#15707) 2026-06-18 08:54:11 +01:00
Keshav d7594a3bb0 Chore: Resolves #15597: Add note lock backend path (#15673) 2026-06-18 00:16:12 +01:00
Laurent Cozic 4e737f397a Server: Fix orphaned items task timing out on large databases (#15704) 2026-06-17 23:50:47 +01:00
Laurent Cozic 42af5e7339 Server: Speed up batch deletes by using whereIn instead of OR chains (#15705) 2026-06-17 23:48:57 +01:00
Henry Heino 6701668e69 Chore: Doc: Add (currently disabled) website/checkout logic for a self-hosted plan (#15675) 2026-06-17 22:22:01 +01:00
renovate[bot] dafb6b05f1 fix(deps): update dependency rate-limiter-flexible to v9.1.1 (#15703)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-06-17 19:18:56 +01:00
Laurent Cozic 6b8b8d0694 Doc: Fixed docu build 2026-06-17 13:47:36 +01:00
Laurent Cozic 9853882787 Doc: Improve AI-related documentation 2026-06-17 13:18:43 +01:00
renovate[bot] 324fa333d4 fix(deps): update dependency rate-limiter-flexible to v9 (#15702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 13:15:10 +01:00
renovate[bot] 0818e7856e fix(deps): update dependency react-native-svg to v15.15.4 (#15697)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 10:37:33 +00:00
Laurent Cozic 4020986f29 Desktop: Add MCP server (#15699) 2026-06-17 11:31:51 +01:00
renovate[bot] 68543655ce chore(deps): update dependency @types/serviceworker to v0.0.194 (#15700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 09:04:36 +01:00
Laurent Cozic 4624a5a892 Merge branch 'dev' into malformed_id 2026-06-14 00:29:15 +01:00
Laurent Cozic 541fdfb2ea Merge branch 'dev' into malformed_id 2026-06-11 14:34:30 +01:00
Laurent Cozic 1068d9108d update 2026-05-26 13:46:28 +01:00
62 changed files with 2635 additions and 239 deletions
+21
View File
@@ -1489,6 +1489,7 @@ packages/lib/models/utils/paginatedFeed.js
packages/lib/models/utils/paginationToSql.js
packages/lib/models/utils/readOnly.test.js
packages/lib/models/utils/readOnly.js
packages/lib/models/utils/resourceUtils.test.js
packages/lib/models/utils/resourceUtils.js
packages/lib/models/utils/types.js
packages/lib/models/utils/userData.test.js
@@ -1653,6 +1654,20 @@ packages/lib/services/keychain/KeychainServiceDriver.dummy.js
packages/lib/services/keychain/KeychainServiceDriver.electron.js
packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.js
@@ -1663,6 +1678,11 @@ packages/lib/services/noteList/renderTemplate.js
packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1760,6 +1780,7 @@ packages/lib/services/rest/routes/events.js
packages/lib/services/rest/routes/folders.test.js
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/mcp.js
packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
+21
View File
@@ -1515,6 +1515,7 @@ packages/lib/models/utils/paginatedFeed.js
packages/lib/models/utils/paginationToSql.js
packages/lib/models/utils/readOnly.test.js
packages/lib/models/utils/readOnly.js
packages/lib/models/utils/resourceUtils.test.js
packages/lib/models/utils/resourceUtils.js
packages/lib/models/utils/types.js
packages/lib/models/utils/userData.test.js
@@ -1679,6 +1680,20 @@ packages/lib/services/keychain/KeychainServiceDriver.dummy.js
packages/lib/services/keychain/KeychainServiceDriver.electron.js
packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.js
@@ -1689,6 +1704,11 @@ packages/lib/services/noteList/renderTemplate.js
packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1786,6 +1806,7 @@ packages/lib/services/rest/routes/events.js
packages/lib/services/rest/routes/folders.test.js
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/mcp.js
packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
@@ -36,7 +36,18 @@
{{#featureLabelsOff}}
<p class="unchecked-text"><i class="fas fa-times feature feature-off"></i>{{label}}</p>
{{/featureLabelsOff}}
{{#pricingTable}}
<strong translate>Pricing</strong>
<table class="table">
<tbody>
{{#rows}}
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
{{/rows}}
</tbody>
</table>
{{/pricingTable}}
<p class="text-center subscribe-wrapper">
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton cfa-button">{{cfaLabel}}</a>
@@ -58,29 +69,27 @@
const buttonId = 'subscribeButton-' + planName;
const buttonElement = document.getElementById(buttonId);
if (stripePricesIds.monthly) {
function handleResult() {
console.info('Redirected to checkout');
function handleResult() {
console.info('Redirected to checkout');
}
buttonElement.addEventListener("click", function(evt) {
const priceId = stripePricesIds[subscriptionPeriod];
if (!priceId) {
console.error('Invalid period: ' + subscriptionPeriod);
return;
}
buttonElement.addEventListener("click", function(evt) {
evt.preventDefault();
evt.preventDefault();
const priceId = stripePricesIds[subscriptionPeriod];
if (!priceId) {
console.error('Invalid period: ' + subscriptionPeriod);
return;
}
createCheckoutSession(priceId).then(function(data) {
stripe.redirectToCheckout({
sessionId: data.sessionId
})
.then(handleResult);
});
createCheckoutSession(priceId).then(function(data) {
stripe.redirectToCheckout({
sessionId: data.sessionId
})
.then(handleResult);
});
}
});
for (const button of document.querySelectorAll('button.action-toggleIncreaseStorage')) {
button.onclick = () => {
@@ -250,6 +250,8 @@
$('.toggle-button-self').click((event) => {
event.preventDefault();
// Self-hosting is currently yearly-only
applyPeriod('yearly');
setHostingType('self');
});
+1
View File
@@ -5,6 +5,7 @@
- Tabs for indentation
- Single quotes for strings
- Proper TypeScript types (avoid `any`)
- Don't annotate return types or const types when TypeScript can infer them. Annotate only when TypeScript would otherwise infer `any`.
- Comments should be only with `//` and should not contain jsdoc syntax
- If you duplicate a substantial block of code, add a comment above it noting the duplication and referencing the original location.
- When creating Jest tests, there should be only one `describe()` statement in the file.
+2 -2
View File
@@ -81,7 +81,7 @@
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.6",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.15.3",
"react-native-svg": "15.15.4",
"react-native-url-polyfill": "2.0.0",
"react-native-version-info": "1.1.1",
"react-native-webview": "13.16.1",
@@ -118,7 +118,7 @@
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.34",
"@types/serviceworker": "0.0.193",
"@types/serviceworker": "0.0.194",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
+4
View File
@@ -71,6 +71,8 @@ import NavService from './services/NavService';
import getAppName from './getAppName';
import PerformanceLogger from './PerformanceLogger';
import Synchronizer from './Synchronizer';
import NoteLockKey from './services/noteLock/NoteLockKey';
import NoteLockService from './services/noteLock/NoteLockService';
const appLogger: LoggerWrapper = Logger.create('App');
const perfLogger = PerformanceLogger.create();
@@ -136,6 +138,8 @@ export default class BaseApplication {
ResourceService.isRunningInBackground_ = false;
// ResourceService.isRunningInBackground_ = false;
ResourceFetcher.instance_ = null;
NoteLockKey.destroyInstance();
NoteLockService.destroyInstance();
EncryptionService.instance_ = null;
DecryptionWorker.instance_ = null;
+6 -6
View File
@@ -693,18 +693,18 @@ export default class Synchronizer {
// a few seconds ahead of what it was set with setTimestamp()
try {
remoteContent = await this.apiCall('get', path);
if (!remoteContent) throw new Error(`Got metadata for path but could not fetch content: ${path}`);
remoteContent = await BaseItem.unserialize(remoteContent);
} catch (error) {
if (error.code === 'rejectedByTarget') {
if (error.code === 'rejectedByTarget' || error.code === 'malformedItem') {
this.progressReport_.errors.push(error);
logger.warn(`Rejected by target: ${path}: ${error.message}`);
logger.warn(`Skipping item from sync target: ${path}: ${error.message}`);
completeItemProcessing(path);
continue;
} else {
throw error;
}
}
if (!remoteContent) throw new Error(`Got metadata for path but could not fetch content: ${path}`);
remoteContent = await BaseItem.unserialize(remoteContent);
if (remoteContent.updated_time > local.sync_time) {
// Since, in this loop, we are only dealing with items that require sync, if the
@@ -1020,9 +1020,9 @@ export default class Synchronizer {
}
}
} catch (error) {
if (error.code === 'rejectedByTarget') {
if (error.code === 'rejectedByTarget' || error.code === 'malformedItem') {
this.progressReport_.errors.push(error);
logger.warn(`Rejected by target: ${path}: ${error.message}`);
logger.warn(`Skipping item from sync target: ${path}: ${error.message}`);
action = null;
} else {
error.message = `On file ${path}: ${error.message}`;
+38
View File
@@ -191,4 +191,42 @@ three line \\n no escape`)).toBe(0);
const note = await Note.save({ id }, { isNew: true });
expect(await BaseItem.loadItemById(note.id)).toMatchObject({ id });
});
// Sync ingestion concatenates resource.id and resource.file_extension into
// a local file path; a malformed id like `../../foo` would escape the
// resource directory. unserialize() must reject these.
it.each([
'../../escape',
'../foo',
'foo/bar',
'foo\\bar',
'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ',
'short',
])('should reject items with malformed IDs during unserialize (id: %j)', async (id) => {
const serialized = `poc-resource\n\nid: ${id}\ntype_: 4`;
await expect(BaseItem.unserialize(serialized)).rejects.toMatchObject({
code: 'malformedItem',
message: expect.stringMatching(/Invalid item ID/),
});
});
it.each([
'../foo',
'foo/bar',
'foo\\bar',
'..',
])('should reject items with malformed file_extension during unserialize (ext: %j)', async (ext) => {
const serialized = `poc-resource\n\nid: 00000000000000000000000000000001\nfile_extension: ${ext}\ntype_: 4`;
await expect(BaseItem.unserialize(serialized)).rejects.toMatchObject({
code: 'malformedItem',
message: expect.stringMatching(/Invalid file extension/),
});
});
it('should accept well-formed resource items', async () => {
const serialized = 'poc-resource\n\nid: 00000000000000000000000000000001\nfile_extension: txt\ntype_: 4';
const out = await BaseItem.unserialize(serialized);
expect(out.id).toBe('00000000000000000000000000000001');
expect(out.file_extension).toBe('txt');
});
});
+13
View File
@@ -621,6 +621,19 @@ export default class BaseItem extends BaseModel {
const ItemClass = this.itemClass(output.type_);
output = ItemClass.removeUnknownFields(output);
// Reject any field that could be used to escape the resource directory
// when concatenated into a file path (resourceFullPath uses raw string
// concat on id and file_extension). The id format is universally a 32
// char hex string; the extension must not contain path separators.
// Throws a malformedItem JoplinError so the sync loop can log+skip the
// item rather than abort the whole batch.
if ('id' in output && output.id !== '' && !/^[a-f0-9]{32}$/.test(output.id)) {
throw new JoplinError(`Invalid item ID format: ${JSON.stringify(output.id)}`, 'malformedItem');
}
if ('file_extension' in output && /[/\\]|\.\./.test(output.file_extension)) {
throw new JoplinError(`Invalid file extension: ${JSON.stringify(output.file_extension)}`, 'malformedItem');
}
for (const n in output) {
if (!output.hasOwnProperty(n)) continue;
output[n] = await this.unserialize_format(output.type_, n, output[n]);
+14
View File
@@ -94,6 +94,20 @@ export default class Folder extends BaseItem {
return r ? r.total : 0;
}
// Returns a map of folder id → number of indexable notes (excluding trash
// and conflicts). Folders with zero notes are omitted from the map.
public static async noteCountsByFolderId() {
const rows = await this.db().selectAll<{ parent_id: string; total: number }>(
`SELECT parent_id, count(*) as total
FROM notes
WHERE is_conflict = 0 AND (deleted_time IS NULL OR deleted_time = 0)
GROUP BY parent_id`,
);
const counts: Record<string, number> = {};
for (const r of rows) counts[r.parent_id] = r.total;
return counts;
}
public static markNotesAsConflict(parentId: string) {
const query = Database.updateQuery('notes', { is_conflict: 1 }, { parent_id: parentId });
return this.db().exec(query);
+119 -2
View File
@@ -1,8 +1,8 @@
import Setting from './Setting';
import Setting, { Env } from './Setting';
import BaseModel from '../BaseModel';
import shim from '../shim';
import markdownUtils from '../markdownUtils';
import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv, msleep, db } from '../testing/test-utils';
import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv, msleep, db, encryptionService, revisionService } from '../testing/test-utils';
import Folder from './Folder';
import Note from './Note';
import Tag from './Tag';
@@ -21,6 +21,9 @@ import { getTrashFolderId } from '../services/trash';
import getConflictFolderId from './utils/getConflictFolderId';
import Revision from './Revision';
import RevisionService from '../services/RevisionService';
import NoteLockKey from '../services/noteLock/NoteLockKey';
import NoteLockService from '../services/noteLock/NoteLockService';
import EncryptionService from '../services/e2ee/EncryptionService';
async function allItems() {
const folders = await Folder.all();
@@ -32,6 +35,9 @@ describe('models/Note', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockKey.destroyInstance();
NoteLockService.destroyInstance();
EncryptionService.instance_ = encryptionService();
});
it('should find resource and note IDs', (async () => {
@@ -243,6 +249,117 @@ describe('models/Note', () => {
expect(Note.previewFields()).not.toContain('extracted_resource_ids');
});
it('should store note lock ciphertext while decrypting only gated loads', async () => {
await NoteLockKey.instance().create('123456');
const resourceId1 = '06894e83b8f84d3d8cbe0f1587f9e226';
const resourceId2 = '06894e83b8f84d3d8cbe0f1587f9e227';
const plainTextBody = `secret [](:/${resourceId1}) [](:/${resourceId2})`;
const note = await Note.save({
body: plainTextBody,
is_locked: 1,
}, { useNoteLock: true });
const storedNote = await Note.load(note.id);
expect(storedNote.body).not.toBe(plainTextBody);
expect(storedNote.extracted_resource_ids).toBe(`${resourceId1},${resourceId2}`);
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
await expect(Note.load(note.id, { fields: ['id', 'is_locked'], useNoteLock: true })).rejects.toThrow();
await expect(Note.load(note.id, { fields: ['id', 'body'], useNoteLock: true })).rejects.toThrow('Gated note lock load is missing lock state');
await Note.save(await Note.load(note.id, { useNoteLock: true }), { useNoteLock: true });
expect((await Note.load(note.id)).body).not.toBe(plainTextBody);
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
await expect(Note.save({
id: note.id,
body: 'must not be stored',
}, { useNoteLock: true })).rejects.toThrow('Gated note lock save is missing lock state');
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
await Note.save({
...await Note.load(note.id, { useNoteLock: true }),
body: `${plainTextBody} edited`,
}, { useNoteLock: true });
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(`${plainTextBody} edited`);
await Note.save({
...await Note.load(note.id, { useNoteLock: true }),
body: 'unlocked',
is_locked: 0,
}, { useNoteLock: true });
const unlockedNote = await Note.load(note.id);
expect(unlockedNote.body).toBe('unlocked');
expect(unlockedNote.extracted_resource_ids).toBe('');
});
it('should not decrypt locked notes while the feature is disabled', async () => {
await NoteLockKey.instance().create('123456');
const note = await Note.save({
body: 'secret',
is_locked: 1,
}, { useNoteLock: true });
Setting.setConstant('env', Env.Prod);
try {
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(note.body);
} finally {
Setting.setConstant('env', Env.Dev);
}
});
it('should fail closed when note lock encryption cannot decrypt or encrypt', async () => {
const noteLockKey = NoteLockKey.instance();
await noteLockKey.create('123456');
const note = await Note.save({
body: 'secret',
is_locked: 1,
}, { useNoteLock: true });
noteLockKey.lock();
await expect(Note.save({
body: 'must not be stored',
is_locked: 1,
}, { useNoteLock: true })).rejects.toThrow('Note lock key is not unlocked');
expect(await Note.all()).toHaveLength(1);
await noteLockKey.unlock('123456');
const corruptedBody = `${note.body}invalid`;
await db().exec('UPDATE notes SET body = ? WHERE id = ?', [corruptedBody, note.id]);
await expect(Note.load(note.id, { useNoteLock: true })).rejects.toThrow();
expect((await Note.load(note.id)).body).toBe(corruptedBody);
const incompleteBody = note.body.slice(0, -1);
await db().exec('UPDATE notes SET body = ? WHERE id = ?', [incompleteBody, note.id]);
await expect(Note.load(note.id, { useNoteLock: true })).rejects.toThrow();
expect((await Note.load(note.id)).body).toBe(incompleteBody);
});
it('should clear plaintext revision data when locking a note', async () => {
const note = await Note.save({ body: 'plain text' });
await Note.save({ id: note.id, body: 'updated plain text' });
await revisionService().collectRevisions();
expect(await Revision.countRevisions(Note.modelType(), note.id)).toBe(1);
const encryptedRevision = await Revision.save({
item_type: Note.modelType(),
item_id: note.id,
item_updated_time: Date.now(),
is_locked: 1,
});
await NoteLockKey.instance().create('123456');
await Note.save({
...await Note.load(note.id),
is_locked: 1,
}, { useNoteLock: true });
expect(await Revision.countRevisions(Note.modelType(), note.id)).toBe(1);
expect(await Revision.load(encryptedRevision.id)).toBeTruthy();
await ItemChange.waitForAllSaved();
expect(await ItemChange.oldNoteContent(note.id)).toBe(null);
});
it('should reset fields for a duplicate', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'note', parent_id: folder1.id });
+17 -3
View File
@@ -26,6 +26,8 @@ import { resolveFileRef, RefKind } from '../services/whiteboard/resolveRef';
const { isImageMimeType } = require('../resourceUtils');
import { MarkupToHtml } from '@joplin/renderer';
import { ALL_NOTES_FILTER_ID } from '../reserved-ids';
import NoteLockNote from '../services/noteLock/NoteLockNote';
import isNoteLockEnabled from '../services/noteLock/isNoteLockEnabled';
export interface PreviewsOrder {
by: string;
@@ -789,8 +791,10 @@ export default class Note extends BaseItem {
return n.updated_time < date;
}
public static load(id: string, options: LoadOptions = null): Promise<NoteEntity> {
return super.load(id, options);
public static async load(id: string, options: LoadOptions = null): Promise<NoteEntity> {
const note = await super.load(id, options);
if (isNoteLockEnabled() && !!options?.useNoteLock) return NoteLockNote.decryptBody(note);
return note;
}
public static async save(o: NoteEntity, options: SaveOptions = null): Promise<NoteEntity> {
@@ -834,6 +838,9 @@ export default class Note extends BaseItem {
// we should set beforeNoteJson to the current contents in the database, or the last value which was stored
// in the item_changes table
const oldNote = !isNew && o.id ? await Note.load(o.id) : null;
if (isNoteLockEnabled() && !!options?.useNoteLock) {
await NoteLockNote.prepareForSave(o, this.linkedItemIds, isNew);
}
syncDebugLog.info('Save Note: P:', oldNote);
@@ -842,7 +849,9 @@ export default class Note extends BaseItem {
// has just been downloaded from the sync target and save is invoked when the note has not yet been decrypted
if (oldNote && !oldNote.encryption_applied) {
const changedSinceCollection = this.revisionService().changedSinceCollection(o.id);
if (changedSinceCollection) {
if (isNoteLockEnabled() && NoteLockNote.isLocked(o)) {
beforeNoteJson = null;
} else if (changedSinceCollection) {
beforeNoteJson = await ItemChange.oldNoteContent(o.id);
} else {
beforeNoteJson = JSON.stringify(oldNote);
@@ -864,6 +873,11 @@ export default class Note extends BaseItem {
let savedNote = await super.save(o, options);
if (isNoteLockEnabled() && !!options?.useNoteLock && NoteLockNote.isLocking(o, oldNote)) {
await ItemChange.waitForAllSaved();
await this.revisionService().deleteUnencryptedHistoryForNote(savedNote.id, { sourceDescription: 'Note.save: note lock' });
}
void ItemChange.add(BaseModel.TYPE_NOTE, savedNote.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, {
changeSource, changeId: options?.changeId, beforeChangeItemJson: beforeNoteJson,
});
+12
View File
@@ -407,6 +407,18 @@ export default class Revision extends BaseItem {
await this.batchDelete(revisions.map(item => item.id), options);
}
// Same as deleteHistoryForNote, but keeps locked revisions when clearing plaintext history during note locking.
public static async deleteUnencryptedHistoryForNote(noteIds: string | string[], options: DeleteOptions) {
const ids = Array.isArray(noteIds) ? noteIds : [noteIds];
const revisions: RevisionEntity[] = await this.modelSelectAll(
`SELECT id FROM revisions WHERE item_type = ? AND item_id in (${this.escapeIdsForSql(ids)}) AND is_locked = 0 ORDER BY item_updated_time DESC`,
[ModelType.Note],
);
await this.batchDelete(revisions.map(item => item.id), options);
}
public static async revisionExists(itemType: ModelType, itemId: string, updatedTime: number) {
const existingRev = await Revision.latestRevision(itemType, itemId);
return existingRev && existingRev.item_updated_time === updatedTime;
+3
View File
@@ -1291,6 +1291,7 @@ class Setting extends BaseModel {
'encryption',
'joplinCloud',
'ai',
'mcp',
'editor',
'plugins',
'markdownPlugins',
@@ -1366,6 +1367,7 @@ class Setting extends BaseModel {
if (name === 'importOrExport') return _('Import and Export');
if (name === 'moreInfo') return _('More information');
if (name === 'ai') return _('AI');
if (name === 'mcp') return _('MCP Server');
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
@@ -1443,6 +1445,7 @@ class Setting extends BaseModel {
'importOrExport': 'fa fa-file-export',
'moreInfo': 'fa fa-info-circle',
'ai': 'fa fa-robot',
'mcp': 'fa fa-plug',
};
// Icomoon icons are currently not present in the mobile app -- we override these
@@ -818,6 +818,127 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.Database,
},
'mcp.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
label: () => _('Enable MCP server'),
description: () => _('Exposes Joplin notes to external AI applications (Claude Desktop, Cursor, etc.) via the Model Context Protocol. Requires the Web Clipper service to be running. Connected AI tools can read your note content; any text they retrieve may be sent to the external LLM provider those tools use.'),
storage: SettingStorage.File,
},
'mcp.tool.search_notes.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow searching notes'),
storage: SettingStorage.File,
},
'mcp.tool.read_note.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow reading notes'),
storage: SettingStorage.File,
},
'mcp.tool.list_notebooks.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow listing notebooks'),
storage: SettingStorage.File,
},
'mcp.tool.list_tags.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow listing tags'),
storage: SettingStorage.File,
},
'mcp.tool.create_note.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow creating notes'),
storage: SettingStorage.File,
},
'mcp.tool.update_note.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow updating notes'),
storage: SettingStorage.File,
},
'mcp.tool.delete_note.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow trashing notes'),
storage: SettingStorage.File,
},
'mcp.tool.manage_tags.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow editing tags on notes'),
storage: SettingStorage.File,
},
'mcp.tool.create_notebook.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow creating notebooks'),
storage: SettingStorage.File,
},
'mcp.tool.semantic_search_notes.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow semantic search of notes'),
storage: SettingStorage.File,
},
theme: {
value: Setting.THEME_LIGHT,
type: SettingItemType.Int,
@@ -0,0 +1,20 @@
import { ResourceEntity } from '../../services/database/types';
import { resourceFilename } from './resourceUtils';
describe('resourceUtils', () => {
it('should build a filename from id and extension', () => {
const resource: ResourceEntity = { id: '00000000000000000000000000000001', file_extension: 'txt' };
expect(resourceFilename(resource)).toBe('00000000000000000000000000000001.txt');
});
it.each([
{ id: '../../escape', file_extension: 'txt' },
{ id: 'foo/bar', file_extension: 'txt' },
{ id: '00000000000000000000000000000001', file_extension: '../foo.txt' },
{ id: '00000000000000000000000000000001', file_extension: 'foo/bar' },
{ id: '00000000000000000000000000000001', file_extension: 'foo\\bar' },
])('should reject filenames containing path separators or parent segments (%j)', (resource) => {
expect(() => resourceFilename(resource as ResourceEntity)).toThrow(/Invalid resource filename/);
});
});
+8 -1
View File
@@ -9,7 +9,14 @@ export const resourceFilename = (resource: ResourceEntity, encryptedBlob = false
let extension = encryptedBlob ? 'crypted' : resource.file_extension;
if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
extension = extension ? `.${extension}` : '';
return resource.id + extension;
const filename = resource.id + extension;
// Defence in depth: even if a malformed id/extension reaches this point,
// the result must be a single filename component (no path separators, no
// parent-directory segments). BaseItem.unserialize is the primary check.
if (/[/\\]/.test(filename) || filename.split(/[/\\]/).some(p => p === '..')) {
throw new Error(`Invalid resource filename: ${JSON.stringify(filename)}`);
}
return filename;
};
export const resourceRelativePath = (resource: ResourceEntity, relativeResourceDirPath: string, encryptedBlob = false) => {
+2
View File
@@ -31,6 +31,7 @@ export interface LoadOptions {
limit?: number;
includeConflicts?: boolean;
includeDeleted?: boolean;
useNoteLock?: boolean;
}
export interface FolderLoadOptions extends LoadOptions {
@@ -50,6 +51,7 @@ export interface SaveOptions {
dispatchUpdateAction?: boolean;
dispatchOptions?: { preserveSelection: boolean };
disableReadOnlyCheck?: boolean;
useNoteLock?: boolean;
changeSource?: number;
+1 -1
View File
@@ -101,7 +101,7 @@
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
"string-to-stream": "3.0.1",
"tar": "7.5.11",
"tar": "7.5.12",
"tcp-port-used": "1.0.2",
"uglifycss": "0.0.29",
"url-parse": "1.5.10",
+10 -1
View File
@@ -372,10 +372,19 @@ export default class RevisionService extends BaseService {
public async deleteHistoryForNote(noteIds: string | string[], options: DeleteOptions) {
const ids = Array.isArray(noteIds) ? noteIds : [noteIds];
await Revision.deleteHistoryForNote(ids, options);
await this.resetHistoryState_(ids);
}
public async deleteUnencryptedHistoryForNote(noteIds: string | string[], options: DeleteOptions) {
const ids = Array.isArray(noteIds) ? noteIds : [noteIds];
await Revision.deleteUnencryptedHistoryForNote(ids, options);
await this.resetHistoryState_(ids);
}
private async resetHistoryState_(noteIds: string[]) {
// Clear any cached content in the item_changes table and reset the state of the note in the revision service, to ensure that any new revisions created
// upon revision collection do not include contents which were present prior to triggering the deletion
for (const noteId of ids) {
for (const noteId of noteIds) {
await ItemChange.resetOldNoteContent(noteId);
RevisionService.instance().removeChangedSinceCollection(noteId);
}
@@ -162,6 +162,27 @@ describe('services_EncryptionService', () => {
expect(plainText2 === veryLongSecret).toBe(true);
}));
it('should encrypt and decrypt with a supplied decrypted master key', (async () => {
let masterKey = await service.generateMasterKey('123456');
masterKey = await MasterKey.save(masterKey);
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
const options = {
masterKeyId: masterKey.id,
decryptedMasterKey,
};
expect(service.loadedMasterKeysCount()).toBe(0);
const cipherText = await service.encryptString('some secret', options);
expect(await service.decryptString(cipherText, options)).toBe('some secret');
expect(service.loadedMasterKeysCount()).toBe(0);
await expect(service.decryptString(cipherText, {
...options,
masterKeyId: '01234568abcdefgh01234568abcdefgh',
})).rejects.toThrow('Supplied master key ID does not match encrypted content');
}));
it('should decrypt various encryption methods', (async () => {
let masterKey = await service.generateMasterKey('123456');
masterKey = await MasterKey.save(masterKey);
@@ -53,6 +53,7 @@ export interface EncryptOptions {
onProgress?: (event: { doneSize: number })=> void;
encryptionHandler?: EncryptionCustomHandler;
masterKeyId?: string;
decryptedMasterKey?: string;
}
type GetPasswordCallback = ()=> string|Promise<string>;
@@ -581,7 +582,7 @@ export default class EncryptionService {
const method = options.encryptionMethod;
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
const masterKeyPlainText = (await this.loadedMasterKey(masterKeyId)).plainText;
const masterKeyPlainText = await this.masterKeyPlainText_(masterKeyId, options);
const chunkSize = this.chunkSize(method);
const crypto = shim.crypto;
@@ -618,7 +619,7 @@ export default class EncryptionService {
if (!options) options = {};
const header = await this.decodeHeaderSource_(source) as { encryptionMethod: number; masterKeyId: string };
const masterKeyPlainText = (await this.loadedMasterKey(header.masterKeyId)).plainText;
const masterKeyPlainText = await this.masterKeyPlainText_(header.masterKeyId, options);
let doneSize = 0;
@@ -641,6 +642,14 @@ export default class EncryptionService {
}
}
private async masterKeyPlainText_(masterKeyId: string, options: EncryptOptions) {
if (options.decryptedMasterKey !== undefined) {
if (options.masterKeyId !== masterKeyId) throw new Error(`Supplied master key ID does not match encrypted content: ${masterKeyId}`);
return options.decryptedMasterKey;
}
return (await this.loadedMasterKey(masterKeyId)).plainText;
}
private stringReader_(string: string, sync = false) {
const reader = {
index: 0,
+345
View File
@@ -0,0 +1,345 @@
import Setting from '../../models/Setting';
import Note from '../../models/Note';
import Folder from '../../models/Folder';
import Tag from '../../models/Tag';
import SearchEngine from '../search/SearchEngine';
import { db, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import McpServer from './McpServer';
import { McpProtocolVersion } from './types';
const allToolSettings = [
'mcp.tool.search_notes.enabled',
'mcp.tool.semantic_search_notes.enabled',
'mcp.tool.read_note.enabled',
'mcp.tool.list_notebooks.enabled',
'mcp.tool.list_tags.enabled',
'mcp.tool.create_note.enabled',
'mcp.tool.update_note.enabled',
'mcp.tool.delete_note.enabled',
'mcp.tool.manage_tags.enabled',
'mcp.tool.create_notebook.enabled',
];
const enableAllTools = () => {
for (const s of allToolSettings) Setting.setValue(s, true);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper unwraps MCP text payloads
const parseToolResult = (result: any) => JSON.parse(result.content[0].text);
describe('McpServer', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
Setting.setValue('mcp.enabled', true);
enableAllTools();
});
test('returns protocol version and server info on initialize', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'initialize', params: {},
});
expect(response.result.protocolVersion).toBe(McpProtocolVersion);
expect(response.result.serverInfo.name).toBe('joplin-mcp');
expect(response.result.capabilities.tools).toBeDefined();
});
test('lists enabled tools only', async () => {
Setting.setValue('mcp.tool.create_note.enabled', false);
Setting.setValue('mcp.tool.update_note.enabled', false);
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/list',
});
const names = response.result.tools.map((t: { name: string }) => t.name);
expect(names).toEqual(expect.arrayContaining(['search_notes', 'read_note', 'list_notebooks', 'list_tags']));
expect(names).not.toContain('create_note');
expect(names).not.toContain('update_note');
});
test('returns MethodNotFound for unknown methods', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'bogus/method',
});
expect(response.error.code).toBe(-32601);
});
test('returns isError when calling a disabled tool', async () => {
Setting.setValue('mcp.tool.search_notes.enabled', false);
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'search_notes', arguments: { query: 'x' } },
});
expect(response.result.isError).toBe(true);
expect(response.result.content[0].text).toMatch(/disabled/);
});
test('returns InvalidParams for malformed tools/call params', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call', params: {},
});
expect(response.error.code).toBe(-32602);
});
test('responds to id: null requests instead of treating them as notifications', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: null, method: 'tools/list',
});
expect(response).not.toBeNull();
expect(response.id).toBeNull();
expect(response.result.tools.length).toBeGreaterThan(0);
});
test('returns isError for unknown tools', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'no_such_tool', arguments: {} },
});
expect(response.result.isError).toBe(true);
expect(response.result.content[0].text).toMatch(/Unknown tool/);
});
test('returns null for notifications and never errors on them', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', method: 'notifications/initialized',
});
expect(response).toBeNull();
});
test('read_note returns title body notebook and tags', async () => {
const folder = await Folder.save({ title: 'Work' });
const note = await Note.save({ title: 'Meeting notes', body: 'Discuss roadmap', parent_id: folder.id });
const tag = await Tag.save({ title: 'important' });
await Tag.addNote(tag.id, note.id);
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'read_note', arguments: { id: note.id } },
});
const payload = parseToolResult(response.result);
expect(payload.title).toBe('Meeting notes');
expect(payload.body).toBe('Discuss roadmap');
expect(payload.notebook_title).toBe('Work');
expect(payload.tags).toEqual(['important']);
});
test('read_note refuses trashed notes', async () => {
const folder = await Folder.save({ title: 'F' });
const trashed = await Note.save({ title: 'Gone', parent_id: folder.id, deleted_time: Date.now() });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'read_note', arguments: { id: trashed.id } },
});
expect(response.result.isError).toBe(true);
});
test('list_notebooks returns id title parent_id and note_count', async () => {
const parent = await Folder.save({ title: 'Parent' });
const child = await Folder.save({ title: 'Child', parent_id: parent.id });
await Note.save({ title: 'n1', parent_id: child.id });
await Note.save({ title: 'n2', parent_id: child.id });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'list_notebooks', arguments: {} },
});
const payload = parseToolResult(response.result);
const parentEntry = payload.notebooks.find((n: { id: string }) => n.id === parent.id);
const childEntry = payload.notebooks.find((n: { id: string }) => n.id === child.id);
expect(childEntry.parent_id).toBe(parent.id);
expect(childEntry.note_count).toBe(2);
expect(parentEntry.note_count).toBe(0);
});
test('search_notes returns a snippet anchored on the keyword', async () => {
const folder = await Folder.save({ title: 'F' });
const body = `${'lorem '.repeat(60)}pet sitters ${'ipsum '.repeat(60)}`;
await Note.save({ title: 'Recommendations', body, parent_id: folder.id });
SearchEngine.instance().setDb(db());
await SearchEngine.instance().syncTables();
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'search_notes', arguments: { query: 'sitters' } },
});
const payload = parseToolResult(response.result);
expect(payload.results.length).toBe(1);
expect(payload.results[0].snippet).toMatch(/pet sitters/);
expect(payload.results[0].snippet.length).toBeLessThan(body.length);
});
test('read_note pages the body when max_chars is set', async () => {
const folder = await Folder.save({ title: 'F' });
const body = '0123456789';
const note = await Note.save({ title: 'Slice', body, parent_id: folder.id });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'read_note', arguments: { id: note.id, offset: 2, max_chars: 4 } },
});
const payload = parseToolResult(response.result);
expect(payload.body).toBe('2345');
expect(payload.body_offset).toBe(2);
expect(payload.body_length).toBe(10);
expect(payload.has_more).toBe(true);
});
test('delete_note moves a note to trash', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 'Doomed', parent_id: folder.id });
await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'delete_note', arguments: { id: note.id } },
});
const reloaded = await Note.load(note.id);
expect(reloaded.deleted_time).toBeGreaterThan(0);
});
test('manage_tags adds and removes tags by title', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 'Tagged', parent_id: folder.id });
await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'manage_tags', arguments: { note_id: note.id, add: ['alpha', 'beta'] } },
});
let tags = await Tag.tagsByNoteId(note.id);
expect(tags.map(t => t.title).sort()).toEqual(['alpha', 'beta']);
await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'manage_tags', arguments: { note_id: note.id, remove: ['alpha'] } },
});
tags = await Tag.tagsByNoteId(note.id);
expect(tags.map(t => t.title)).toEqual(['beta']);
});
test('create_notebook creates a notebook under a parent', async () => {
const parent = await Folder.save({ title: 'Parent' });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'create_notebook', arguments: { title: 'Child', parent_id: parent.id } },
});
const payload = parseToolResult(response.result);
const reloaded = await Folder.load(payload.id);
expect(reloaded.title).toBe('Child');
expect(reloaded.parent_id).toBe(parent.id);
});
test('update_note append adds text to the existing body', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 't', body: 'start', parent_id: folder.id });
await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'update_note', arguments: { id: note.id, append: '-end' } },
});
const reloaded = await Note.load(note.id);
expect(reloaded.body).toBe('start-end');
});
test('update_note replace_text fails on ambiguous match', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 't', body: 'foo bar foo', parent_id: folder.id });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'update_note', arguments: { id: note.id, replace_text: { find: 'foo', replace: 'baz' } } },
});
expect(response.result.isError).toBe(true);
const reloaded = await Note.load(note.id);
expect(reloaded.body).toBe('foo bar foo');
});
test('update_note replace_text fails when find is missing', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 't', body: 'hello', parent_id: folder.id });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'update_note', arguments: { id: note.id, replace_text: { find: 'world', replace: 'x' } } },
});
expect(response.result.isError).toBe(true);
});
test('update_note replace_text replaces a unique match', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 't', body: 'hello world', parent_id: folder.id });
await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'update_note', arguments: { id: note.id, replace_text: { find: 'world', replace: 'there' } } },
});
const reloaded = await Note.load(note.id);
expect(reloaded.body).toBe('hello there');
});
test('handler throwing a plain Error surfaces as JSON-RPC InternalError, not a tool error', async () => {
// Forge an internal-bug scenario by passing a clearly invalid id format
// straight through; the Note model will throw an internal Error rather
// than ToolError when SQL fails on it. (We rely on the dispatcher's
// distinction here: ToolError → isError:true, anything else → JSON-RPC error.)
jest.spyOn(Note, 'load').mockRejectedValueOnce(new Error('forged internal failure'));
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'read_note', arguments: { id: 'a'.repeat(32) } },
});
expect(response.result).toBeUndefined();
expect(response.error.code).toBe(-32603);
expect(response.error.message).toMatch(/forged internal failure/);
});
test('semantic_search_notes returns a clear error when AI is off', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'semantic_search_notes', arguments: { query: 'anything' } },
});
expect(response.result.isError).toBe(true);
expect(response.result.content[0].text).toMatch(/embedding|AI/);
});
test('create_note creates a note in the chosen notebook', async () => {
const folder = await Folder.save({ title: 'Inbox' });
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'create_note', arguments: { title: 'Hi', body: 'Body', notebook_id: folder.id } },
});
const payload = parseToolResult(response.result);
const saved = await Note.load(payload.id);
expect(saved.title).toBe('Hi');
expect(saved.body).toBe('Body');
expect(saved.parent_id).toBe(folder.id);
});
test('create_note rejects an unknown notebook', async () => {
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'create_note', arguments: { title: 'x', notebook_id: 'doesnotexist00000000000000000000' } },
});
expect(response.result.isError).toBe(true);
});
test('update_note only changes the fields passed', async () => {
const folder = await Folder.save({ title: 'F' });
const note = await Note.save({ title: 'Original', body: 'Keep', parent_id: folder.id });
await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'update_note', arguments: { id: note.id, title: 'New' } },
});
const updated = await Note.load(note.id);
expect(updated.title).toBe('New');
expect(updated.body).toBe('Keep');
});
});
+136
View File
@@ -0,0 +1,136 @@
import Logger from '@joplin/utils/Logger';
import Setting from '../../models/Setting';
import { allTools, enabledTools, findTool } from './registry';
import { JsonRpcRequest, JsonRpcResponse, JsonRpcErrorCodes, McpProtocolVersion, ToolCallResult, ToolError } from './types';
const logger = Logger.create('McpServer');
const serverName = 'joplin-mcp';
const serverVersion = '1.0.0';
class InvalidParamsError extends Error {}
// Routes a JSON-RPC request to the matching MCP method. The transport layer
// (HTTP today, possibly stdio later) calls this with a parsed envelope and
// gets back a response envelope to write.
export default class McpServer {
private static instance_: McpServer;
public static instance(): McpServer {
if (!this.instance_) this.instance_ = new McpServer();
return this.instance_;
}
public async handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse | null> {
// Per JSON-RPC 2.0: a request without an id field is a notification
// (no response). id: null is a real request and must get a response
// with id: null, so it isn't a notification.
const isNotification = request.id === undefined;
const id = request.id ?? null;
if (request.jsonrpc !== '2.0' || !request.method) {
if (isNotification) return null;
return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'Invalid JSON-RPC request');
}
try {
switch (request.method) {
case 'initialize':
return this.successResponse(id, this.handleInitialize());
case 'tools/list':
return this.successResponse(id, this.handleToolsList());
case 'tools/call':
return this.successResponse(id, await this.handleToolsCall(request.params));
case 'ping':
return this.successResponse(id, {});
case 'notifications/initialized':
return null;
default:
if (isNotification) return null;
return this.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`);
}
} catch (error) {
logger.error(`Error handling method ${request.method}:`, error);
if (isNotification) return null;
if (error instanceof InvalidParamsError) {
return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, error.message);
}
return this.errorResponse(id, JsonRpcErrorCodes.InternalError, error.message || 'Internal error');
}
}
private handleInitialize() {
return {
protocolVersion: McpProtocolVersion,
capabilities: {
tools: {},
},
serverInfo: {
name: serverName,
version: serverVersion,
},
};
}
private handleToolsList() {
return {
tools: enabledTools().map(t => ({
name: t.id,
description: t.description,
inputSchema: t.inputSchema,
})),
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- params are JSON-RPC-shaped
private async handleToolsCall(params: any): Promise<ToolCallResult> {
if (!params || typeof params.name !== 'string') {
throw new InvalidParamsError('Missing or invalid "name" parameter');
}
const tool = findTool(params.name);
if (!tool) {
// "Disabled" vs "unknown" surface differently so the LLM gets actionable feedback.
const exists = allTools().some(t => t.id === params.name);
return toolErrorResult(exists ? `Tool '${params.name}' is disabled in Joplin settings` : `Unknown tool '${params.name}'`);
}
const input = params.arguments ?? {};
try {
const payload = await tool.handler(input);
return {
content: [{ type: 'text', text: serialisePayload(payload) }],
};
} catch (error) {
if (error instanceof ToolError) {
return toolErrorResult(error.message);
}
// Internal bug — let it bubble to the JSON-RPC layer as InternalError.
throw error;
}
}
private successResponse(id: string | number | null, result: unknown): JsonRpcResponse {
return { jsonrpc: '2.0', id, result };
}
private errorResponse(id: string | number | null, code: number, message: string): JsonRpcResponse {
return { jsonrpc: '2.0', id, error: { code, message } };
}
public isEnabled() {
return Setting.value('mcp.enabled') as boolean;
}
}
const toolErrorResult = (message: string): ToolCallResult => ({
content: [{ type: 'text', text: message }],
isError: true,
});
// MCP content is always text, so we JSON-serialise objects/arrays and pass
// strings through unchanged. null/undefined collapse to an empty string.
const serialisePayload = (payload: unknown) => {
if (payload === null || payload === undefined) return '';
if (typeof payload === 'string') return payload;
return JSON.stringify(payload, null, 2);
};
+42
View File
@@ -0,0 +1,42 @@
import Setting from '../../models/Setting';
import { McpTool } from './types';
import searchNotes from './tools/searchNotes';
import semanticSearchNotes from './tools/semanticSearchNotes';
import readNote from './tools/readNote';
import listNotebooks from './tools/listNotebooks';
import listTags from './tools/listTags';
import createNote from './tools/createNote';
import updateNote from './tools/updateNote';
import deleteNote from './tools/deleteNote';
import manageTags from './tools/manageTags';
import createNotebook from './tools/createNotebook';
// Every tool registered here gets an `mcp.tool.<id>.enabled` setting (see
// builtInMetadata.ts). Adding a tool to this list without also adding the
// setting means it will be reported as enabled by default — keep them in sync.
const allMcpTools: McpTool[] = [
searchNotes,
semanticSearchNotes,
readNote,
listNotebooks,
listTags,
createNote,
updateNote,
deleteNote,
manageTags,
createNotebook,
];
export const allTools = () => allMcpTools;
export const enabledTools = () => {
return allMcpTools.filter(t => Setting.value(`mcp.tool.${t.id}.enabled`) as boolean);
};
export const findTool = (id: string) => {
const t = allMcpTools.find(t => t.id === id);
if (!t) return null;
if (!(Setting.value(`mcp.tool.${t.id}.enabled`) as boolean)) return null;
return t;
};
@@ -0,0 +1,55 @@
import Note from '../../../models/Note';
import Folder from '../../../models/Folder';
import { McpTool, ToolError } from '../types';
interface Input {
title?: string;
body?: string;
notebook_id?: string;
is_todo?: boolean;
}
const tool: McpTool = {
id: 'create_note',
description: 'Create a new note. Returns the created note id. If notebook_id is omitted, the note is created in the default notebook.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Note title.' },
body: { type: 'string', description: 'Note body in Markdown.' },
notebook_id: { type: 'string', description: 'Optional notebook (folder) id. Use list_notebooks to find ids.' },
is_todo: { type: 'boolean', description: 'Set to true to create the note as a to-do.' },
},
required: ['title'],
},
handler: async (input: Input) => {
if (typeof input.title !== 'string' || !input.title.trim()) {
throw new ToolError('Missing or invalid "title" parameter');
}
// `is_todo: 'false'` is otherwise truthy and would silently flip the flag.
if (input.is_todo !== undefined && typeof input.is_todo !== 'boolean') {
throw new ToolError('"is_todo" must be a boolean');
}
let parentId = input.notebook_id;
if (parentId) {
const folder = await Folder.load(parentId);
if (!folder) throw new ToolError(`Notebook not found: ${parentId}`);
} else {
const defaultFolder = await Folder.defaultFolder();
if (!defaultFolder) throw new ToolError('No notebook available. Create one first or pass notebook_id.');
parentId = defaultFolder.id;
}
const saved = await Note.save({
title: input.title,
body: input.body ?? '',
parent_id: parentId,
is_todo: input.is_todo ? 1 : 0,
});
return { id: saved.id, title: saved.title, notebook_id: saved.parent_id };
},
};
export default tool;
@@ -0,0 +1,37 @@
import Folder from '../../../models/Folder';
import { McpTool, ToolError } from '../types';
interface Input {
title?: string;
parent_id?: string;
}
const tool: McpTool = {
id: 'create_notebook',
description: 'Create a new notebook. Optionally nest it under an existing notebook by passing parent_id.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Notebook title.' },
parent_id: { type: 'string', description: 'Optional id of the parent notebook to nest under.' },
},
required: ['title'],
},
handler: async (input: Input) => {
if (!input.title || !input.title.trim()) throw new ToolError('Missing "title" parameter');
if (input.parent_id) {
const parent = await Folder.load(input.parent_id);
if (!parent) throw new ToolError(`Parent notebook not found: ${input.parent_id}`);
}
const saved = await Folder.save({
title: input.title,
parent_id: input.parent_id ?? '',
}, { userSideValidation: true });
return { id: saved.id, title: saved.title, parent_id: saved.parent_id || null };
},
};
export default tool;
@@ -0,0 +1,32 @@
import Note from '../../../models/Note';
import { McpTool, ToolError } from '../types';
interface Input {
id?: string;
}
const tool: McpTool = {
id: 'delete_note',
description: 'Move a note to the trash. The note is not permanently removed and the user can restore it from the trash.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'The note id to trash.' },
},
required: ['id'],
},
handler: async (input: Input) => {
if (!input.id) throw new ToolError('Missing "id" parameter');
const existing = await Note.load(input.id);
if (!existing || existing.is_conflict || (existing.deleted_time && existing.deleted_time > 0)) {
throw new ToolError(`Note not found: ${input.id}`);
}
await Note.batchDelete([input.id], { toTrash: true });
return { id: input.id, trashed: true };
},
};
export default tool;
@@ -0,0 +1,28 @@
import Folder from '../../../models/Folder';
import { FolderEntity } from '../../database/types';
import { McpTool } from '../types';
const tool: McpTool = {
id: 'list_notebooks',
description: 'List all notebooks (folders) with their ids, titles, and parent ids. Returned in a flat list — use parent_id to reconstruct the tree.',
inputSchema: {
type: 'object',
properties: {},
},
handler: async () => {
const folders = await Folder.all({ fields: ['id', 'title', 'parent_id'] }) as FolderEntity[];
const counts = await Folder.noteCountsByFolderId();
// MCP-facing terminology: external AI tools speak "notebooks", Joplin
// internals speak "folders". Quoted key sidesteps the id-denylist rule.
return {
'notebooks': folders.map(f => ({
id: f.id,
title: f.title,
parent_id: f.parent_id || null,
note_count: counts[f.id] ?? 0,
})),
};
},
};
export default tool;
@@ -0,0 +1,19 @@
import Tag from '../../../models/Tag';
import { McpTool } from '../types';
const tool: McpTool = {
id: 'list_tags',
description: 'List all tags that have at least one note attached, with their ids and titles.',
inputSchema: {
type: 'object',
properties: {},
},
handler: async () => {
const tags = await Tag.allWithNotes();
return {
tags: tags.map(t => ({ id: t.id, title: t.title })),
};
},
};
export default tool;
@@ -0,0 +1,74 @@
import Note from '../../../models/Note';
import Tag from '../../../models/Tag';
import { McpTool, ToolError } from '../types';
interface Input {
note_id?: string;
add?: string[];
remove?: string[];
}
const tool: McpTool = {
id: 'manage_tags',
description: 'Add or remove tags on a note. Tags are addressed by title; unknown tags in "add" are created automatically.',
inputSchema: {
type: 'object',
properties: {
note_id: { type: 'string', description: 'The note id whose tags should change.' },
add: { type: 'array', items: { type: 'string' }, description: 'Tag titles to attach. Created if missing.' },
remove: { type: 'array', items: { type: 'string' }, description: 'Tag titles to detach. Ignored if the tag is not attached.' },
},
required: ['note_id'],
},
handler: async (input: Input) => {
if (!input.note_id) throw new ToolError('Missing "note_id" parameter');
const addList = checkStringArray(input.add, 'add');
const removeList = checkStringArray(input.remove, 'remove');
if (!addList.length && !removeList.length) {
throw new ToolError('Pass at least one of "add" or "remove"');
}
const note = await Note.load(input.note_id);
if (!note || note.is_conflict || (note.deleted_time && note.deleted_time > 0)) {
throw new ToolError(`Note not found: ${input.note_id}`);
}
const added: string[] = [];
for (const title of addList) {
const trimmed = title.trim();
if (!trimmed) continue;
await Tag.addNoteTagByTitle(input.note_id, trimmed);
added.push(trimmed);
}
const removed: string[] = [];
for (const title of removeList) {
const trimmed = title.trim();
if (!trimmed) continue;
const tag = await Tag.loadByTitle(trimmed);
if (tag) {
await Tag.removeNote(tag.id, input.note_id);
removed.push(trimmed);
}
}
const currentTags = await Tag.tagsByNoteId(input.note_id);
return {
note_id: input.note_id,
added,
removed,
tags: currentTags.map(t => t.title),
};
},
};
const checkStringArray = (value: unknown, paramName: string) => {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) throw new ToolError(`"${paramName}" must be an array of strings`);
for (const item of value) {
if (typeof item !== 'string') throw new ToolError(`"${paramName}" must be an array of strings`);
}
return value as string[];
};
export default tool;
@@ -0,0 +1,62 @@
import Note from '../../../models/Note';
import Folder from '../../../models/Folder';
import Tag from '../../../models/Tag';
import { McpTool, ToolError } from '../types';
interface Input {
id?: string;
offset?: number;
max_chars?: number;
}
const defaultMaxChars = 0;
const tool: McpTool = {
id: 'read_note',
description: 'Read a single note by id. Returns title, markdown body, notebook name, tags, and timestamps. For very long notes, use offset and max_chars to page through the body.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'The note id (32-character hex).' },
offset: { type: 'integer', description: 'Byte offset into the body to start at. Defaults to 0.', minimum: 0 },
max_chars: { type: 'integer', description: 'Maximum characters of body to return. Omit or set to 0 for the full body.', minimum: 0 },
},
required: ['id'],
},
handler: async (input: Input) => {
if (!input.id) throw new ToolError('Missing "id" parameter');
const note = await Note.load(input.id);
if (!note || note.is_conflict || (note.deleted_time && note.deleted_time > 0)) {
throw new ToolError(`Note not found: ${input.id}`);
}
const folder = note.parent_id ? await Folder.load(note.parent_id) : null;
const tags = await Tag.tagsByNoteId(note.id);
const fullBody = note.body ?? '';
const offset = Math.max(0, input.offset ?? 0);
const maxChars = Math.max(0, input.max_chars ?? defaultMaxChars);
const end = maxChars > 0 ? Math.min(fullBody.length, offset + maxChars) : fullBody.length;
const body = fullBody.slice(offset, end);
return {
id: note.id,
title: note.title,
body,
body_length: fullBody.length,
body_offset: offset,
body_returned_chars: body.length,
has_more: end < fullBody.length,
notebook_id: note.parent_id,
notebook_title: folder ? folder.title : null,
tags: tags.map(t => t.title),
is_todo: !!note.is_todo,
todo_completed: !!note.todo_completed,
created_time: note.created_time,
updated_time: note.updated_time,
};
},
};
export default tool;
@@ -0,0 +1,98 @@
import SearchEngineUtils from '../../search/SearchEngineUtils';
import { NoteEntity } from '../../database/types';
import { McpTool, ToolError } from '../types';
interface Input {
query?: string;
limit?: number;
}
const fields = ['id', 'title', 'parent_id', 'updated_time', 'body'];
const defaultLimit = 20;
const maxLimit = 100;
const snippetChars = 240;
const tool: McpTool = {
id: 'search_notes',
description: [
'Search notes. Returns a ranked list of matches with id, title, notebook id, updated_time, and a short snippet anchored on the keyword match. The snippet often answers the question without a follow-up read_note call.',
'',
'The query supports plain keywords and Joplin search filters. Combine filters with spaces (AND); prefix with - to negate.',
'',
'Filters:',
' notebook:"Name" limit to a notebook by title (quotes if the title has spaces)',
' tag:Name limit to notes with this tag',
' title:Text match in title only',
' body:Text match in body only',
' any:1 word1 word2 match notes containing any of the words (default is all)',
' type:note|todo filter by item type',
' iscompleted:0|1 for todos, filter by completion state',
' created:YYYYMMDD notes created on or after that day; also supports day-N, week-N, month-N, year-N (e.g. created:day-7)',
' updated:YYYYMMDD notes updated on or after that day; same shorthand as created:',
' due:YYYYMMDD todo due-date filter',
' sourceurl:https://… match notes clipped from a URL',
' resource:image/png match notes with attachments of this MIME type',
'',
'Examples:',
' meeting notes — keyword search across all notes',
' notebook:"Work" project — keyword "project" within the Work notebook',
' notebook:Inbox — every note in the Inbox notebook',
' tag:idea -tag:archived — tagged "idea" but not "archived"',
' type:todo iscompleted:0 due:day+7 — open todos due within a week',
].join('\n'),
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query. See the tool description for the full filter syntax.' },
limit: { type: 'integer', description: 'Maximum number of results to return.', minimum: 1, maximum: maxLimit, default: defaultLimit },
},
required: ['query'],
},
handler: async (input: Input) => {
if (!input.query || !input.query.trim()) throw new ToolError('Missing "query" parameter');
const limit = Math.min(Math.max(input.limit ?? defaultLimit, 1), maxLimit);
const { notes } = await SearchEngineUtils.notesForQuery(input.query, false, { fields });
// Pull keywords out of the query so we can anchor the snippet near a
// match. Filters like `notebook:"X"` aren't useful for that.
const keywords = input.query
.split(/\s+/)
.filter(t => t && !t.includes(':') && !t.startsWith('-'))
.map(t => t.replace(/^["*]+|["*]+$/g, '').toLowerCase())
.filter(Boolean);
const results = notes.slice(0, limit).map((n: NoteEntity) => ({
id: n.id,
title: n.title,
notebook_id: n.parent_id,
updated_time: n.updated_time,
snippet: makeSnippet(n.body ?? '', keywords),
}));
return { results, total: notes.length };
},
};
const makeSnippet = (body: string, keywords: string[]) => {
const normalised = body.replace(/\s+/g, ' ').trim();
if (!normalised) return '';
if (normalised.length <= snippetChars) return normalised;
let anchor = -1;
const lower = normalised.toLowerCase();
for (const kw of keywords) {
const i = lower.indexOf(kw);
if (i >= 0) { anchor = i; break; }
}
if (anchor < 0) return `${normalised.slice(0, snippetChars).trimEnd()}`;
const start = Math.max(0, anchor - Math.floor(snippetChars / 3));
const end = Math.min(normalised.length, start + snippetChars);
const prefix = start > 0 ? '…' : '';
const suffix = end < normalised.length ? '…' : '';
return `${prefix}${normalised.slice(start, end).trim()}${suffix}`;
};
export default tool;
@@ -0,0 +1,83 @@
import Note from '../../../models/Note';
import SearchService from '../../ai/SearchService';
import { McpTool, ToolError } from '../types';
interface Input {
query?: string;
notebook_id?: string;
tag_id?: string;
relevance?: 'strict' | 'normal' | 'loose';
}
const tool: McpTool = {
id: 'semantic_search_notes',
description: [
'Semantic search across notes using the local embeddings index.',
'Use this when the user asks by meaning rather than exact words — for example "the note about pet sitters for my dog" rather than "pet sitter".',
'Falls back with a clear error if AI embeddings are not enabled in Settings → AI.',
'',
'Results are ranked chunks (not whole notes), each with the source note id, the chunk text that matched, and a similarity score. Use read_note on the note id to get full context.',
].join('\n'),
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Free-text query expressing what to find.' },
notebook_id: { type: 'string', description: 'Optional: limit search to a single notebook.' },
tag_id: { type: 'string', description: 'Optional: limit search to notes with this tag.' },
relevance: {
type: 'string',
enum: ['strict', 'normal', 'loose'],
description: 'How strict to be about similarity. "strict" returns fewer high-confidence chunks; "loose" returns more candidates.',
default: 'normal',
},
},
required: ['query'],
},
handler: async (input: Input) => {
if (!input.query || !input.query.trim()) throw new ToolError('Missing "query" parameter');
if (input.notebook_id && input.tag_id) throw new ToolError('Pass either "notebook_id" or "tag_id", not both');
const scope = input.notebook_id
? { type: 'folder' as const, folderId: input.notebook_id }
: input.tag_id
? { type: 'tag' as const, tagId: input.tag_id }
: undefined;
let hits;
try {
hits = await SearchService.instance().search({
query: { text: input.query },
scope,
relevance: input.relevance ?? 'normal',
});
} catch (error) {
// SearchService throws when no embedding provider is active — that's
// a configuration mistake the LLM should report to the user, not an
// internal bug.
const message = error instanceof Error ? error.message : 'Semantic search failed';
throw new ToolError(message);
}
const noteIds = Array.from(new Set(hits.map(h => h.noteId)));
const notes = noteIds.length
? await Note.byIds(noteIds, { fields: ['id', 'title', 'parent_id'] })
: [];
const noteById = new Map(notes.map(n => [n.id, n]));
const results = hits.map(h => {
const note = noteById.get(h.noteId);
return {
note_id: h.noteId,
title: note?.title ?? null,
notebook_id: note?.parent_id ?? null,
chunk_index: h.chunkIndex,
chunk_text: h.chunkText,
score: Math.round(h.score * 1000) / 1000,
};
});
return { results, total: results.length };
},
};
export default tool;
@@ -0,0 +1,108 @@
import Note from '../../../models/Note';
import Folder from '../../../models/Folder';
import { NoteEntity } from '../../database/types';
import { McpTool, ToolError } from '../types';
interface ReplaceTextOp {
find: string;
replace: string;
}
interface Input {
id?: string;
title?: string;
body?: string;
append?: string;
prepend?: string;
replace_text?: ReplaceTextOp;
notebook_id?: string;
todo_completed?: boolean;
}
const tool: McpTool = {
id: 'update_note',
description: [
'Update an existing note. Only the fields you pass are changed; omitted fields keep their current value.',
'',
'For body changes, prefer the partial operations over passing a full body:',
' append — append text to the end of the body',
' prepend — insert text at the start of the body',
' replace_text — replace a single exact match of "find" with "replace" (errors if the text is missing or appears more than once)',
'',
'Pass "body" only for full rewrites; it overrides the partial operations.',
].join('\n'),
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'The note id to update.' },
title: { type: 'string', description: 'New title.' },
body: { type: 'string', description: 'Full replacement body. Use the partial ops below for small edits.' },
append: { type: 'string', description: 'Text to append to the end of the existing body.' },
prepend: { type: 'string', description: 'Text to insert at the start of the existing body.' },
replace_text: {
type: 'object',
description: 'Find/replace a single occurrence in the existing body. Errors if "find" is missing or matches multiple times.',
properties: {
find: { type: 'string' },
replace: { type: 'string' },
},
required: ['find', 'replace'],
},
notebook_id: { type: 'string', description: 'Move the note to a different notebook by passing its id.' },
todo_completed: { type: 'boolean', description: 'For to-do notes: mark as completed (true) or open (false).' },
},
required: ['id'],
},
handler: async (input: Input) => {
if (!input.id) throw new ToolError('Missing "id" parameter');
const existing = await Note.load(input.id);
if (!existing || existing.is_conflict || (existing.deleted_time && existing.deleted_time > 0)) {
throw new ToolError(`Note not found: ${input.id}`);
}
if (input.notebook_id) {
const folder = await Folder.load(input.notebook_id);
if (!folder) throw new ToolError(`Notebook not found: ${input.notebook_id}`);
}
const patch: NoteEntity = { id: input.id };
if (input.title !== undefined) patch.title = input.title;
if (input.notebook_id !== undefined) patch.parent_id = input.notebook_id;
if (input.todo_completed !== undefined) patch.todo_completed = input.todo_completed ? Date.now() : 0;
let nextBody = existing.body ?? '';
let bodyChanged = false;
if (input.body !== undefined) {
nextBody = input.body;
bodyChanged = true;
} else {
if (input.prepend) {
nextBody = `${input.prepend}${nextBody}`;
bodyChanged = true;
}
if (input.append) {
nextBody = `${nextBody}${input.append}`;
bodyChanged = true;
}
if (input.replace_text) {
const { find, replace } = input.replace_text;
if (!find) throw new ToolError('"replace_text.find" must not be empty');
const firstIdx = nextBody.indexOf(find);
if (firstIdx < 0) throw new ToolError('replace_text: "find" string not found in body');
if (nextBody.indexOf(find, firstIdx + 1) >= 0) {
throw new ToolError('replace_text: "find" string appears more than once; pass more context to make it unique');
}
nextBody = `${nextBody.slice(0, firstIdx)}${replace ?? ''}${nextBody.slice(firstIdx + find.length)}`;
bodyChanged = true;
}
}
if (bodyChanged) patch.body = nextBody;
const saved = await Note.save(patch);
return { id: saved.id, updated_time: saved.updated_time };
},
};
export default tool;
+74
View File
@@ -0,0 +1,74 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON Schema is arbitrary nested JSON
export type JsonSchema = { type: string;[key: string]: any };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool input shape varies per schema
export type ToolInput = Record<string, any>;
export interface ToolTextContent {
type: 'text';
text: string;
}
// MCP also allows image and resource content; v1 ships text only.
export type ToolContent = ToolTextContent;
export interface ToolCallResult {
content: ToolContent[];
isError?: boolean;
}
// Handlers return their raw payload (any JSON-serialisable value) or throw.
// The dispatcher serialises the payload into MCP content. ToolErrors come back
// as { isError: true, content: [text] }; any other Error bubbles up to the
// JSON-RPC layer as an InternalError so the MCP client sees it.
export interface McpTool {
id: string;
description: string;
inputSchema: JsonSchema;
handler: (input: ToolInput)=> Promise<unknown>;
}
// Throw this from a tool handler for failure modes the LLM should see and
// recover from (note not found, ambiguous match, missing parameter, etc.).
// Plain Errors are treated as internal bugs and surface as JSON-RPC errors.
export class ToolError extends Error {
public constructor(message: string) {
super(message);
this.name = 'ToolError';
}
}
export interface JsonRpcRequest {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- params shape varies per method
params?: any;
}
export interface JsonRpcError {
code: number;
message: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- data is method-specific
data?: any;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- result shape varies per method
result?: any;
error?: JsonRpcError;
}
// Protocol-level errors use -32xxx; application errors come back as
// result.isError=true so the LLM sees them.
export const JsonRpcErrorCodes = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
InternalError: -32603,
};
export const McpProtocolVersion = '2025-06-18';
@@ -0,0 +1,103 @@
import uuid from '../../uuid';
import EncryptionService from '../e2ee/EncryptionService';
import { MasterKeyEntity } from '../e2ee/types';
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
interface DecryptedNoteLockKey {
id: string;
plainText: string;
}
export default class NoteLockKey {
public static instance_: NoteLockKey = null;
private decryptedKey_: string = null;
private keyId_: string = null;
private unlockExpiryTimestamp_: number = null;
private constructor(private encryptionService_: EncryptionService = EncryptionService.instance()) {}
public static instance() {
if (!this.instance_) {
this.instance_ = new NoteLockKey();
}
return this.instance_;
}
public static destroyInstance() {
this.instance_?.lock();
this.instance_ = null;
}
public load(): MasterKeyEntity {
return localSyncInfo().noteLockKey;
}
public save(o: MasterKeyEntity): MasterKeyEntity {
const key = { ...o };
if (!key.id) {
key.id = uuid.create();
key.created_time = Date.now();
}
key.updated_time = Date.now();
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = key;
saveLocalSyncInfo(syncInfo);
if (this.keyId_ && this.keyId_ !== key.id) this.lock();
return key;
}
public async create(password: string, unlockExpiryTimestamp: number = null) {
if (this.load()) throw new Error('Note lock key already exists');
return this.createNewKey_(password, unlockExpiryTimestamp);
}
public async reset(password: string, unlockExpiryTimestamp: number = null) {
return this.createNewKey_(password, unlockExpiryTimestamp);
}
public async unlock(password: string, unlockExpiryTimestamp: number = null) {
const key = this.load();
if (!key) throw new Error('Note lock key has not been created');
if (!key.id) throw new Error('Note lock key does not have an ID');
const decryptedKey = await this.encryptionService_.decryptMasterKeyContent(key, password);
this.keyId_ = key.id;
this.decryptedKey_ = decryptedKey;
this.unlockExpiryTimestamp_ = unlockExpiryTimestamp;
}
public lock() {
this.keyId_ = null;
this.decryptedKey_ = null;
this.unlockExpiryTimestamp_ = null;
}
public isUnlocked() {
this.invalidateExpiredKey_();
if (this.keyId_ && this.keyId_ !== this.load()?.id) this.lock();
return !!this.decryptedKey_;
}
public decryptedKey(): DecryptedNoteLockKey {
if (!this.isUnlocked()) throw new Error('Note lock key is not unlocked');
return {
id: this.keyId_,
plainText: this.decryptedKey_,
};
}
private async createNewKey_(password: string, unlockExpiryTimestamp: number = null) {
const key = this.save(await this.encryptionService_.generateMasterKey(password));
await this.unlock(password, unlockExpiryTimestamp);
return key;
}
private invalidateExpiredKey_() {
if (this.unlockExpiryTimestamp_ !== null && this.unlockExpiryTimestamp_ <= Date.now()) this.lock();
}
}
@@ -0,0 +1,43 @@
import { NoteEntity } from '../database/types';
import isItemId from '../../models/utils/isItemId';
import NoteLockService from './NoteLockService';
export default class NoteLockNote {
public static isLocked(note: NoteEntity): boolean {
if (!note) return false;
return !!note.is_locked;
}
public static isLocking(note: NoteEntity, oldNote: NoteEntity): boolean {
if (!oldNote) return false;
return this.isLocked(note) && !oldNote.is_locked;
}
public static async decryptBody(note: NoteEntity): Promise<NoteEntity> {
if (!note) throw new Error('Gated note lock load is missing note');
if (note.is_locked === undefined) throw new Error('Gated note lock load is missing lock state');
if (this.isLocked(note)) {
// A missing body here means the gated load did not request enough fields, so pass an empty string and let decryption fail explicitly.
return {
...note,
body: await NoteLockService.instance().decryptString(note.body ?? ''),
};
}
return note;
}
public static async prepareForSave(note: NoteEntity, linkedItemIds: (body: string)=> string[], isNew: boolean) {
if (!note) throw new Error('Gated note lock save is missing note');
// Gated saves for existing notes should be based on a loaded note, so missing lock state is a logic error.
if (note.is_locked === undefined && !isNew) throw new Error('Gated note lock save is missing lock state');
const isLocked = this.isLocked(note);
if (!isLocked) note.extracted_resource_ids = '';
const plainTextBody = note.body ?? '';
if (isLocked) {
note.extracted_resource_ids = linkedItemIds(plainTextBody).filter(id => !!isItemId(id)).join(',');
note.body = await NoteLockService.instance().encryptString(plainTextBody);
}
}
}
@@ -0,0 +1,74 @@
import Setting from '../../models/Setting';
import { afterAllCleanUp, encryptionService, fileContentEqual, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '../../testing/test-utils';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockKey from './NoteLockKey';
import NoteLockService from './NoteLockService';
describe('NoteLockService', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
NoteLockKey.destroyInstance();
NoteLockService.destroyInstance();
EncryptionService.instance_ = encryptionService();
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should encrypt and decrypt strings and files without loading an E2EE master key', async () => {
const encryptionServiceInstance = EncryptionService.instance();
const noteLockKey = NoteLockKey.instance();
const service = NoteLockService.instance();
await noteLockKey.create('123456');
const cipherText = await service.encryptString('some secret');
expect(await service.decryptString(cipherText)).toBe('some secret');
const sourcePath = `${supportDir}/photo.jpg`;
const encryptedPath = `${Setting.value('tempDir')}/note-lock-photo.crypted`;
const decryptedPath = `${Setting.value('tempDir')}/note-lock-photo.jpg`;
await service.encryptFile(sourcePath, encryptedPath);
await service.decryptFile(encryptedPath, decryptedPath);
expect(fileContentEqual(sourcePath, encryptedPath)).toBe(false);
expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true);
expect(encryptionServiceInstance.loadedMasterKeysCount()).toBe(0);
});
it('should clear cached key data and only rotate on reset', async () => {
const noteLockKey = NoteLockKey.instance();
const firstKey = await noteLockKey.create('123456');
await expect(noteLockKey.create('123456')).rejects.toThrow('Note lock key already exists');
noteLockKey.lock();
expect(noteLockKey.isUnlocked()).toBe(false);
await expect(noteLockKey.unlock('wrong password')).rejects.toThrow();
await noteLockKey.unlock('123456', Date.now() - 1);
expect(noteLockKey.isUnlocked()).toBe(false);
const secondKey = await noteLockKey.reset('654321');
expect(secondKey.id).not.toBe(firstKey.id);
expect(noteLockKey.isUnlocked()).toBe(true);
});
it('should sync the encrypted note lock key without loading it into the E2EE registry', async () => {
const createdKey = await NoteLockKey.instance().create('123456');
NoteLockKey.destroyInstance();
NoteLockService.destroyInstance();
await synchronizerStart();
await switchClient(2);
EncryptionService.instance_ = encryptionService();
await synchronizerStart();
const syncedKey = NoteLockKey.instance();
expect(syncedKey.load()).toEqual(createdKey);
expect(syncedKey.isUnlocked()).toBe(false);
expect(EncryptionService.instance().loadedMasterKeysCount()).toBe(0);
});
});
@@ -0,0 +1,49 @@
import EncryptionService, { EncryptOptions } from '../e2ee/EncryptionService';
import NoteLockKey from './NoteLockKey';
export default class NoteLockService {
public static instance_: NoteLockService = null;
private constructor(
private encryptionService_: EncryptionService,
private noteLockKey_: NoteLockKey,
) {}
public static instance() {
if (!this.instance_) {
const encryptionService = EncryptionService.instance();
const noteLockKey = NoteLockKey.instance();
this.instance_ = new NoteLockService(encryptionService, noteLockKey);
}
return this.instance_;
}
public static destroyInstance() {
this.instance_ = null;
}
public async encryptString(plainText: string) {
return this.encryptionService_.encryptString(plainText, this.encryptionOptions_());
}
public async decryptString(cipherText: string) {
return this.encryptionService_.decryptString(cipherText, this.encryptionOptions_());
}
public async encryptFile(srcPath: string, destPath: string) {
return this.encryptionService_.encryptFile(srcPath, destPath, this.encryptionOptions_());
}
public async decryptFile(srcPath: string, destPath: string) {
return this.encryptionService_.decryptFile(srcPath, destPath, this.encryptionOptions_());
}
private encryptionOptions_(): EncryptOptions {
const key = this.noteLockKey_.decryptedKey();
return {
masterKeyId: key.id,
decryptedMasterKey: key.plainText,
};
}
}
@@ -0,0 +1,7 @@
import Setting, { Env } from '../../models/Setting';
const isNoteLockEnabled = () => {
return Setting.value('env') === Env.Dev;
};
export default isNoteLockEnabled;
+2
View File
@@ -11,6 +11,7 @@ import route_ping from './routes/ping';
import route_auth from './routes/auth';
import route_events from './routes/events';
import route_revisions from './routes/revisions';
import route_mcp from './routes/mcp';
import { ltrimSlashes } from '../../path-utils';
const md5 = require('md5');
@@ -122,6 +123,7 @@ export default class Api {
auth: route_auth,
events: route_events,
revisions: route_revisions,
mcp: route_mcp,
};
this.dispatch = this.dispatch.bind(this);
+44
View File
@@ -0,0 +1,44 @@
import { Request, RequestMethod } from '../Api';
import { ErrorBadRequest, ErrorForbidden, ErrorMethodNotAllowed } from '../utils/errors';
import Setting from '../../../models/Setting';
import McpServer from '../../mcp/McpServer';
import { JsonRpcRequest, JsonRpcResponse } from '../../mcp/types';
// Single-endpoint JSON-RPC transport. v1 only handles client-initiated
// requests so plain POST/response is enough; streamable HTTP can come later
// if we ever need server-initiated messages.
export default async function(request: Request) {
if (request.method !== RequestMethod.POST) throw new ErrorMethodNotAllowed();
if (!(Setting.value('mcp.enabled') as boolean)) {
throw new ErrorForbidden('MCP server is disabled');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON-RPC envelope is dynamically shaped
let payload: any;
try {
payload = typeof request.body === 'string' ? JSON.parse(request.body) : request.body;
} catch (error) {
throw new ErrorBadRequest('Invalid JSON body');
}
const server = McpServer.instance();
// JSON-RPC batch: spec allows an array of requests. If every item is a
// notification, the server must return nothing (empty body), same as the
// single-notification case.
if (Array.isArray(payload)) {
const responses: JsonRpcResponse[] = [];
for (const item of payload) {
const r = await server.handleRequest(item as JsonRpcRequest);
if (r) responses.push(r);
}
if (!responses.length) return '';
return responses;
}
const response = await server.handleRequest(payload as JsonRpcRequest);
// Notifications get no body per JSON-RPC spec.
if (!response) return '';
return response;
}
@@ -416,4 +416,37 @@ describe('Synchronizer.resources', () => {
expect(await synchronizer().api().get(`${resource.id}.md`)).toContain('my new title 2');
});
// A crafted resource metadata file with `..` in its id must be rejected at sync ingestion. Sync
// must continue past it (not abort the whole batch) so one malformed item can't permanently
// break sync.
it('should skip resources with malformed IDs during sync without aborting', async () => {
const folder = await Folder.save({ title: 'folder1' });
const note = await Note.save({ title: 'good note', parent_id: folder.id });
await synchronizerStart();
const malicious = [
'poc-resource',
'',
'id: ../../joplin-poc-escaped',
'mime: application/octet-stream',
'filename: poc',
'file_extension: txt',
'size: 0',
'created_time: 2026-01-01T00:00:00.000Z',
'updated_time: 2026-01-01T00:00:00.000Z',
'user_created_time: 2026-01-01T00:00:00.000Z',
'user_updated_time: 2026-01-01T00:00:00.000Z',
'type_: 4',
].join('\n');
await fileApi().put('11111111111111111111111111111111.md', malicious);
await switchClient(2);
await synchronizerStart();
const localNote = await Note.load(note.id);
expect(localNote).toBeTruthy();
expect(localNote.title).toBe('good note');
expect(await Resource.load('11111111111111111111111111111111')).toBeFalsy();
});
});
@@ -121,6 +121,25 @@ describe('syncInfoUtils', () => {
expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('0.0.0');
});
it('should merge sync target info and keep the latest note lock key', () => {
const syncInfo1 = new SyncInfo();
syncInfo1.noteLockKey = {
id: '1',
content: 'content1',
updated_time: 100,
};
const syncInfo2 = new SyncInfo();
syncInfo2.noteLockKey = {
id: '2',
content: 'content2',
updated_time: 200,
};
expect(mergeSyncInfos(syncInfo1, syncInfo2).noteLockKey).toEqual(syncInfo2.noteLockKey);
expect(mergeSyncInfos(new SyncInfo(), syncInfo1).noteLockKey).toEqual(syncInfo1.noteLockKey);
});
it('should merge sync target info and takes into account usage of master key - 1', async () => {
const syncInfo1 = new SyncInfo();
syncInfo1.masterKeys = [{
@@ -229,6 +248,15 @@ describe('syncInfoUtils', () => {
'hasBeenUsed': true,
},
],
'noteLockKey': {
'id': '400227d8a77c4d3bb7346514861c643c',
'created_time': 1515008161362,
'updated_time': 1708103706234,
'source_application': 'net.cozic.joplin-desktop',
'encryption_method': 4,
'checksum': '',
'content': '{"iv":"M1uezlW1Pu1g3dwrCTqcHg=="}',
},
'ppk': {
'value': {
'id': 'SNQ5ZCs61KDVUW2qqqqHd3',
@@ -270,6 +298,13 @@ describe('syncInfoUtils', () => {
'updated_time': 1708103706234,
},
],
'noteLockKey': {
'created_time': 1515008161362,
'encryption_method': 4,
'id': '400227d8a77c4d3bb7346514861c643c',
'source_application': 'net.cozic.joplin-desktop',
'updated_time': 1708103706234,
},
'ppk': {
'updatedTime': 1633274368892,
'value': {
@@ -334,6 +369,7 @@ describe('syncInfoUtils', () => {
expect(result.e2ee).toEqual(false);
expect(result.appMinVersion).toEqual('3.7.0');
expect(result.masterKeys).toEqual([]);
expect(result.noteLockKey).toEqual(null);
Logger.globalLogger.enabled = true;
});
@@ -261,6 +261,16 @@ export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
}
}
const noteLockKey1 = s1.noteLockKey;
const noteLockKey2 = s2.noteLockKey;
if (!noteLockKey1) {
output.noteLockKey = noteLockKey2;
} else if (!noteLockKey2) {
output.noteLockKey = noteLockKey1;
} else {
output.noteLockKey = (noteLockKey1.updated_time || 0) >= (noteLockKey2.updated_time || 0) ? noteLockKey1 : noteLockKey2;
}
// We use >= so that the version from s1 (local) is preferred to the version in s2 (remote).
// For example, if s2 has appMinVersion 0.00 and s1 has appMinVersion 0.0.0, we choose the
// local version, 0.0.0.
@@ -279,6 +289,7 @@ export class SyncInfo {
private e2ee_: SyncInfoValueBoolean;
private activeMasterKeyId_: SyncInfoValueString;
private masterKeys_: MasterKeyEntity[] = [];
private noteLockKey_: MasterKeyEntity = null;
private ppk_: SyncInfoValuePublicPrivateKeyPair;
private appMinVersion_: string = appMinVersion_;
private revisionServiceEnabled_: SyncInfoValueBoolean;
@@ -300,6 +311,7 @@ export class SyncInfo {
e2ee: this.e2ee_,
activeMasterKeyId: this.activeMasterKeyId_,
masterKeys: this.masterKeys,
noteLockKey: this.noteLockKey,
ppk: this.ppk_,
appMinVersion: this.appMinVersion,
revisionServiceEnabled: this.revisionServiceEnabled_,
@@ -319,6 +331,11 @@ export class SyncInfo {
});
}
if (filtered.noteLockKey) {
delete filtered.noteLockKey.content;
delete filtered.noteLockKey.checksum;
}
// Truncate the private key and public key
if (filtered.ppk.value) {
filtered.ppk.value.privateKey.ciphertext = `${filtered.ppk.value.privateKey.ciphertext.substr(0, 20)}...${filtered.ppk.value.privateKey.ciphertext.substr(-20)}`;
@@ -344,6 +361,7 @@ export class SyncInfo {
this.e2ee_ = 'e2ee' in s ? s.e2ee : { value: false, updatedTime: 0 };
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
this.noteLockKey_ = 'noteLockKey' in s ? s.noteLockKey : null;
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
this.appMinVersion_ = s.appMinVersion ? s.appMinVersion : '0.0.0';
this.revisionServiceEnabled_ = 'revisionServiceEnabled' in s ? s.revisionServiceEnabled : { value: true, updatedTime: 0 };
@@ -443,6 +461,16 @@ export class SyncInfo {
this.masterKeys_ = v;
}
public get noteLockKey(): MasterKeyEntity {
return this.noteLockKey_;
}
public set noteLockKey(v: MasterKeyEntity) {
if (JSON.stringify(v) === JSON.stringify(this.noteLockKey_)) return;
this.noteLockKey_ = v;
}
public keyTimestamp(name: string): number {
const self = this as unknown as Record<string, { updatedTime: number }>;
if (!(`${name}_` in self)) throw new Error(`Invalid name: ${name}`);
+96 -10
View File
@@ -38,11 +38,17 @@ enum PlanHostingType {
Self = 'self',
}
export interface PlanTieredPricingTableRow {
condition: string;
priceYearly: string;
}
export interface Plan {
name: string;
title: string;
priceMonthly?: StripePublicConfigPrice;
priceYearly?: StripePublicConfigPrice;
pricingTable?: { rows: PlanTieredPricingTableRow[] };
featured: boolean;
iconName: string;
featuresOn: FeatureId[];
@@ -67,16 +73,39 @@ export enum PriceCurrency {
USD = 'USD',
}
export interface StripePublicConfigPrice {
export interface StripePublicConfigTieredAmount {
amount: string;
formattedAmount: string;
users: [number, number|'infinity'];
userRange: { min: number; max: number };
}
export interface StripePublicConfigBasePrice {
accountType: number; // AccountType
id: string;
period: PricePeriod;
currency: PriceCurrency;
}
export interface StripePublicConfigFixedPrice extends StripePublicConfigBasePrice {
amount: string;
formattedAmount: string;
formattedMonthlyAmount: string;
currency: PriceCurrency;
amounts: undefined;
}
export interface StripePublicConfigTieredPrice extends StripePublicConfigBasePrice {
amounts: StripePublicConfigTieredAmount[];
quantityMinimum: number;
amount: undefined;
formattedAmount: undefined;
formattedMonthlyAmount: undefined;
}
export type StripePublicConfigPrice = StripePublicConfigFixedPrice | StripePublicConfigTieredPrice;
export interface StripePublicConfig {
publishableKey: string;
prices: StripePublicConfigPrice[];
@@ -92,17 +121,28 @@ function formatPrice(amount: string | number, currency: PriceCurrency): string {
throw new Error(`Unsupported currency: ${currency}`);
}
interface FindPriceQuery {
accountType?: number;
period?: PricePeriod;
priceId?: string;
}
export const isTieredPrice = (p: StripePublicConfigPrice): p is StripePublicConfigTieredPrice => {
return 'amounts' in p;
};
export function loadStripeConfig(env: string, filePath: string): StripePublicConfig {
const config: StripePublicConfig = JSON.parse(fs.readFileSync(filePath, 'utf8'))[env];
if (!config) throw new Error(`Invalid env: ${env}`);
const decoratePrices = (p: StripePublicConfigPrice) => {
const decoratePrices = (p: StripePublicConfigPrice): StripePublicConfigPrice => {
if (isTieredPrice(p)) {
return {
...p,
amounts: p.amounts.map(amount => ({
...amount,
formattedAmount: formatPrice(amount.amount, p.currency),
userRange: {
min: amount.users[0],
max: amount.users[1] === 'infinity' ? Number.POSITIVE_INFINITY : amount.users[1],
},
})),
};
}
return {
...p,
formattedAmount: formatPrice(p.amount, p.currency),
@@ -116,6 +156,12 @@ export function loadStripeConfig(env: string, filePath: string): StripePublicCon
return config;
}
interface FindPriceQuery {
accountType?: number;
period?: PricePeriod;
priceId?: string;
}
export function findPrice(config: StripePublicConfig, query: FindPriceQuery): StripePublicConfigPrice {
let output: StripePublicConfigPrice = null;
@@ -455,7 +501,32 @@ export const createFeatureTableMd = () => {
return markdownUtils.createMarkdownTable(headers, rows);
};
const getTieredPricingTable = (price: StripePublicConfigPrice) => {
if (!isTieredPrice(price)) throw new Error(`Not a tiered price: ${price.id}`);
const rows: PlanTieredPricingTableRow[] = [];
for (const amount of price.amounts) {
const formatUserCount = (count: number) => {
if (count === Number.POSITIVE_INFINITY) {
return '∞';
}
return String(count);
};
rows.push({
condition: `${
formatUserCount(amount.userRange.min)
}${
formatUserCount(amount.userRange.max)
} users`,
priceYearly: `${amount.formattedAmount} / user / year`,
});
}
return rows;
};
export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Plan> {
// TODO: Set to true to enable self-hosting self-service.
const selfServiceSelfHostingEnabled = false;
return {
basic: {
name: 'basic',
@@ -560,8 +631,23 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
featuresOff: [],
featureLabelsOn: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, true),
featureLabelsOff: [],
cfaLabel: _('Get a quote'),
cfaUrl: 'https://tally.so/r/D4BlOE',
...(selfServiceSelfHostingEnabled ? {
pricingTable: {
rows: getTieredPricingTable(findPrice(stripeConfig, {
accountType: 5,
period: PricePeriod.Yearly,
})),
},
cfaLabel: _('Try it now'),
cfaUrl: '',
priceYearly: findPrice(stripeConfig, {
accountType: 5,
period: PricePeriod.Yearly,
}),
} : {
cfaLabel: _('Get a quote'),
cfaUrl: 'https://tally.so/r/D4BlOE',
}),
footnote: '',
learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
hostingType: PlanHostingType.Self,
+1 -1
View File
@@ -53,7 +53,7 @@
"prettycron": "0.10.0",
"qrcode": "1.5.4",
"query-string": "7.1.3",
"rate-limiter-flexible": "8.3.0",
"rate-limiter-flexible": "9.1.1",
"raw-body": "3.0.2",
"samlify": "2.13.1",
"short-uuid": "5.2.0",
+4 -6
View File
@@ -409,12 +409,10 @@ export default abstract class BaseModel<T> {
if (!ids.length) throw new Error('no id provided');
await this.withTransaction(async () => {
const query = this.db(this.tableName).where({ id: ids[0] });
for (let i = 1; i < ids.length; i++) {
await query.orWhere({ id: ids[i] });
}
const deletedCount = await query.del();
// Use whereIn rather than chaining orWhere in a loop: the latter
// builds a giant OR clause that's expensive for both Knex to
// assemble and Postgres to parse.
const deletedCount = await this.db(this.tableName).whereIn('id', ids as (string|number)[]).del();
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted. ID: ${id}`);
}, 'BaseModel::delete');
}
+34 -13
View File
@@ -1039,11 +1039,21 @@ export default class ItemModel extends BaseModel<Item> {
// items, so a simple processing task like this one is sufficient for now
// but it would be nice to get to the bottom of this bug.
public processOrphanedItems = async (options: ProcessOrphanedItemsOptions = {}) => {
// Process in batches to avoid long-running transactions that can timeout
// and poison the connection pool.
const batchSize = options.batchSize ?? 100;
// The obvious query here is `items LEFT JOIN user_items WHERE
// user_items.id IS NULL LIMIT N` (or the equivalent NOT EXISTS).
// That times out on busy instances: orphans are ~0.0008% of items, so
// Postgres has to scan most of the table to find a single batch, and
// the statement timeout fires before it returns anything.
//
// Instead we walk `items` by primary key in fixed-size windows. Each
// window is two cheap indexed queries: fetch the next N items by id,
// then look up which of those ids exist in user_items. The orphan
// filter happens in TS. Per-query cost is bounded by the window size,
// not by the table size, so no statement can blow past the timeout.
const batchSize = options.batchSize ?? 10000;
let batchNum = 0;
let totalProcessed = 0;
let lastId = '';
modelLogger.info(`processOrphanedItems: Starting with batchSize=${batchSize}`);
@@ -1051,23 +1061,34 @@ export default class ItemModel extends BaseModel<Item> {
batchNum++;
const batchStartTime = Date.now();
// Find items that have no corresponding entry in user_items.
// NOT EXISTS is used instead of LEFT JOIN for performance as it
// allows Postgres to short-circuit on the first match per item.
const orphanedItems: Item[] = await this.db(this.tableName)
const windowItems: Item[] = await this.db(this.tableName)
.select(['items.id', 'items.name', 'items.owner_id'])
.whereNotExists(
this.db('user_items')
.select(this.db.raw('1'))
.whereRaw('user_items.item_id = items.id'),
)
.where('items.id', '>', lastId)
.orderBy('items.id')
.limit(batchSize);
if (!orphanedItems.length) {
if (!windowItems.length) {
modelLogger.info(`processOrphanedItems: Completed. Total items processed: ${totalProcessed}`);
break;
}
// Advance the cursor by the window boundary, not by orphans found,
// so that windows with zero orphans still make progress.
lastId = windowItems[windowItems.length - 1].id;
const windowItemIds = windowItems.map(i => i.id);
const existingUserItems = await this.db('user_items')
.select('item_id')
.whereIn('item_id', windowItemIds);
const userItemIds = new Set(existingUserItems.map(u => u.item_id));
const orphanedItems = windowItems.filter(i => !userItemIds.has(i.id));
if (!orphanedItems.length) {
const batchDuration = Date.now() - batchStartTime;
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - No orphans in window of ${windowItems.length} items (${batchDuration}ms)`);
continue;
}
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Found ${orphanedItems.length} orphaned items`);
const userIds: string[] = unique(orphanedItems.map(i => i.owner_id));
+2 -2
View File
@@ -1,4 +1,4 @@
import { Client } from 'ldapts';
import { Client, EqualityFilter } from 'ldapts';
import { User } from '../services/database/types';
import Logger from '@joplin/utils/Logger';
import { LdapConfig } from './types';
@@ -53,7 +53,7 @@ export default async function ldapLogin(email: string, password: string, user: U
try {
searchResults = await client.search(baseDN, {
filter: `(${mailAttribute}=${email})`,
filter: new EqualityFilter({ attribute: mailAttribute, value: email }),
attributes: ['dn', fullNameAttribute],
});
+42
View File
@@ -58,6 +58,27 @@
"period": "yearly",
"amount": "95.88",
"currency": "EUR"
},
{
"accountType": 5,
"id": "price_1ThCwVL9ZkKzC9sXU9AVyDB1",
"period": "yearly",
"quantityMinimum": 2,
"amounts": [
{
"amount": "40.00",
"users": [2, 10]
},
{
"amount": "30.00",
"users": [11, 50]
},
{
"amount": "20.00",
"users": [51, "infinity"]
}
],
"currency": "EUR"
}
],
"archivedPrices": []
@@ -121,6 +142,27 @@
"period": "yearly",
"amount": "95.88",
"currency": "EUR"
},
{
"accountType": 5,
"id": "price_1TgZ0zLx4fybOTqJl0xF4kHr",
"period": "yearly",
"quantityMinimum": 2,
"amounts": [
{
"amount": "40.00",
"users": [2, 10]
},
{
"amount": "30.00",
"users": [11, 50]
},
{
"amount": "20.00",
"users": [51, "infinity"]
}
],
"currency": "EUR"
}
],
"archivedPrices": [
+2
View File
@@ -324,3 +324,5 @@ sentencepiece
xenova
pretrained
huggingface
stdio
doesnotexist
+61
View File
@@ -0,0 +1,61 @@
# AI chat
Joplin can connect to a chat model — either a cloud service or one running on your own machine — so that plugins can use it to summarise, rewrite, or answer questions about your notes. The model itself is configured in **one place** in the settings. Plugins then use that model without needing their own API keys.
There is no built-in chat sidebar in Joplin; this is the foundation that plugins build on.
## Turning it on
AI is **off by default**. To enable it:
1. Open the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/apps/config_screen.md) and go to the **AI** section.
2. Tick **Enable AI features**.
3. Pick a **Chat provider** and fill in its settings.
4. Click **Test AI configuration** to verify it works.
AI features are only available on the desktop app.
## Picking a provider
| Provider | What it is | Setup |
|---|---|---|
| **Joplin Cloud AI** | A chat model hosted by Joplin Cloud. Available to Joplin Cloud users on supported plans. | Zero config — Joplin reuses your sync credentials. Selected automatically the first time you enable AI if you are syncing with Joplin Cloud. |
| **OpenAI-compatible** | Any service that speaks the OpenAI API: OpenAI itself, Ollama, LM Studio, OpenRouter, vLLM, and many others. | Set the base URL (e.g. `https://api.openai.com/v1` or `http://localhost:11434/v1` for Ollama), the API key, and the model name. |
| **Anthropic** | Direct calls to Anthropic's Claude models. | Set your API key and a model id (e.g. `claude-3-5-sonnet-latest`). |
You can change provider at any time.
## Local vs remote: the "Allow remote providers" switch
To protect against accidentally sending your notes to a cloud service, Joplin keeps a second, separate switch: **Allow remote AI providers**. By default it is off.
- **Local providers** (Ollama, LM Studio, or any other server running on `localhost` / `127.0.0.1`) work with this switch off — nothing leaves your computer.
- **Remote providers** (Joplin Cloud AI, Anthropic, OpenAI, anything not on localhost — including services on your local network) need this switch on. If it's off, AI calls to a remote provider fail with a clear error.
LAN addresses are deliberately treated as remote. The idea is "did my data leave this device", not "did it leave my house".
## Token usage
Most chat providers charge per token. To help you keep an eye on usage, Joplin tracks cumulative input and output tokens for the **currently configured provider** and shows them in Settings → AI. There's a **Reset token usage** button next to the counters.
When you change provider or endpoint, the counters reset automatically so you don't mix totals from different services.
## Testing your setup
The **Test AI configuration** button sends a one-message chat ("Reply with the single word OK.") to your active provider and shows the response inline. This is the fastest way to confirm everything works end-to-end — the configuration screen does not validate provider details until you actually call the model.
If the test fails, the error message is shown directly below the button. Common ones:
- *"Joplin Cloud AI requires Joplin Cloud sync"* — you picked Joplin Cloud AI but you're not syncing with Joplin Cloud. Either restore Joplin Cloud sync or pick a different provider.
- *"Remote AI providers are not allowed"* — turn on **Allow remote AI providers**.
- *"No choices in response — check that the base URL includes /v1"* — common for local Ollama / LM Studio servers; the base URL needs the `/v1` suffix.
## Plugins that use AI chat
Plugins can call `joplin.ai.chat()` to ask the model a question. They cannot choose the provider or the model — both come from your settings. That means a plugin written against OpenAI will also work against Ollama or Joplin Cloud AI with no changes.
If a plugin uses AI, you'll see it in the plugin's description. The plugin can read your notes as part of building its prompt, so the usual rules apply: if you're using a remote provider, that note content goes to the provider.
## Disabling AI
Untick **Enable AI features** in Settings → AI. While the master toggle is off, no AI call from any plugin or built-in feature succeeds, regardless of provider settings or the remote-allow switch.
+80
View File
@@ -0,0 +1,80 @@
# MCP server (connecting Joplin to AI apps)
Joplin can be exposed to external AI applications — such as Claude Desktop, Cursor, or Zed — through a small server that speaks the [Model Context Protocol](https://modelcontextprotocol.io/). Once you turn it on, those apps can search your notes, read them, and (if you allow it) create or update notes from a conversation.
The MCP server is **off by default**. Joplin itself is never the one talking to a model — it just answers questions from whichever AI app you've connected.
## What an AI app can do with it
Joplin exposes a small, fixed set of tools. Each can be turned on or off individually.
| Tool | What it does | Default |
|---|---|---|
| Search notes | Keyword search using Joplin's regular search syntax. | On |
| Semantic search | Search by meaning, using the [local embeddings index](https://github.com/laurent22/joplin/blob/dev/readme/apps/ai_semantic_search.md). | On |
| Read note | Return one note (title, markdown body, notebook, tags). | On |
| List notebooks | List notebooks with their hierarchy. | On |
| List tags | List tags. | On |
| Create note | Create a new note in a chosen notebook. | **Off** |
| Update note | Change the title, body, notebook, or to-do state of an existing note. | **Off** |
| Trash note | Move a note to the trash. | **Off** |
| Edit tags on a note | Add or remove tags by title. | **Off** |
| Create notebook | Create a new notebook, optionally inside an existing one. | **Off** |
The "write" tools default to off so you have to deliberately let an AI app modify your data.
## Turning it on
The MCP server runs on top of the [Web Clipper service](https://github.com/laurent22/joplin/blob/dev/readme/apps/clipper.md), so the Web Clipper must be running.
1. Open the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/apps/config_screen.md) and go to **Web Clipper**. Make sure the service is started, and note the port number and authorisation token.
2. Go to the **MCP** section. Tick **Enable MCP server**.
3. Decide which tools to allow. The read-only tools are pre-ticked; the write tools are not.
Joplin's MCP server is HTTP-based and listens on the same port as the Web Clipper.
## Connecting Claude Desktop
Claude Desktop doesn't speak HTTP MCP servers directly; it needs a small bridge called [`mcp-remote`](https://www.npmjs.com/package/mcp-remote). You don't need to install it — it'll be downloaded automatically the first time.
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or the equivalent on Windows / Linux, and add:
```json
{
"mcpServers": {
"joplin": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"http://127.0.0.1:PORT/mcp?token=YOUR_TOKEN"
]
}
}
}
```
Replace `PORT` with the Web Clipper port and `YOUR_TOKEN` with the authorisation token. Restart Claude Desktop. The Joplin tools become available to Claude when it judges them useful.
There's no list of MCP tools visible in Claude Desktop's UI today. The easiest way to verify is to ask Claude a question that requires note access — for example "What notebooks do I have in Joplin?".
## Connecting other apps
Cursor, Zed, and a growing number of editors support MCP. The setup follows the same shape: point them at `http://127.0.0.1:PORT/mcp?token=YOUR_TOKEN`, possibly via the same `mcp-remote` bridge. Consult the host app's documentation for where its MCP config file lives.
## Privacy
Important to understand:
- An AI app connected via MCP can **read your notes**. Whichever model that app uses (Claude, GPT-4, etc.) may include note content in the prompts it sends to its own cloud provider — that's how it answers your question.
- This is independent of Joplin's own [AI chat](https://github.com/laurent22/joplin/blob/dev/readme/apps/ai_chat.md) provider. Joplin's chat settings have no effect on what an external AI app does.
- The MCP server only listens on `127.0.0.1`, so other machines on your network can't reach it. The authorisation token guards against other applications on your own machine connecting without your knowledge.
- Turning on the **write** tools allows the AI app to create, modify, or trash notes. Joplin will not ask you to confirm each write — the AI app may or may not. Leave write tools off unless you trust both the app and the model behind it.
To shut the server off, untick **Enable MCP server** or turn off the Web Clipper service.
## Troubleshooting
- **Tools don't appear in the AI app.** Most apps cache the tool list when they start. After flipping a per-tool toggle in Joplin, restart the AI app completely (Cmd+Q on macOS, not just close the window) so it reconnects and refreshes.
- **Connection refused / 403.** Check that the Web Clipper is started and that the MCP toggle is on in Joplin's settings. The token in the URL must exactly match the one shown in Joplin under Web Clipper.
- **Write attempt says the tool isn't available.** The write tool is off in Joplin's settings, or wasn't on when the AI app last connected. Turn it on and restart the AI app.
+54
View File
@@ -0,0 +1,54 @@
# Semantic search (AI embeddings)
Joplin can index your notes so they can be searched by **meaning** rather than just by exact words. For example, searching for "the note about pet sitters for my dog" can find a note titled "Vet contacts" if its body mentions someone who walks dogs, even when "pet sitter" never appears in either.
This is sometimes called **semantic search** or **vector search**. It complements Joplin's regular keyword search — it doesn't replace it.
## How it works
When you enable AI, Joplin downloads a small language model (around 140 MB) onto your computer. From then on it runs in the background, reading each note and storing a numerical fingerprint of it in a local index. When you search, your query gets the same treatment and Joplin returns the notes whose fingerprints are closest.
All of this runs **entirely on your device**. The model is local; no note content is sent to a cloud service. The index is also local — it is not synced — so each device builds its own.
## Turning it on
1. Open the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/apps/config_screen.md) and go to the **AI** section.
2. Tick **Enable AI features**.
3. Leave **Enable the embeddings indexer** ticked (it is on by default).
The first time you do this Joplin downloads the model. After that it starts indexing your notes in the background.
## Tracking progress
Settings → AI shows the indexer's state and how many notes have been processed so far. The first time, indexing the entire vault can take a while — Joplin processes 100 notes every 5 minutes to keep the load on your machine very small. A 10 000-note vault takes roughly 8 hours of background work. You can leave it running and use Joplin normally; there's no rush.
After the initial scan, new and edited notes are picked up within a few minutes.
## Using it
There is no dedicated "semantic search" box in Joplin's UI today. Semantic search is exposed in two ways:
- **Plugins** can call `joplin.ai.search()` to look up notes by meaning. The plugin's description tells you whether it uses this.
- **External AI apps** (Claude Desktop, Cursor, etc.) can use it through the [MCP server](https://github.com/laurent22/joplin/blob/dev/readme/apps/ai_mcp.md), via the `semantic_search_notes` tool.
If you want to try it directly, the MCP server is the easiest path.
## Switching providers / re-indexing
If you change the embedding model — for example by switching providers — Joplin **wipes the index and rebuilds it**. Fingerprints from different models aren't comparable, so a clean rebuild is the only safe option. The indexer status panel shows what's happening.
## Platform support
| Platform | Embeddings work? |
|---|---|
| macOS (Apple Silicon) | Yes |
| macOS (Intel) | No — the underlying runtime isn't shipped for this architecture; AI chat still works. |
| Windows (x64, ARM64) | Yes |
| Linux (x64, ARM64) | Yes |
| Mobile, CLI | No — semantic search runs only on the desktop app. |
On platforms where embeddings don't work, the indexer stays paused and any plugin or MCP tool that needs it shows a clear error rather than silently returning nothing.
## Disabling the indexer
You can keep AI chat on but turn off the indexer by unticking **Enable the embeddings indexer** in Settings → AI. The model stays downloaded but no further indexing happens. Existing index data stays on disk; remove the AI profile data manually if you want to delete it completely.
+1 -1
View File
@@ -1,6 +1,6 @@
# AI chat
This spec describes how Joplin exposes a generic chat-with-an-LLM capability to plugins and built-in features. It is the first concrete implementation of the **provider abstraction** and **privacy & cost guardrails** primitives from [AI primitives](ai_primitives.md).
How Joplin exposes a generic chat-with-an-LLM capability to plugins and built-in features. This is the concrete implementation of the **provider abstraction** and **privacy & cost guardrails** primitives — see [ai_primitives.md](ai_primitives.md) for how it fits with the rest.
The goal is not to ship a built-in chat UI — that is left to plugins. The goal is to provide one stable API (`joplin.ai.chat()`) that lets any plugin call a chat model without bundling provider code or asking the user for a second set of API keys.
+1 -1
View File
@@ -1,6 +1,6 @@
# AI embeddings
How Joplin builds and queries the local note-embeddings index. See [ai_primitives.md](ai_primitives.md) for the user-facing spec.
How Joplin builds and queries the local note-embeddings index. See [ai_primitives.md](ai_primitives.md) for how it fits with the other AI primitives.
## Overview
+110
View File
@@ -0,0 +1,110 @@
# MCP server
How the [Model Context Protocol](https://modelcontextprotocol.io/) server is wired into Joplin. See [ai_primitives.md](ai_primitives.md) for how it fits with the other AI primitives.
## Overview
The MCP server is a JSON-RPC endpoint on the existing Web Clipper service. It exposes a small set of purpose-built tools for reading and editing notes. External AI applications (Claude Desktop, Cursor, Zed, …) connect to it as one MCP server among others and call those tools from their host-side chat flow. Joplin itself never sees the conversation — only the individual tool calls.
```
External AI app Joplin (running)
┌───────────────┐ ┌─────────────────────────────┐
│ Claude/Cursor │ ───POST───► │ Web Clipper :41184 │
│ (host LLM) │ JSON-RPC │ └─ /mcp route │
└───────────────┘ │ └─ McpServer │
│ └─ tool registry │
│ └─ tools/* │
└─────────────────────────────┘
```
## Transport
Single HTTP endpoint at `POST /mcp` on the existing Web Clipper port. Auth uses the same Web Clipper token (`api.token`). Stdio is not implemented in v1.
The endpoint accepts a JSON-RPC 2.0 envelope (or a batch array). Requests without an `id` field are notifications and get no response body. Server-initiated messages aren't supported.
## Protocol surface
| Method | Behaviour |
|---|---|
| `initialize` | Returns protocol version, server info, and the `tools` capability. |
| `tools/list` | Lists enabled tools — disabled ones are hidden entirely. |
| `tools/call` | Invokes a tool by name. |
| `ping` | Returns `{}`. |
| `notifications/initialized` | Accepted, no response. |
Unknown methods return JSON-RPC `MethodNotFound`. Malformed `tools/call` params return `InvalidParams` (−32602).
## Tools
Tools live under `packages/lib/services/mcp/tools/`. Each module exports an `McpTool`:
```ts
interface McpTool {
id: string;
description: string;
inputSchema: JsonSchema;
handler: (input) => Promise<unknown>;
}
```
Handlers return their raw payload. The dispatcher JSON-serialises it into MCP text content. There is no need to wrap responses in `{ content, isError }` boilerplate.
| Tool | Purpose | Default |
|---|---|---|
| `search_notes` | Keyword search (Joplin FTS syntax). Returns id, title, notebook id, updated time, and a snippet anchored on the match. | on |
| `semantic_search_notes` | Vector search via the embeddings index. Returns ranked chunks with source note id and score. | on |
| `read_note` | One note with notebook title, tag names, timestamps. Body supports `offset` / `max_chars` paging. | on |
| `list_notebooks` | Flat list of notebooks with `parent_id` and `note_count`. | on |
| `list_tags` | Tags that have at least one attached note. | on |
| `create_note` | Creates a note in the chosen notebook (default folder if omitted). | off |
| `update_note` | Patch title / body / notebook / todo state. Body changes can use `append`, `prepend`, or `replace_text` for small edits. | off |
| `delete_note` | Move to trash. Reversible by the user. | off |
| `manage_tags` | Add/remove tags by title. Missing tags in `add` are created. | off |
| `create_notebook` | Optionally nested under a parent. | off |
Write tools default off so users grant write access deliberately.
### Why purpose-built and not a Data API wrapper
The Data API is a generic data front-end with pagination, fields parameters, and a full entity surface. An LLM doesn't need any of that — it needs a small, opinionated capability surface. Wrapping the Data API would force tool descriptions to explain Joplin internals (pagination cursors, available fields) rather than the operation itself.
The tools should not grow into a generic data layer. If a feature can't be expressed as a small, well-named operation, the right answer is usually a new tool, not flags on an existing one.
## Error model
Two error channels, deliberately distinct:
- **`ToolError`** thrown from a handler → `result.isError = true` with the error message as text. The LLM sees it and can recover ("note not found", "ambiguous match", missing parameter, etc.).
- **Any other `Error`** thrown from a handler → JSON-RPC `InternalError` (−32603). The MCP client treats this as a connection-level failure; the stack goes to the server log. The LLM does not see it as tool output.
The split lets us distinguish expected, LLM-recoverable failures from genuine bugs without designing a custom error-code system.
## Tool registry and toggles
`registry.ts` holds the static list of all tools. `findTool(id)` returns a tool only if it exists *and* its `mcp.tool.<id>.enabled` setting is true. `enabledTools()` filters the same way.
Adding a tool means: write the file in `tools/`, register it in `registry.ts`, add the `mcp.tool.<id>.enabled` setting to `builtInMetadata.ts`, and add it to the table above.
## Settings
All MCP settings live in the dedicated `mcp` section of Settings.
| Setting | Default | Purpose |
|---|---|---|
| `mcp.enabled` | false | Master toggle. Server returns 403 when off. |
| `mcp.tool.<id>.enabled` | varies (see table above) | Per-tool toggle. Disabled tools are hidden from `tools/list`. |
There is no scope/permission system on the auth token — for v1, the per-tool toggles are the granularity. Token scopes could be added later without breaking existing setups.
## Why this lives inside the Web Clipper service
The Web Clipper already provides an authenticated localhost HTTP service that's enabled by users who want external integrations. Reusing it means no second port to open, no second token to manage, and no duplicate transport/CORS plumbing. The MCP server is unreachable when the Web Clipper is off, which is the right default.
## What's out of scope for v1
- **Stdio transport.** Host apps that don't support HTTP MCP servers need a bridge like `mcp-remote`.
- **Token scopes.** Per-tool toggles are enough until a real need surfaces.
- **Resource and prompt MCP primitives.** Only tools are exposed.
- **Streaming responses.** Every call is request/response.
- **Joplin running its own chat.** Chat is handled by the host AI app; Joplin is a tool surface, not an LLM consumer in this flow.
+19 -147
View File
@@ -1,6 +1,10 @@
# AI primitives
This spec describes the core AI primitives in Joplin. The goal is not to ship a single AI feature, but to provide a platform on which features and plugins can be built. The primitives below are validated against five target use cases:
This spec describes the AI primitives in Joplin. They are not features themselves; they are the foundation features and plugins are built on. Detailed specs for each primitive live in their own documents — links below.
## Target use cases
The primitives are designed against five concrete features. None of them are core Joplin features by themselves; they exist as the bar the primitives have to clear.
- **Chat with your note** — a sidebar that can summarise, rewrite, or answer questions about the current note.
- **Chat with your note collection** — ask a question across all notes and get a cited answer.
@@ -8,154 +12,22 @@ This spec describes the core AI primitives in Joplin. The goal is not to ship a
- **AI-generated note graphs** — surfacing semantically related but unlinked notes.
- **Fuzzy semantic search** — finding notes by meaning rather than exact terms (e.g. "the note about pet sitters for my dog").
Each primitive below should plausibly serve at least two of these use cases. Features should be built on top of these primitives, never alongside them.
Each primitive serves at least two of these. Features build on top of these primitives, never alongside them.
## Overview
## The primitives
The primitives are:
1. **Provider abstraction** — pluggable layer for LLM and embedding models, so users can mix cloud, self-hosted, and on-device providers. See [ai_chat.md](ai_chat.md).
2. **Local embeddings index** — background-indexed semantic store, local-only, not synced. See [ai_embeddings.md](ai_embeddings.md).
3. **Retrieval helpers** — the shared query surface plugins call (`joplin.ai.search()`). Covered in [ai_embeddings.md](ai_embeddings.md).
4. **Privacy & cost guardrails** — enforced at the provider layer so every feature inherits them. Covered in [ai_chat.md](ai_chat.md).
5. **MCP server** — exposes Joplin notes to external AI tools. See [ai_mcp.md](ai_mcp.md).
1. **Provider abstraction** — pluggable layer for LLM and embedding models.
2. **Local embeddings index** — background-indexed semantic store, local-only, not synced.
3. **Retrieval helpers** — the shared query surface that plugins actually call.
4. **Privacy & cost guardrails** — enforced at the provider layer so every feature inherits them.
5. **MCP server** — exposes Joplin notes to external AI tools.
## Design principles
Primitives 1–3 are required for any of the five target use cases to work. Primitive 4 must be in place from day one. Primitive 5 is independently valuable and can ship in parallel.
A few rules carried through every primitive:
## Implementation status
| Primitive | Status |
|-------------------------------------|------------------------------------------------------------------------|
| Provider abstraction (chat) | Shipped (Joplin Cloud, OpenAI-compatible, Anthropic) |
| Provider abstraction (embeddings) | Shipped (local ONNX-backed) |
| Local embeddings index | Shipped — multilingual-e5-small, downloaded on first enable |
| Retrieval helpers (`search`) | Shipped as `joplin.ai.search()` |
| Chat helper (`chat`) | Shipped as `joplin.ai.chat()` |
| Privacy & cost guardrails | Shipped (off by default, remote-allow flag, classification, token tally)|
| MCP server | Not started |
Implementation detail for the embeddings stack lives in [ai_embeddings.md](ai_embeddings.md).
## 1. Provider abstraction
A pluggable layer so users can pick their LLM and embedding model independently (cloud, self-hosted, or on-device). No provider is hardcoded.
Two models are configured independently:
- **Chat model** — generates and transforms text (summaries, rewrites, answers).
- **Embedding model** — turns text into vectors for retrieval.
Users may legitimately mix providers (e.g. a cloud chat model with a local embedding model) so the API treats them as two separate slots.
### Configured providers and the active provider
Users configure a **list** of providers (each with its own settings — API key, base URL, model name) and select one as **active** for chat and one as active for embeddings.
### Built-in providers
Chat:
- **Joplin Cloud AI** — zero-config for users on Joplin Cloud sync.
- **OpenAI-compatible** adapter (covers OpenAI, Ollama, LM Studio, vLLM, OpenRouter, and similar via base-URL override).
- **Anthropic** adapter.
Embeddings:
- **Bundled local embedding provider** (see below).
### Chat API
Plugins call `joplin.ai.chat(messages, options?)`. The active provider and model are taken from user settings — plugins cannot pick a model. Throws if AI is disabled, if the active provider is remote and the user hasn't allowed remote providers, or if the provider is misconfigured.
## 2. Local embeddings index
Notes are chunked, embedded, and stored locally, so retrieval can run without a network call. Embeddings are **not synced**: they are large, model-specific, and re-derivable. The model identifier is stored alongside each chunk, so a model change triggers a clear-and-rebuild rather than silent corruption.
Indexing runs as a background service: on first enable it walks the entire vault, after which it follows the note-change feed incrementally. New and edited notes become searchable within minutes.
The bundled model is the default. Users may switch to a different embedding provider via the provider abstraction; doing so triggers a re-index because vectors from different models aren't comparable.
### Platform scope
- **Desktop on Apple Silicon macOS, Linux x64/arm64, Windows x64/arm64**: full support.
- **macOS Intel**: chat works; embeddings do not (no ONNX prebuild for `darwin-x64`).
- **CLI / mobile / server**: no on-device embeddings. Chat with remote providers still works.
Implementation detail lives in [ai_embeddings.md](ai_embeddings.md).
## 3. Retrieval helpers
The shared query surface. All five target features differ mainly in *what* they retrieve — same machinery, different scope.
### API
Plugins call `joplin.ai.search({ query, scope?, relevance? })`. Returns matching chunks with the source note id, chunk text, and a similarity score.
- `query`: either plain text or `{ noteId }` to find chunks similar to an existing note. Note-id queries reuse the note's already-indexed chunks as the query — no re-embedding needed. This is what tag-suggestion and semantic-graph use cases rely on.
- `scope`: `all` (default), `note`, `folder`, or `tag`. Trashed and conflict notes are always excluded.
- `relevance`: `strict` / `normal` / `loose`. A preset that maps internally to model-appropriate values for the number of results returned and the minimum similarity threshold.
Raw thresholds (`k`, `minScore`) are a leaky abstraction: the right values depend on the embedding model, and silently break when the model changes. Plugins calibrated against one model would produce poor results against another with no signal that anything had changed.
The `relevance` preset is the contract plugins write against. Joplin owns the mapping from preset to numeric values per model. When the bundled model changes, the mapping is re-tuned and plugins continue working without modification.
### Hybrid search
Retrieval may eventually be combined internally with the existing FTS-based keyword search. Plugins will not see a contract change when hybrid ranking lands.
### Prior art
The [Jarvis](https://github.com/alondmnt/joplin-plugin-jarvis) plugin already exposes a [semantic search API](https://github.com/alondmnt/joplin-plugin-jarvis/blob/master/docs/API.md) to other plugins, supporting both free-text and note-ID queries. It is a useful reference.
### Mapping to features
How each target use case composes the primitives:
| Feature | Retrieval scope | Then |
|--------------------------|-----------------------------|------------------------------------------|
| Chat with note | `note` or `folder` | Pass chunks as context to chat model |
| Chat with note collection | `all` | Pass top chunks (with note IDs) as context to chat model |
| Fuzzy search | `all` | Show chunks directly as results |
| Tag suggestions | `all`, query = note content | Inspect tags of returned chunks |
| Semantic graph | `all`, per note | Use scores as edge weights |
Chat-based features additionally pass each chunk's source note ID into the prompt so the LLM can cite sources back to the user as clickable links.
## 4. Privacy & cost guardrails
Enforced at the provider layer so every feature — core or plugin — inherits these checks automatically.
### Requirements
- **AI features off by default.** A top-level toggle, plus a per-feature kill switch for the embeddings indexer (users who want chat-only).
- **Offline by default**: remote providers require a separate explicit opt-in.
- **Per-provider classification** as `local` or `remote`. OpenAI-compatible providers can be either depending on the configured base URL (loopback addresses count as local).
- **Token accounting** per provider, queryable by plugins and shown in settings.
- **No silent enablement.** Switching from a local to a remote provider requires an explicit user choice; auto-defaults (e.g. selecting Joplin Cloud AI for Joplin Cloud users on first enable) only apply once.
## 5. MCP server
> Status: not started. Design recorded here for reference.
Joplin runs an optional [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes notes to external AI applications (Claude Desktop, ChatGPT desktop, Cursor, Zed, etc.).
### Scope
The server exposes a minimal tool surface:
- Search notes
- Read note by ID
- Create note
- Update note
- List notebooks and tags
### Implementation
- Built as a thin protocol adapter on top of the existing Data API.
- Auth uses the same token model as the Data API.
- Disabled by default; enabled from the same settings page as the Web Clipper.
- **Per-tool toggles.** Each MCP tool (search, read, create, update, list, etc.) can be individually enabled or disabled in settings, so users can grant external apps read-only access without exposing write operations.
### Why it belongs in this spec
The MCP server is not required for the five target use cases, but it is the cheapest way to make Joplin a first-class participant in the broader AI tool ecosystem without building any chat UI. It also exercises the same note-access surface that internal AI features will need, so the two efforts share infrastructure.
- **Off by default.** No AI capability is reachable without an explicit user opt-in, and remote providers need a second opt-in on top.
- **Plugins don't choose the provider.** Plugins write against `joplin.ai.chat()` and `joplin.ai.search()`; the user picks the active provider in settings. A plugin written against one provider works against all of them.
- **Local first.** The bundled embedding model is local; remote chat providers are optional. Users on small profiles (CLI, mobile) get clean degradation rather than broken features.
- **No conversation state in core.** Chat history, system prompts, retrieval orchestration, and UI all live in the feature (plugin or external app), not in the primitive. The primitives are stateless functions.
- **MCP is a peer, not a client.** The MCP server exposes Joplin to external AI apps as a tool surface. Joplin itself is not an MCP client and does not host conversations.
+20 -20
View File
@@ -12508,7 +12508,7 @@ __metadata:
"@types/node": "npm:18.19.130"
"@types/react": "npm:19.1.10"
"@types/react-redux": "npm:7.1.34"
"@types/serviceworker": "npm:0.0.193"
"@types/serviceworker": "npm:0.0.194"
"@types/tar-stream": "npm:3.1.4"
assert-browserify: "npm:2.0.0"
babel-jest: "npm:29.7.0"
@@ -12562,7 +12562,7 @@ __metadata:
react-native-securerandom: "npm:1.0.1"
react-native-share: "npm:12.2.6"
react-native-sqlite-storage: "npm:6.0.1"
react-native-svg: "npm:15.15.3"
react-native-svg: "npm:15.15.4"
react-native-url-polyfill: "npm:2.0.0"
react-native-version-info: "npm:1.1.1"
react-native-web: "npm:0.21.2"
@@ -12823,7 +12823,7 @@ __metadata:
sqlite3: "npm:5.1.6"
string-padding: "npm:1.0.2"
string-to-stream: "npm:3.0.1"
tar: "npm:7.5.11"
tar: "npm:7.5.12"
tcp-port-used: "npm:1.0.2"
tesseract.js: "npm:6.0.1"
typescript: "npm:5.9.3"
@@ -13025,7 +13025,7 @@ __metadata:
prettycron: "npm:0.10.0"
qrcode: "npm:1.5.4"
query-string: "npm:7.1.3"
rate-limiter-flexible: "npm:8.3.0"
rate-limiter-flexible: "npm:9.1.1"
raw-body: "npm:3.0.2"
samlify: "npm:2.13.1"
short-uuid: "npm:5.2.0"
@@ -19444,10 +19444,10 @@ __metadata:
languageName: node
linkType: hard
"@types/serviceworker@npm:0.0.193":
version: 0.0.193
resolution: "@types/serviceworker@npm:0.0.193"
checksum: 10/88ead2d7e2d4ad09b29ae5cb7dc04fbd12185d33088301168bea6e2f001b2e214173a80897807fff957c9c78db9c6d7f5f0fd840b4f1d40f5ce68a53a8dc4a23
"@types/serviceworker@npm:0.0.194":
version: 0.0.194
resolution: "@types/serviceworker@npm:0.0.194"
checksum: 10/ee2058afbf81d05a2f206a81800df12c3e2b6ab4c3683ec68f53c045c49f13cc08d1b65dad061be60dd0e45c5d7a3569a4edf472ddf33874d2a096c3dd8f4ce8
languageName: node
linkType: hard
@@ -49289,10 +49289,10 @@ __metadata:
languageName: node
linkType: hard
"rate-limiter-flexible@npm:8.3.0":
version: 8.3.0
resolution: "rate-limiter-flexible@npm:8.3.0"
checksum: 10/9c8d7a3224e3a57fdf7da721dabdb4942eb7dd7b5f3aa4cd57e2185928d5842855a8dc2e1db5e0403987d369972d0567e98aade2c2daa71436ebdae704e5a8ff
"rate-limiter-flexible@npm:9.1.1":
version: 9.1.1
resolution: "rate-limiter-flexible@npm:9.1.1"
checksum: 10/3acdd92f9e2664d9ac5cbdbb8fb62933fbb8e881dd7d5aca13ea6bd44a057a05e2ffb75eb3f366033a66ee5b22ef61a3b1d3fae310bf6a306857ad67c767fe04
languageName: node
linkType: hard
@@ -49858,9 +49858,9 @@ __metadata:
languageName: node
linkType: hard
"react-native-svg@npm:15.15.3":
version: 15.15.3
resolution: "react-native-svg@npm:15.15.3"
"react-native-svg@npm:15.15.4":
version: 15.15.4
resolution: "react-native-svg@npm:15.15.4"
dependencies:
css-select: "npm:^5.1.0"
css-tree: "npm:^1.1.3"
@@ -49868,7 +49868,7 @@ __metadata:
peerDependencies:
react: "*"
react-native: "*"
checksum: 10/32254d53ac6d43af1e38011e899ae23ee8a272f1bd8e24fb34f355326cace369cd260331e58a53af3aec67ec8ec40ce6a60e57655259ebd0c32fb156649a4a23
checksum: 10/07b1e9826533ecb4ad731602e720bb4208c18ba0a5f259a17f5ee3aa9b02b1006bb5ae59c81d9d49773c898578a0728724a1085d03939e64e8ddf4ef6c842fdf
languageName: node
linkType: hard
@@ -55760,16 +55760,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:7.5.11":
version: 7.5.11
resolution: "tar@npm:7.5.11"
"tar@npm:7.5.12":
version: 7.5.12
resolution: "tar@npm:7.5.12"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/fb2e77ee858a73936c68e066f4a602d428d6f812e6da0cc1e14a41f99498e4f7fd3535e355fa15157240a5538aa416026cfa6306bb0d1d1c1abf314b1f878e9a
checksum: 10/a72114d28ab9b4878eeebaae8987692a577c390683c13f150d8330e139237038cc46fbb0be6983b02acf5a31b01d74776436ba03790f320a59efb44b8ac39e39
languageName: node
linkType: hard