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, supportDir, switchClient, withWarningSilenced } from '../../testing/test-utils'; import McpServer from './McpServer'; import { McpProtocolVersion } from './types'; import shim from '../../shim'; import { join } from 'path'; const allToolSettings = [ 'ai.tool.search_notes.enabled', 'ai.tool.semantic_search_notes.enabled', 'ai.tool.read_note.enabled', 'ai.tool.list_notebooks.enabled', 'ai.tool.list_tags.enabled', 'ai.tool.read_image.enabled', 'ai.tool.create_note.enabled', 'ai.tool.update_note.enabled', 'ai.tool.delete_note.enabled', 'ai.tool.manage_tags.enabled', 'ai.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 disabled tools as disabled', async () => { Setting.setValue('ai.tool.search_notes.enabled', true); Setting.setValue('ai.tool.read_note.enabled', true); Setting.setValue('ai.tool.list_notebooks.enabled', true); Setting.setValue('ai.tool.list_tags.enabled', true); Setting.setValue('ai.tool.create_note.enabled', false); Setting.setValue('ai.tool.update_note.enabled', false); const response = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', }); type ToolSlice = { name: string; description: string }; const names = response.result.tools.map((t: ToolSlice) => t.name); // Enabled expect(names).toEqual(expect.arrayContaining(['search_notes', 'read_note', 'list_notebooks', 'list_tags'])); // Disabled const createNote = response.result.tools.find((t: ToolSlice) => t.name === 'create_note'); const updateNote = response.result.tools.find((t: ToolSlice) => t.name === 'update_note'); expect(createNote).toBeTruthy(); expect(updateNote).toBeTruthy(); expect(createNote.description).toMatch(/^\(Disabled tool\)/); expect(updateNote.description).toMatch(/^\(Disabled tool\)/); }); 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('ai.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 () => { await withWarningSilenced(/Missing or invalid "name" parameter/, 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('search_notes returns the section enclosing the match', async () => { const folder = await Folder.save({ title: 'F' }); const body = [ 'Intro paragraph.', '', '# Desktop', '', '## Windows', '', `${'filler '.repeat(40)}`, '', '## macOS', '', 'Signing uses a notarytool keychain profile.', '', '# Mobile', '', 'Nothing here yet.', ].join('\n'); await Note.save({ title: 'Release', 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: 'notarytool' } }, }); const payload = parseToolResult(response.result); expect(payload.results[0].section).toBe('1.2'); expect(payload.results[0].section_heading).toBe('macOS'); expect(payload.results[0].snippet).toMatch(/notarytool/); }); test('search_notes reports section 0 for a match before the first heading', async () => { const folder = await Folder.save({ title: 'F' }); const body = 'A preamble mentioning kangaroos.\n\n# Later\n\nOther text.\n'; await Note.save({ title: 'Preamble', 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: 'kangaroos' } }, }); const payload = parseToolResult(response.result); expect(payload.results[0].section).toBe('0'); expect(payload.results[0].section_heading).toBe(''); }); test('search_notes omits the section when the query has no body keywords', async () => { const folder = await Folder.save({ title: 'F' }); // Starts with a heading, so offset 0 falls inside a real section. const body = '# Changelog\n\n## v1\n\nFirst release.\n'; await Note.save({ title: 'Changelog', 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: 'title:Changelog' } }, }); const payload = parseToolResult(response.result); expect(payload.results.length).toBe(1); expect(payload.results[0].section).toBeUndefined(); expect(payload.results[0].section_heading).toBeUndefined(); }); test('search_notes reports section 0 for a note without headings', async () => { const folder = await Folder.save({ title: 'F' }); await Note.save({ title: 'Flat', body: 'Just a flat note about wombats.', 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: 'wombats' } }, }); const payload = parseToolResult(response.result); expect(payload.results[0].section).toBe('0'); }); 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('read_note caps the body by default and steers towards the outline', async () => { const folder = await Folder.save({ title: 'F' }); const body = 'x'.repeat(10000); const note = await Note.save({ title: 'Long', 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 } }, }); const payload = parseToolResult(response.result); expect(payload.body.length).toBe(8000); expect(payload.body_length).toBe(10000); expect(payload.has_more).toBe(true); expect(payload.truncation_message).toContain('8000 of 10000'); expect(payload.truncation_message).toContain('outline'); }); test('read_note returns the outline as pageable text', async () => { const folder = await Folder.save({ title: 'F' }); const body = '# One\n\nabc\n\n## One A\n\ndef\n'; const note = await Note.save({ title: 'Structured', 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, outline: true } }, }); const payload = parseToolResult(response.result); expect(payload.is_outline).toBe(true); expect(payload.body.split('\n')).toEqual([ 'path\tchars\theading', '1\t26\tOne', '1.1\t14\tOne A', ]); // The outline pages on characters, exactly like the body does. const paged = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_note', arguments: { id: note.id, outline: true, max_chars: 20 } }, }); const pagedPayload = parseToolResult(paged.result); expect(pagedPayload.body.length).toBe(20); expect(pagedPayload.has_more).toBe(true); expect(pagedPayload.truncation_message).toContain('Outline truncated at 20'); }); test('read_note returns a single section and pages within it', async () => { const folder = await Folder.save({ title: 'F' }); const body = '# One\n\nabc\n\n# Two\n\nsecond\n'; const note = await Note.save({ title: 'Structured', 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, section: 'Two' } }, }); const payload = parseToolResult(response.result); expect(payload.body).toBe('# Two\n\nsecond\n'); expect(payload.section_path).toBe('2'); expect(payload.section_heading).toBe('Two'); const paged = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_note', arguments: { id: note.id, section: '2', offset: 7, max_chars: 3 } }, }); const pagedPayload = parseToolResult(paged.result); expect(pagedPayload.body).toBe('sec'); expect(pagedPayload.has_more).toBe(true); }); test('read_notes returns several notes and reports unknown ids', async () => { const folder = await Folder.save({ title: 'Work' }); const note1 = await Note.save({ title: 'One', body: 'First', parent_id: folder.id }); const note2 = await Note.save({ title: 'Two', body: 'Second', parent_id: folder.id }); const trashed = await Note.save({ title: 'Gone', parent_id: folder.id, deleted_time: Date.now() }); const missingId = 'a'.repeat(32); const response = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'read_notes', arguments: { ids: [note1.id, missingId, note2.id, trashed.id] } }, }); const payload = parseToolResult(response.result); expect(payload.notes.map((n: { title: string }) => n.title)).toEqual(['One', 'Two']); expect(payload.notes[0].body).toBe('First'); expect(payload.notes[0].notebook_title).toBe('Work'); expect(payload.not_found).toEqual([missingId, trashed.id]); expect(payload.truncation_message).toBeUndefined(); }); test('read_notes shares the body budget across notes', async () => { const folder = await Folder.save({ title: 'F' }); const short = await Note.save({ title: 'Short', body: 'x'.repeat(1000), parent_id: folder.id }); const long1 = await Note.save({ title: 'Long1', body: 'x'.repeat(10000), parent_id: folder.id }); const long2 = await Note.save({ title: 'Long2', body: 'x'.repeat(10000), parent_id: folder.id }); const response = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'read_notes', arguments: { ids: [short.id, long1.id, long2.id] } }, }); const payload = parseToolResult(response.result); const [shortResult, long1Result, long2Result] = payload.notes; expect(shortResult.body.length).toBe(1000); expect(shortResult.has_more).toBe(false); expect(long1Result.body.length).toBe(3500); expect(long1Result.body_length).toBe(10000); expect(long1Result.has_more).toBe(true); expect(long2Result.body.length).toBe(3500); expect(payload.truncation_message).toContain('read_note'); }); test('read_notes rejects too many ids', async () => { const ids = Array.from({ length: 21 }, (_v, i) => i.toString(16).padStart(32, '0')); const response = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'read_notes', arguments: { ids } }, }); expect(response.result.isError).toBe(true); }); test('read_notes is governed by the read_note setting', async () => { Setting.setValue('ai.tool.read_note.enabled', false); const note = await Note.save({ title: 'N' }); const response = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'read_notes', arguments: { ids: [note.id] } }, }); expect(response.result.isError).toBe(true); }); test('read_note rejects outline combined with section', async () => { const folder = await Folder.save({ title: 'F' }); const note = await Note.save({ title: 'Structured', body: '# One\n', 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, outline: true, section: '1' } }, }); expect(response.result.isError).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')); await withWarningSilenced(/forged internal failure/, async () => { 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'); }); test('read_image returns image data as base64', async () => { const folder = await Folder.save({ title: 'Folder' }); let note = await Note.save({ title: 'Test', body: 'Test', parent_id: folder.id }); note = await shim.attachFileToNote(note, join(supportDir, 'photo.jpg')); const resourceId = Note.linkedItemIds(note.body)[0]; const response = await McpServer.instance().handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'read_image', arguments: { id: resourceId, resolution: 'low' } }, }); expect(response.error).toBeFalsy(); const content = response.result.content[0]; expect(content.type).toBe('image'); expect(content.mimeType).toContain('image/'); expect(content.data).toBeTruthy(); }); });