mirror of
https://github.com/laurent22/joplin.git
synced 2026-06-18 20:16:34 +02:00
Desktop: Add MCP server (#15699)
This commit is contained in:
+15
@@ -1653,6 +1653,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
|
||||
@@ -1760,6 +1774,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
|
||||
|
||||
@@ -1679,6 +1679,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
|
||||
@@ -1786,6 +1800,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -324,3 +324,5 @@ sentencepiece
|
||||
xenova
|
||||
pretrained
|
||||
huggingface
|
||||
stdio
|
||||
doesnotexist
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# MCP server
|
||||
|
||||
How the [Model Context Protocol](https://modelcontextprotocol.io/) server is wired into Joplin. See [ai_primitives.md](ai_primitives.md) for the user-facing spec.
|
||||
|
||||
## 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 note search, read, write, and listing. External AI applications (Claude Desktop, Cursor, Zed, etc.) connect to it as one MCP server among others and call those tools as part of 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 mounted at `POST /mcp` on the existing Web Clipper port. Auth uses the same Web Clipper token (`api.token`). Stdio transport is not implemented in v1.
|
||||
|
||||
The endpoint accepts a JSON-RPC 2.0 envelope (or a batch array). Requests with no `id` are treated as notifications and produce no response body. Server-initiated messages are not supported.
|
||||
|
||||
## Protocol surface
|
||||
|
||||
| Method | Behaviour |
|
||||
|--------|-----------|
|
||||
| `initialize` | Returns protocol version, server info, and the `tools` capability. |
|
||||
| `tools/list` | Lists enabled tools — disabled ones are hidden from clients entirely. |
|
||||
| `tools/call` | Invokes a tool by name. |
|
||||
| `ping` | Returns `{}`. |
|
||||
| `notifications/initialized` | Accepted, no response. |
|
||||
|
||||
Unknown methods return JSON-RPC `MethodNotFound`. Tool-level failures (unknown tool, disabled tool, bad input) come back as `result.isError = true` with a text message so the host LLM sees a normal tool-error response and can recover.
|
||||
|
||||
## Tools
|
||||
|
||||
All six tools live under `packages/lib/services/mcp/tools/`. Each module exports a single `McpTool` with `{ id, description, inputSchema, handler }`. The handler returns `{ content, isError? }` where `content` is an array of `{ type: 'text', text }`. JSON payloads are serialised into a text block — MCP clients parse it back, and we keep the option to add `resource` or `image` content types later without changing the registry.
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `search_notes` | FTS search via `SearchEngineUtils`. Returns id, title, notebook id, updated time. |
|
||||
| `read_note` | Loads one note plus its notebook title and tag names. Refuses trashed and conflict notes. |
|
||||
| `list_notebooks` | Flat list of folders with `parent_id` for tree reconstruction. |
|
||||
| `list_tags` | Tags that have at least one attached note. |
|
||||
| `create_note` | Creates a note in the chosen notebook (default folder if omitted). |
|
||||
| `update_note` | Patches title / body / notebook / todo state. Omitted fields keep their value. |
|
||||
|
||||
Why purpose-built rather than wrapping the Data API: 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 over time. 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.
|
||||
|
||||
## 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. Read tools default to enabled; write tools (`create_note`, `update_note`) default to disabled — users have to explicitly grant write access.
|
||||
|
||||
Adding a tool means: register it in `registry.ts`, add the `mcp.tool.<id>.enabled` setting to `builtInMetadata.ts`, and add it to the table above.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `mcp.enabled` | false | Master toggle. Server returns 403 when off. |
|
||||
| `mcp.tool.search_notes.enabled` | true | |
|
||||
| `mcp.tool.read_note.enabled` | true | |
|
||||
| `mcp.tool.list_notebooks.enabled` | true | |
|
||||
| `mcp.tool.list_tags.enabled` | true | |
|
||||
| `mcp.tool.create_note.enabled` | false | Write — opt-in. |
|
||||
| `mcp.tool.update_note.enabled` | false | Write — opt-in. |
|
||||
|
||||
All MCP settings live in the `server` section alongside the Web Clipper. There is no separate scope/permission system on the auth token — for v1, the per-tool toggles are the granularity. Token scopes can 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 dead when 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 will need to wait or run a bridge).
|
||||
- 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.
|
||||
@@ -32,7 +32,7 @@ Primitives 1–3 are required for any of the five target use cases to work. Prim
|
||||
| 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 |
|
||||
| MCP server | Shipped — HTTP transport on the Web Clipper, six purpose-built tools |
|
||||
|
||||
Implementation detail for the embeddings stack lives in [ai_embeddings.md](ai_embeddings.md).
|
||||
|
||||
@@ -135,27 +135,28 @@ Enforced at the provider layer so every feature — core or plugin — inherits
|
||||
|
||||
## 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:
|
||||
The server exposes a minimal, purpose-built tool surface — not a 1:1 wrapper around the Data API:
|
||||
|
||||
- Search notes
|
||||
- Read note by ID
|
||||
- Create note
|
||||
- Update note
|
||||
- List notebooks and tags
|
||||
- `search_notes` — keyword search
|
||||
- `read_note` — fetch a note with its notebook title and tags
|
||||
- `list_notebooks` — flat list of folders with parents
|
||||
- `list_tags` — tags with at least one attached note
|
||||
- `create_note` — new note in a chosen notebook
|
||||
- `update_note` — patch title / body / notebook / todo state
|
||||
|
||||
### Implementation
|
||||
|
||||
- Built as a thin protocol adapter on top of the existing Data API.
|
||||
- HTTP transport mounted at `/mcp` on the existing Web Clipper service. No second port, no second token.
|
||||
- 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.
|
||||
- **Per-tool toggles.** Read tools (`search_notes`, `read_note`, `list_notebooks`, `list_tags`) default to on; write tools (`create_note`, `update_note`) default to off, so users grant write access deliberately.
|
||||
|
||||
Implementation detail lives in [ai_mcp.md](ai_mcp.md).
|
||||
|
||||
### 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.
|
||||
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. Chat handling stays in the host AI app — Joplin is a tool surface, not an LLM consumer in this flow.
|
||||
Reference in New Issue
Block a user