文件
mynode/packages/lib/services/mcp/McpServer.test.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

605 行
25 KiB
TypeScript

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();
});
});