mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Chore: Desktop: AI chat panel: Allow enabling existing MCP tools for use in the chat panel (#15999)
This commit is contained in:
1 parent
6caf347ee0
commit
b6eb739680
37 files changed
+1088
-740
No files matched your search
+19
-12
@@ -1589,13 +1589,13 @@ packages/lib/services/ai/SearchService.js
|
||||
packages/lib/services/ai/SqliteVec.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.js
|
||||
packages/lib/services/ai/applyNoteEdits.js
|
||||
packages/lib/services/ai/availability.test.js
|
||||
packages/lib/services/ai/availability.js
|
||||
packages/lib/services/ai/chunker.test.js
|
||||
packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.tools.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
@@ -1606,6 +1606,24 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1697,17 +1715,6 @@ 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
|
||||
|
||||
+19
-12
@@ -1615,13 +1615,13 @@ packages/lib/services/ai/SearchService.js
|
||||
packages/lib/services/ai/SqliteVec.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.js
|
||||
packages/lib/services/ai/applyNoteEdits.js
|
||||
packages/lib/services/ai/availability.test.js
|
||||
packages/lib/services/ai/availability.js
|
||||
packages/lib/services/ai/chunker.test.js
|
||||
packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.tools.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
@@ -1632,6 +1632,24 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1723,17 +1741,6 @@ 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
|
||||
|
||||
@@ -18,6 +18,7 @@ import JoplinError from '@joplin/lib/JoplinError';
|
||||
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import ChatMessageItem from './ChatMessageItem';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
const logger = Logger.create('ChatPanel');
|
||||
|
||||
@@ -219,6 +220,8 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
selection,
|
||||
noteId: note.id,
|
||||
folderId: note.parent_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -354,6 +357,13 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
{visibleMessages.length === 0 && (
|
||||
<div className='empty'>
|
||||
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
|
||||
<br/>
|
||||
<br/>
|
||||
<button
|
||||
type='button'
|
||||
className='link-button'
|
||||
onClick={() => NavService.go('Config', { props: { defaultSection: 'ai.tools' } })}
|
||||
>{_('Manage capabilities')}</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
|
||||
@@ -125,7 +125,7 @@ test.describe('wcag', () => {
|
||||
|
||||
await mainScreen.chatPanel.sendMessage('/reply-with test');
|
||||
await mainScreen.chatPanel.sendMessage(
|
||||
'/tool appendToNote {"text": "test"}\n/reply-with done',
|
||||
'/tool editor.appendToNote {"text": "test"}\n/reply-with done',
|
||||
);
|
||||
await mainScreen.chatPanel.waitForMessageCount(5);
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ const globalMigrations: GlobalMigration[] = [
|
||||
interface UserSettingMigration {
|
||||
oldName: string;
|
||||
newName: string;
|
||||
transformValue: (value: string)=> string | string[];
|
||||
transformValue: (value: unknown)=> unknown;
|
||||
|
||||
// Currently the migration code only supports migrating a plugin setting to the regular settings
|
||||
// (not a plugin setting to a different name). So "oldName" should be the plugin setting name
|
||||
@@ -182,13 +182,13 @@ const userSettingMigration: UserSettingMigration[] = [
|
||||
{
|
||||
oldName: 'spellChecker.language',
|
||||
newName: 'spellChecker.languages',
|
||||
transformValue: (value: string) => { return [value]; },
|
||||
transformValue: (value) => { return [value]; },
|
||||
isPluginSetting: false,
|
||||
},
|
||||
{
|
||||
oldName: 'plugin-org.joplinapp.plugins.AbcSheetMusic.options',
|
||||
newName: 'markdown.plugin.abc.options',
|
||||
transformValue: (value: string) => { return value; },
|
||||
transformValue: (value) => { return value; },
|
||||
isPluginSetting: true,
|
||||
},
|
||||
];
|
||||
@@ -1294,6 +1294,7 @@ class Setting extends BaseModel {
|
||||
'noteLock',
|
||||
'joplinCloud',
|
||||
'ai',
|
||||
'ai.tools',
|
||||
'mcp',
|
||||
'editor',
|
||||
'plugins',
|
||||
@@ -1314,7 +1315,7 @@ class Setting extends BaseModel {
|
||||
}
|
||||
|
||||
public static isSubSection(sectionName: string) {
|
||||
return ['encryption', 'application', 'appearance', 'joplinCloud'].includes(sectionName);
|
||||
return ['encryption', 'application', 'appearance', 'joplinCloud', 'ai.tools'].includes(sectionName);
|
||||
}
|
||||
|
||||
public static groupMetadatasBySections(metadatas: SettingItem[]): MetadataBySection {
|
||||
@@ -1371,7 +1372,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 (name === 'ai.tools') return _('Tools');
|
||||
|
||||
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
|
||||
|
||||
@@ -1388,6 +1389,9 @@ class Setting extends BaseModel {
|
||||
if (name === 'noteLock') {
|
||||
return _('Locked notes are encrypted on this device and can only be read after entering your note lock password. The password is required again after locking or restarting Joplin.');
|
||||
}
|
||||
if (name === 'ai.tools') {
|
||||
return _('Tools and services to expose to AI. AI agents can use these tools either via the note chat panel or Joplin\'s MCP server (if enabled).');
|
||||
}
|
||||
|
||||
if (this.customSections_[name] && this.customSections_[name].description) return this.customSections_[name].description;
|
||||
|
||||
@@ -1454,7 +1458,7 @@ class Setting extends BaseModel {
|
||||
'importOrExport': 'fa fa-file-export',
|
||||
'moreInfo': 'fa fa-info-circle',
|
||||
'ai': 'fa fa-robot',
|
||||
'mcp': 'fa fa-plug',
|
||||
'ai.tools': 'fa fa-plug',
|
||||
};
|
||||
|
||||
// Icomoon icons are currently not present in the mobile app -- we override these
|
||||
|
||||
@@ -26,6 +26,10 @@ const show3rdPartySyncSettings = (Setting: typeof SettingType) => {
|
||||
return !Setting.value('isJoplinCloudWebApp');
|
||||
};
|
||||
|
||||
const showAiTools = (settings: Record<string, unknown>) => {
|
||||
return !!settings['mcp.enabled'] || !!settings['ai.enabled'];
|
||||
};
|
||||
|
||||
export enum CameraDirection {
|
||||
Back,
|
||||
Front,
|
||||
@@ -636,6 +640,17 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
label: () => _('OCR: Search in extracted content'),
|
||||
},
|
||||
|
||||
'mcp.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'ai',
|
||||
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,
|
||||
},
|
||||
|
||||
'ai.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
@@ -852,124 +867,125 @@ 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': {
|
||||
'ai.tool.edit_current.enabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow searching notes'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow editing the current note (chat panel only)'),
|
||||
description: () => _('Enables the default edit operations for notes with an open AI chat panel. When disabled, the chat panel can still read the open note. Does not apply to MCP.'),
|
||||
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': {
|
||||
'ai.tool.search_notes.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow creating notes'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow searching notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.update_note.enabled': {
|
||||
'ai.tool.read_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow updating notes'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow reading notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.delete_note.enabled': {
|
||||
'ai.tool.list_notebooks.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow trashing notes'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow listing notebooks'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.manage_tags.enabled': {
|
||||
'ai.tool.list_tags.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow editing tags on notes'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow listing tags'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.create_notebook.enabled': {
|
||||
'ai.tool.create_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow creating notebooks'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow creating notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.semantic_search_notes.enabled': {
|
||||
value: true,
|
||||
'ai.tool.update_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow semantic search of notes'),
|
||||
show: showAiTools,
|
||||
label: () => _('Allow updating notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'ai.tool.delete_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: showAiTools,
|
||||
label: () => _('Allow trashing notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'ai.tool.manage_tags.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: showAiTools,
|
||||
label: () => _('Allow editing tags on notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'ai.tool.create_notebook.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: showAiTools,
|
||||
label: () => _('Allow creating notebooks'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'ai.tool.semantic_search_notes.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'ai.tools',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: showAiTools,
|
||||
label: () => _('Allow semantic search of notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { _internal, ChatCommands, NoteContext, runNoteChat } from './noteChat';
|
||||
import { applyAnchorEdits } from './applyNoteEdits';
|
||||
import { _internal, ChatCommands, runNoteChat } from './noteChat';
|
||||
import { ChatRole, ChatToolCall } from './types';
|
||||
import { expectThrow, setupDatabase, switchClient } from '../../testing/test-utils';
|
||||
import { expectThrow, setupDatabase, switchClient, withWarningSilenced } from '../../testing/test-utils';
|
||||
import Setting from '../../models/Setting';
|
||||
import { NoteContext } from './tools/types';
|
||||
import ToolIndex from './tools/ToolIndex';
|
||||
import { isEditorToolCall } from './tools/buildEditorTools';
|
||||
|
||||
const makeTestContext = () => {
|
||||
let body = 'Body';
|
||||
const makeTestContext = (defaultContext: Partial<NoteContext>) => {
|
||||
const initialContext: NoteContext = {
|
||||
title: 'Test',
|
||||
body,
|
||||
body: 'Body',
|
||||
noteId: '00000000000000000000000000000000',
|
||||
folderId: '00000000000000000000000000000001',
|
||||
selection: null,
|
||||
...defaultContext,
|
||||
};
|
||||
let body = initialContext.body;
|
||||
|
||||
const commands: ChatCommands = {
|
||||
replaceSelection: jest.fn(),
|
||||
@@ -21,8 +26,11 @@ const makeTestContext = () => {
|
||||
displayError: jest.fn(),
|
||||
};
|
||||
|
||||
const context = () => ({ ...initialContext, body });
|
||||
|
||||
return {
|
||||
onContext: () => Promise.resolve({ ...initialContext, body }),
|
||||
onContext: () => Promise.resolve(context()),
|
||||
get context() { return context(); },
|
||||
commands,
|
||||
};
|
||||
};
|
||||
@@ -40,6 +48,8 @@ describe('noteChat', () => {
|
||||
title: 'My note',
|
||||
body: 'long body text',
|
||||
selection: 'just this bit',
|
||||
noteId: '',
|
||||
folderId: '',
|
||||
});
|
||||
expect(prompt).toContain('My note');
|
||||
expect(prompt).toContain('just this bit');
|
||||
@@ -48,7 +58,7 @@ describe('noteChat', () => {
|
||||
});
|
||||
|
||||
test('systemPrompt advertises Joplin-specific Markdown features', () => {
|
||||
const prompt = _internal.systemPrompt({ title: 'n', body: 'b', selection: null });
|
||||
const prompt = _internal.systemPrompt({ title: 'n', body: 'b', noteId: '', folderId: '', selection: null });
|
||||
// The fence tags are the load-bearing strings — the model knows the
|
||||
// syntax inside (JSONCanvas, Mermaid, KaTeX) but needs the Joplin
|
||||
// fence tag to wrap it correctly.
|
||||
@@ -66,6 +76,8 @@ describe('noteChat', () => {
|
||||
const prompt = _internal.systemPrompt({
|
||||
title: 'My note',
|
||||
body: 'the whole body',
|
||||
noteId: '',
|
||||
folderId: '',
|
||||
selection: null,
|
||||
});
|
||||
// Including the body in the prompt would cause a cache invalidation on each note change
|
||||
@@ -89,7 +101,7 @@ describe('noteChat', () => {
|
||||
body: 'b',
|
||||
selection: null,
|
||||
},
|
||||
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'readNote'],
|
||||
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'readNoteBody'],
|
||||
},
|
||||
{
|
||||
label: 'offers replaceFencedBlock when Mermaid block present',
|
||||
@@ -98,13 +110,16 @@ describe('noteChat', () => {
|
||||
body: '```mermaid\ngitGraph\n\tcommit\n```\n',
|
||||
selection: null,
|
||||
},
|
||||
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock', 'readNote'],
|
||||
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock', 'readNoteBody'],
|
||||
},
|
||||
])('toolDefinitions should include the expected operations (case $label)', ({ note, expectedOperations }) => {
|
||||
const editSchemaItems = _internal.toolDefinitions(note);
|
||||
const { commands, context } = makeTestContext(note);
|
||||
const editSchemaItems = new ToolIndex({ note: context, commands }).getTools();
|
||||
|
||||
const allowedSchemaOperations = editSchemaItems.map(item => item.name).sort();
|
||||
expect(allowedSchemaOperations).toEqual([...expectedOperations].sort());
|
||||
const allowedSchemaOperations = editSchemaItems.map(item => item.id).sort();
|
||||
expect(
|
||||
allowedSchemaOperations.filter(id => isEditorToolCall(id)),
|
||||
).toEqual([...expectedOperations].map(o => `editor.${o}`).sort());
|
||||
});
|
||||
|
||||
test('estimateTokens approximates char/4', () => {
|
||||
@@ -112,130 +127,10 @@ describe('noteChat', () => {
|
||||
expect(_internal.estimateTokens('a'.repeat(400))).toBe(100);
|
||||
});
|
||||
|
||||
test('applyAnchorEdits appends with paragraph break (covered in detail below)', () => {
|
||||
const { newBody } = applyAnchorEdits('hello', [
|
||||
{ op: 'appendToNote', text: 'world' },
|
||||
], 0);
|
||||
expect(newBody).toBe('hello\n\nworld');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits inserts before/after anchor', () => {
|
||||
const before = applyAnchorEdits('one two three', [
|
||||
{ op: 'insertBefore', anchor: 'two', text: 'X ' },
|
||||
], 0);
|
||||
expect(before.newBody).toBe('one X two three');
|
||||
|
||||
const after = applyAnchorEdits('one two three', [
|
||||
{ op: 'insertAfter', anchor: 'two', text: ' X' },
|
||||
], 0);
|
||||
expect(after.newBody).toBe('one two X three');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits replaces range and reports missing anchor', () => {
|
||||
const replaced = applyAnchorEdits('alpha beta gamma', [
|
||||
{ op: 'replaceRange', anchor: 'beta', text: 'BETA' },
|
||||
], 0);
|
||||
expect(replaced.newBody).toBe('alpha BETA gamma');
|
||||
|
||||
const missing = applyAnchorEdits('alpha beta gamma', [
|
||||
{ op: 'replaceRange', anchor: 'delta', text: 'X' },
|
||||
], 0);
|
||||
expect(missing.newBody).toBe('alpha beta gamma');
|
||||
expect(missing.appliedEdits[0].status).toBe('anchor-not-found');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits picks anchor closest to cursor on duplicates', () => {
|
||||
// "foo" appears at index 0 and index 8. Cursor near second occurrence.
|
||||
const body = 'foo bar foo baz';
|
||||
const { newBody } = applyAnchorEdits(body, [
|
||||
{ op: 'insertAfter', anchor: 'foo', text: '!' },
|
||||
], 10);
|
||||
expect(newBody).toBe('foo bar foo! baz');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits appends with blank line for Markdown paragraph break', () => {
|
||||
// Empty body — no separator needed.
|
||||
expect(applyAnchorEdits('', [{ op: 'appendToNote', text: 'first' }], 0).newBody).toBe('first');
|
||||
// No trailing newline — needs full \n\n.
|
||||
expect(applyAnchorEdits('hello', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
|
||||
// One trailing newline — add one more to make a blank line.
|
||||
expect(applyAnchorEdits('hello\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
|
||||
// Already has blank line — no extra separator.
|
||||
expect(applyAnchorEdits('hello\n\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits rejects invalid edits without mutating the body', () => {
|
||||
// Empty anchor, missing text, wrong shape, unknown op.
|
||||
const body = 'hello world';
|
||||
const cases = [
|
||||
{ op: 'insertBefore', anchor: '', text: 'X' },
|
||||
{ op: 'insertAfter', anchor: 'world' }, // missing text
|
||||
{ op: 'appendToNote' }, // missing text
|
||||
{ op: 'mysteryOp', text: 'X' },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = applyAnchorEdits(body, cases as any, 0);
|
||||
expect(result.newBody).toBe(body);
|
||||
expect(result.appliedEdits.every(e => e.status === 'invalid')).toBe(true);
|
||||
});
|
||||
|
||||
test('applyAnchorEdits replaces the inner content of a fenced block', () => {
|
||||
const body = 'Intro paragraph.\n\n```jsoncanvas\n{"nodes":[],"edges":[]}\n```\n\nOutro.';
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"nodes":[{"id":"a"}],"edges":[]}' },
|
||||
], 0);
|
||||
expect(appliedEdits[0].status).toBe('applied');
|
||||
expect(newBody).toContain('```jsoncanvas\n{"nodes":[{"id":"a"}],"edges":[]}\n```');
|
||||
// Surrounding prose is preserved.
|
||||
expect(newBody).toContain('Intro paragraph.');
|
||||
expect(newBody).toContain('Outro.');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits picks the fenced block closest to the cursor', () => {
|
||||
const first = '```jsoncanvas\n{"v":1}\n```';
|
||||
const second = '```jsoncanvas\n{"v":2}\n```';
|
||||
const body = `${first}\n\nSome prose between.\n\n${second}`;
|
||||
// Cursor near the second block — should edit that one, leaving the first untouched.
|
||||
const cursorNearSecond = body.indexOf(second) + 5;
|
||||
const { newBody } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":99}' },
|
||||
], cursorNearSecond);
|
||||
expect(newBody).toContain('{"v":1}'); // first untouched
|
||||
expect(newBody).toContain('{"v":99}'); // second replaced
|
||||
expect(newBody).not.toContain('{"v":2}');
|
||||
|
||||
// Cursor at 0 — first block wins (fallback behaviour preserved).
|
||||
const { newBody: firstWins } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":0}' },
|
||||
], 0);
|
||||
expect(firstWins).toContain('{"v":0}');
|
||||
expect(firstWins).toContain('{"v":2}'); // second untouched
|
||||
expect(firstWins).not.toContain('{"v":1}');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits reports anchor-not-found when fenced block missing', () => {
|
||||
const body = 'No whiteboard in this note.';
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{}' },
|
||||
], 0);
|
||||
expect(newBody).toBe(body);
|
||||
expect(appliedEdits[0].status).toBe('anchor-not-found');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits rejects replaceFencedBlock with unsupported tag', () => {
|
||||
// Plain code fences (```js, ```python, ...) are not structured-document
|
||||
// formats — the model should use anchor-based ops instead.
|
||||
const body = '```js\nconsole.log("x");\n```';
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'js', text: 'console.log("y");' },
|
||||
], 0);
|
||||
expect(newBody).toBe(body);
|
||||
expect(appliedEdits[0].status).toBe('invalid');
|
||||
});
|
||||
|
||||
test('toolDefinitions advertises replaceFencedBlock with structured-block guidance', () => {
|
||||
const tools = _internal.toolDefinitions({ title: 'n', body: '```abc\n```', selection: null });
|
||||
const toolDefinition = tools.find(tool => tool.name === 'replaceFencedBlock');
|
||||
const { context, commands } = makeTestContext({ title: 'n', body: '```abc\n```', selection: null });
|
||||
const tools = new ToolIndex({ note: context, commands }).getTools();
|
||||
const toolDefinition = tools.find(tool => tool.id === 'editor.replaceFencedBlock');
|
||||
expect(toolDefinition).toBeTruthy();
|
||||
expect(toolDefinition.description).toContain('jsoncanvas');
|
||||
// Guidance to use the op for structured blocks, appendToNote for creation.
|
||||
@@ -243,16 +138,6 @@ describe('noteChat', () => {
|
||||
expect(toolDefinition.description).toContain('Use appendToNote');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits refuses replaceRange anchor covering most of the body', () => {
|
||||
const body = 'short text';
|
||||
// Anchor is >50% of body — would be destructive; refuse.
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceRange', anchor: 'short text', text: '' },
|
||||
], 0);
|
||||
expect(newBody).toBe(body);
|
||||
expect(appliedEdits[0].status).toBe('invalid');
|
||||
});
|
||||
|
||||
test('assertWithinTokenBudget should include tool calls in the token budget', async () => {
|
||||
_internal.assertWithinTokenBudget([
|
||||
{ role: ChatRole.Assistant, content: 'Testing... '.repeat(5) },
|
||||
@@ -264,7 +149,7 @@ describe('noteChat', () => {
|
||||
role: ChatRole.Assistant,
|
||||
content: 'Testing...'.repeat(5),
|
||||
toolCalls: [{
|
||||
toolName: 'replaceSelection',
|
||||
toolName: 'editor.replaceSelection',
|
||||
callId: 'call-1',
|
||||
arguments: { text: 'Testing... '.repeat(50) },
|
||||
parseError: null,
|
||||
@@ -276,11 +161,11 @@ describe('noteChat', () => {
|
||||
|
||||
test('runTools should describe successful edit operations', async () => {
|
||||
const toolCalls: ChatToolCall[] = [
|
||||
{ toolName: 'appendToNote', callId: 'call-1', arguments: { text: 'Test.' }, parseError: null },
|
||||
{ toolName: 'replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' }, parseError: null },
|
||||
{ toolName: 'editor.appendToNote', callId: 'call-1', arguments: { text: 'Test.' }, parseError: null },
|
||||
{ toolName: 'editor.replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' }, parseError: null },
|
||||
];
|
||||
|
||||
const { onContext, commands } = makeTestContext();
|
||||
const { onContext, commands } = makeTestContext({});
|
||||
|
||||
const result = await _internal.runTools(
|
||||
{ text: 'Test...', toolCalls, usage: { inputTokens: 10, outputTokens: 300 } },
|
||||
@@ -294,14 +179,14 @@ describe('noteChat', () => {
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'appendToNote',
|
||||
toolName: 'editor.appendToNote',
|
||||
toolCallId: 'call-1',
|
||||
isError: false,
|
||||
userDescription: 'Added 1 word',
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'replaceRange',
|
||||
toolName: 'editor.replaceRange',
|
||||
toolCallId: 'call-2',
|
||||
isError: false,
|
||||
userDescription: 'Removed 1 word\nAdded 1 word',
|
||||
@@ -310,31 +195,34 @@ describe('noteChat', () => {
|
||||
});
|
||||
|
||||
test('noteChat should stop retry loop if several responses in a row include tool failures', async () => {
|
||||
const { onContext, commands } = makeTestContext();
|
||||
const { onContext, commands } = makeTestContext({});
|
||||
const failingAndSucceedingToolCall = (repeat: number) => [
|
||||
`/repeat ${repeat}`,
|
||||
// one failing tool
|
||||
`/tool replaceRange ${JSON.stringify({ anchor: 'does not exist', text: 'replaced' })}`,
|
||||
`/tool editor.replaceRange ${JSON.stringify({ anchor: 'does not exist', text: 'replaced' })}`,
|
||||
// and one tool call that should succeed
|
||||
'/tool appendToNote { "text": "test" }',
|
||||
'/tool editor.appendToNote { "text": "test" }',
|
||||
].join('\n');
|
||||
|
||||
const userMessage = failingAndSucceedingToolCall(32);
|
||||
const result = await runNoteChat(
|
||||
onContext,
|
||||
[],
|
||||
userMessage,
|
||||
commands,
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
const result = await withWarningSilenced(
|
||||
/call editor.replaceRange failed/,
|
||||
() => runNoteChat(
|
||||
onContext,
|
||||
[],
|
||||
userMessage,
|
||||
commands,
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
|
||||
const failedAttempts = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
failedAttempts.push(
|
||||
{ role: ChatRole.Assistant },
|
||||
{ role: ChatRole.Tool, isError: true, toolName: 'replaceRange' },
|
||||
{ role: ChatRole.Tool, isError: false, toolName: 'appendToNote' },
|
||||
{ role: ChatRole.Tool, isError: true, toolName: 'editor.replaceRange' },
|
||||
{ role: ChatRole.Tool, isError: false, toolName: 'editor.appendToNote' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -348,12 +236,12 @@ describe('noteChat', () => {
|
||||
|
||||
...failedAttempts,
|
||||
|
||||
{ role: ChatRole.Assistant, content: 'tool not found: replaceRange\ntool not found: appendToNote' },
|
||||
{ role: ChatRole.Assistant, content: 'tool not found: editor.replaceRange\ntool not found: editor.appendToNote' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('noteChat initialize the chat history with the note body', async () => {
|
||||
const { onContext, commands } = makeTestContext();
|
||||
const { onContext, commands } = makeTestContext({});
|
||||
|
||||
const result = await runNoteChat(
|
||||
onContext,
|
||||
@@ -367,8 +255,8 @@ describe('noteChat', () => {
|
||||
expect(result).toMatchObject([
|
||||
{ role: ChatRole.System },
|
||||
{ role: ChatRole.User, content: 'test' },
|
||||
{ role: ChatRole.Assistant, content: '', toolCalls: [{ toolName: 'readNote' }] },
|
||||
{ role: ChatRole.Tool, content: 'Body', toolName: 'readNote' },
|
||||
{ role: ChatRole.Assistant, content: '', toolCalls: [{ toolName: 'editor.readNoteBody' }] },
|
||||
{ role: ChatRole.Tool, content: 'Body', toolName: 'editor.readNoteBody' },
|
||||
{ role: ChatRole.Assistant, content: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { runNoteChat } from './noteChat';
|
||||
import { ChatMessage, ChatRole } from './types';
|
||||
import { setupDatabase, switchClient } from '../../testing/test-utils';
|
||||
import Setting from '../../models/Setting';
|
||||
import Note from '../../models/Note';
|
||||
import Tag from '../../models/Tag';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import { NoteContext } from './tools/types';
|
||||
|
||||
const runChatForNote = (note: NoteEntity, history: ChatMessage[], message: string) => {
|
||||
const getContext = async () => {
|
||||
const currentNote = await Note.load(note.id);
|
||||
return <NoteContext> {
|
||||
title: currentNote.title,
|
||||
body: currentNote.body,
|
||||
selection: null,
|
||||
noteId: note.id,
|
||||
folderId: note.parent_id,
|
||||
};
|
||||
};
|
||||
|
||||
return runNoteChat(
|
||||
getContext,
|
||||
history,
|
||||
message,
|
||||
{
|
||||
replaceSelection: jest.fn(),
|
||||
updateNoteBody: async (newBody: string) => {
|
||||
await Note.save({ id: note.id, body: newBody });
|
||||
},
|
||||
displayError: jest.fn(),
|
||||
},
|
||||
jest.fn(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
};
|
||||
|
||||
describe('noteChat.tools', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabase(0);
|
||||
await switchClient(0);
|
||||
Setting.setValue('ai.chat.providerType', 'test-provider');
|
||||
Setting.setValue('ai.enabled', true);
|
||||
});
|
||||
|
||||
test('should allow external tools when enabled in settings', async () => {
|
||||
const note = await Note.save({ title: 'test' });
|
||||
|
||||
const toolOptions = {
|
||||
note_id: note.id,
|
||||
add: ['test'],
|
||||
};
|
||||
const toolCallMessage = `/tool manage_tags ${JSON.stringify(toolOptions)}`;
|
||||
const runChat = () => (
|
||||
runChatForNote(
|
||||
note,
|
||||
// Avoid the default history
|
||||
[{ role: ChatRole.User, content: 'testing' }],
|
||||
toolCallMessage,
|
||||
)
|
||||
);
|
||||
|
||||
// Should not allow managing tags when disabled in settings
|
||||
Setting.setValue('ai.tool.manage_tags.enabled', false);
|
||||
await runChat();
|
||||
expect(await Tag.tagsByNoteId(note.id)).toHaveLength(0);
|
||||
|
||||
// Should allow managing tags when enabled
|
||||
Setting.setValue('ai.tool.manage_tags.enabled', true);
|
||||
await runChat();
|
||||
expect(await Tag.tagsByNoteId(note.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('running a disabled tool should provide information how to enable it', async () => {
|
||||
const note = await Note.save({ title: 'test' });
|
||||
|
||||
const toolCallMessage = `/tool manage_tags ${JSON.stringify({ someArg: 'test' })}`;
|
||||
const runChat = () => (
|
||||
runChatForNote(
|
||||
note,
|
||||
// Avoid the default history
|
||||
[{ role: ChatRole.User, content: 'testing' }],
|
||||
toolCallMessage,
|
||||
)
|
||||
);
|
||||
|
||||
// Should not allow managing tags when disabled in settings
|
||||
Setting.setValue('ai.tool.manage_tags.enabled', false);
|
||||
const messages = await runChat();
|
||||
const infoMessage = messages.find(message => message.role === ChatRole.Tool && message.toolName === 'manage_tags');
|
||||
expect(infoMessage).toBeTruthy();
|
||||
expect(infoMessage.content).toContain('Tool `manage_tags` is disabled in Joplin\'s settings');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import AiService from './AiService';
|
||||
import { ChatMessage, ChatResult, ChatRole, ChatToolCall, ChatToolMessage, ToolSpec } from './types';
|
||||
import { ChatMessage, ChatResult, ChatRole, ChatToolCall, ChatToolMessage } from './types';
|
||||
import JoplinError from '../../JoplinError';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import findFencedBlock from './utils/findFencedBlock';
|
||||
import { applyAnchorEdits, supportedStructuredBlockTags } from './applyNoteEdits';
|
||||
import { hasOwnProperty } from '@joplin/utils/object';
|
||||
const Countable = require('../../countable/Countable');
|
||||
import { _, _n } from '../../locale';
|
||||
import { _ } from '../../locale';
|
||||
import { EditOp, isEditorToolCall } from './tools/buildEditorTools';
|
||||
import { NoteContext, ToolError } from './tools/types';
|
||||
import ToolIndex from './tools/ToolIndex';
|
||||
|
||||
const logger = Logger.create('noteChat');
|
||||
|
||||
@@ -16,25 +15,6 @@ const noteBodyTokenBudget = 80000;
|
||||
|
||||
const charsPerToken = 4;
|
||||
|
||||
export type EditOp =
|
||||
| { op: 'replaceSelection'; text: string }
|
||||
| { op: 'insertBefore'; anchor: string; text: string }
|
||||
| { op: 'insertAfter'; anchor: string; text: string }
|
||||
| { op: 'appendToNote'; text: string }
|
||||
| { op: 'replaceRange'; anchor: string; text: string }
|
||||
| { op: 'replaceFencedBlock'; tag: string; text: string }
|
||||
;
|
||||
|
||||
const knownOps = new Set<EditOp['op']>([
|
||||
'replaceSelection', 'insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock',
|
||||
]);
|
||||
|
||||
export interface NoteContext {
|
||||
title: string;
|
||||
body: string;
|
||||
selection: string | null;
|
||||
}
|
||||
|
||||
export interface ChatReply {
|
||||
reply: string;
|
||||
edits: EditOp[];
|
||||
@@ -56,10 +36,6 @@ const joplinMarkdownNotes = [
|
||||
'- HTML is allowed for features without a Markdown equivalent (e.g. `<s>strikethrough</s>`).',
|
||||
].join('\n');
|
||||
|
||||
const hasStructuredBlock = (note: NoteContext) => {
|
||||
return supportedStructuredBlockTags.some(tag => !!findFencedBlock(note.body, tag, 0));
|
||||
};
|
||||
|
||||
// The system prompt should be relatively constant across note changes to take advantage of prompt caching.
|
||||
// See https://developers.openai.com/api/docs/guides/prompt-caching
|
||||
const systemPrompt = (note: NoteContext) => {
|
||||
@@ -67,6 +43,7 @@ const systemPrompt = (note: NoteContext) => {
|
||||
'You are an assistant helping the user work on a note in Joplin, a note-taking application.',
|
||||
'',
|
||||
`Note title: ${note.title || '(untitled)'}`,
|
||||
`Note ID: ${note.noteId}`,
|
||||
'',
|
||||
joplinMarkdownNotes,
|
||||
'',
|
||||
@@ -85,7 +62,8 @@ const systemPrompt = (note: NoteContext) => {
|
||||
} else {
|
||||
lines.push(
|
||||
'Tool guidance:',
|
||||
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "appendToNote" tool.',
|
||||
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "editor.appendToNote" tool.',
|
||||
'- For tasks modifying the current note, prefer "editor." tools to global tools.',
|
||||
'- Some tools call for anchors. Anchors must be exact substrings of the current note body. Keep them short but unique.',
|
||||
);
|
||||
}
|
||||
@@ -95,118 +73,6 @@ const systemPrompt = (note: NoteContext) => {
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const toolDefinitions = (note: NoteContext) => {
|
||||
const result: ToolSpec[] = [];
|
||||
|
||||
if (note.selection) {
|
||||
result.push({
|
||||
name: 'replaceSelection',
|
||||
description: 'Replaces the text currently selected by the user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result.push(
|
||||
{
|
||||
name: 'readNote',
|
||||
description: 'Get the current, up-to-date content of the note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
result.push(
|
||||
{
|
||||
name: 'appendToNote',
|
||||
description: 'Adds text to the end of the note. Text is always added to the end of the note. This tool does not require an anchor.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const anchoredSchema = (anchorDescription: string, textDescription: string) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
anchor: { type: 'string', description: anchorDescription },
|
||||
text: { type: 'string', description: textDescription },
|
||||
},
|
||||
required: ['anchor', 'text'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
result.push(
|
||||
{
|
||||
name: 'insertBefore',
|
||||
description: 'Insert text immediately before the first occurrence of `anchor`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'Text to find',
|
||||
'What to insert just **before** the found text',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'insertAfter',
|
||||
description: 'Insert text immediately after the first occurrence of `anchor`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'Text to find',
|
||||
'What to insert just **after** the found text',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'replaceRange',
|
||||
description: 'Replace the first occurrence of `anchor` with `text`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'The text to replace',
|
||||
'What to replace it with',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const hasFencedBlock = hasStructuredBlock(note);
|
||||
if (hasFencedBlock) {
|
||||
result.push({
|
||||
name: 'replaceFencedBlock',
|
||||
description: [
|
||||
'Replaces the inner content of the first ```<tag>``` fenced block.',
|
||||
`"text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`,
|
||||
'Use appendToNote to create a new fenced block.',
|
||||
].join(' '),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: {
|
||||
type: 'string',
|
||||
enum: supportedStructuredBlockTags,
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'The new content for the block content. Should not include the fenced block delimiters.',
|
||||
},
|
||||
},
|
||||
required: ['tag', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const createHistory = (history: ChatMessage[], newMessage: string, context: NoteContext): ChatMessage[] => {
|
||||
history = removeSystemPrompt(history);
|
||||
|
||||
@@ -228,14 +94,14 @@ const createHistory = (history: ChatMessage[], newMessage: string, context: Note
|
||||
content: '',
|
||||
hide: true,
|
||||
toolCalls: [
|
||||
{ callId, arguments: { }, toolName: 'readNote', parseError: null },
|
||||
{ callId, arguments: { }, toolName: 'editor.readNoteBody', parseError: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
content: context.body,
|
||||
toolCallId: callId,
|
||||
toolName: 'readNote',
|
||||
toolName: 'editor.readNoteBody',
|
||||
userDescription: '',
|
||||
isEdit: false,
|
||||
isError: false,
|
||||
@@ -308,12 +174,15 @@ interface StepNoteChatOptions {
|
||||
const stepNoteChat = async ({
|
||||
context, messages, commands, onHistoryChanged, signal, allowTools,
|
||||
}: StepNoteChatOptions) => {
|
||||
assertWithinTokenBudget(messages, noteBodyTokenBudget);
|
||||
const note = await context();
|
||||
|
||||
assertWithinTokenBudget(messages, noteBodyTokenBudget);
|
||||
const chatResult = await AiService.instance().chat(
|
||||
messages, { tools: allowTools ? toolDefinitions(note) : [], signal },
|
||||
);
|
||||
const toolIndex = new ToolIndex({ note, commands });
|
||||
|
||||
const chatResult = await AiService.instance().chat(messages, {
|
||||
tools: allowTools ? toolIndex.getTools() : [],
|
||||
signal,
|
||||
});
|
||||
|
||||
// Chat results can contain sensitive information -- only log in dev mode
|
||||
logger.debug('Received chat result', chatResult);
|
||||
@@ -326,7 +195,9 @@ const stepNoteChat = async ({
|
||||
|
||||
let failedToolCount = 0;
|
||||
if (allowTools) {
|
||||
const toolResults = await runTools(chatResult, note, context, commands, signal);
|
||||
const toolResults = await runTools(
|
||||
chatResult, note, context, commands, signal,
|
||||
);
|
||||
messages.push(...toolResults);
|
||||
onHistoryChanged([...messages]);
|
||||
|
||||
@@ -379,171 +250,116 @@ const assertNotCancelled = (signal: AbortSignal) => {
|
||||
}
|
||||
};
|
||||
|
||||
const runTools = async (chat: ChatResult, initialContext: NoteContext, context: OnContext, commands: ChatCommands, signal: AbortSignal) => {
|
||||
const runTools = async (
|
||||
chat: ChatResult, initialContext: NoteContext, getContext: OnContext, commands: ChatCommands, signal: AbortSignal,
|
||||
) => {
|
||||
assertNotCancelled(signal);
|
||||
|
||||
const currentContext = await getContext();
|
||||
let chatResponses: ChatToolMessage[] = [];
|
||||
|
||||
const isEdit = (toolName: string) => isValidEditOp(toolName);
|
||||
const isEdit = (toolName: string) => isEditorToolCall(toolName) && toolName !== 'editor.readNoteBody';
|
||||
|
||||
const respondSuccess = (action: ChatToolCall, message: string, userDescription?: string) => {
|
||||
chatResponses.push({
|
||||
role: ChatRole.Tool,
|
||||
toolName: action.toolName,
|
||||
toolCallId: action.callId,
|
||||
content: message,
|
||||
userDescription: userDescription ?? message,
|
||||
isError: false,
|
||||
isEdit: isEdit(action.toolName),
|
||||
});
|
||||
};
|
||||
|
||||
const respondFailure = (action: ChatToolCall, reason: string) => {
|
||||
const respondFailure = (action: ChatToolCall, reason: string, userDescription?: string) => {
|
||||
chatResponses.push({
|
||||
role: ChatRole.Tool,
|
||||
toolName: action.toolName,
|
||||
toolCallId: action.callId,
|
||||
content: `failed: ${reason}`,
|
||||
userDescription: reason,
|
||||
userDescription: userDescription ?? reason,
|
||||
isError: true,
|
||||
isEdit: isEdit(action.toolName),
|
||||
});
|
||||
};
|
||||
|
||||
// Only replaceSelection operations allowed when there's a selection
|
||||
const currentContext = await context();
|
||||
if (currentContext.selection) {
|
||||
const expectedName = 'replaceSelection';
|
||||
const action = chat.toolCalls.find(toolCall => toolCall.toolName === expectedName);
|
||||
if (action) {
|
||||
const args = action.arguments;
|
||||
if (action.parseError) {
|
||||
respondFailure(action, action.parseError);
|
||||
} else if (!hasOwnProperty(args, 'text') || typeof args.text !== 'string') {
|
||||
respondFailure(action, 'missing or invalid `text` property');
|
||||
} else {
|
||||
try {
|
||||
await commands.replaceSelection(args.text, currentContext.selection);
|
||||
respondSuccess(action, describeEditOperation(toolCallToEditOperation(action)));
|
||||
} catch (error) {
|
||||
logger.error('Failed to replace selection', error);
|
||||
respondFailure(action, 'failed to replace selection');
|
||||
commands.displayError(error.message ?? String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
const runTool = async (toolCall: ChatToolCall, body: string) => {
|
||||
const index = new ToolIndex({
|
||||
note: { ...currentContext, body },
|
||||
commands: {
|
||||
...commands,
|
||||
updateNoteBody: newBody => {
|
||||
body = newBody;
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
});
|
||||
const toolId = toolCall.toolName;
|
||||
const tool = index.findTool(toolId);
|
||||
|
||||
// Only one tool call is allowed in this context
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
if (action === toolCall) continue;
|
||||
if (toolCall.toolName === expectedName) {
|
||||
respondFailure(toolCall, `Only a single ${expectedName} tool call is valid in this context`);
|
||||
} else {
|
||||
respondFailure(toolCall, `Only the ${expectedName} tool is valid in this context.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const initialBody = initialContext.body;
|
||||
let body = currentContext.body;
|
||||
const hadExternalBodyChanges = initialBody !== body;
|
||||
let attemptedEdit = false;
|
||||
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
if (toolCall.toolName === 'replaceSelection') {
|
||||
respondFailure(toolCall, 'replaceSelection is invalid in this context');
|
||||
} else if (toolCall.parseError) {
|
||||
respondFailure(toolCall, toolCall.parseError);
|
||||
} else if (toolCall.toolName === 'readNote') {
|
||||
respondSuccess(toolCall, body, _('Read note'));
|
||||
} else if (!isValidEditOp(toolCall.toolName)) {
|
||||
respondFailure(toolCall, 'tool not found');
|
||||
} else if (hadExternalBodyChanges) {
|
||||
attemptedEdit = true;
|
||||
respondFailure(toolCall, 'body changed externally, before edit could be applied');
|
||||
} else {
|
||||
attemptedEdit = true;
|
||||
const editOperation = toolCallToEditOperation(toolCall);
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [editOperation], 0);
|
||||
body = newBody;
|
||||
|
||||
if (appliedEdits.length !== 1) {
|
||||
throw new Error(`Invalid state: Wrong number of applied edits, ${appliedEdits.length}`);
|
||||
}
|
||||
|
||||
const edit = appliedEdits[0];
|
||||
if (edit.status === 'applied') {
|
||||
respondSuccess(toolCall, describeEditOperation(editOperation));
|
||||
} else {
|
||||
respondFailure(toolCall, edit.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attemptedEdit && hadExternalBodyChanges) {
|
||||
commands.displayError(_('The note changed while the request was running; edits were not applied. Try again.'));
|
||||
}
|
||||
// Avoid overwriting user-created changes
|
||||
if (!hadExternalBodyChanges && body !== initialBody) {
|
||||
if (tool) {
|
||||
try {
|
||||
await commands.updateNoteBody(body, initialBody);
|
||||
const output = await tool.handler(toolCall.arguments, { selectedFolderId: currentContext.folderId });
|
||||
|
||||
chatResponses.push({
|
||||
role: ChatRole.Tool,
|
||||
toolName: tool.id,
|
||||
toolCallId: toolCall.callId,
|
||||
content: typeof output === 'string' ? output : JSON.stringify(output),
|
||||
userDescription: tool.userDescription(toolCall.arguments, output),
|
||||
isError: false,
|
||||
isEdit: isEdit(tool.id),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update body', error);
|
||||
|
||||
// Replace any 'success' messages with failures, since all tools
|
||||
// currently depend on updating the body.
|
||||
chatResponses = [];
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
respondFailure(toolCall, 'failed to update body');
|
||||
logger.error('Tool call', toolCall.toolName, 'failed', error);
|
||||
if (error instanceof ToolError) {
|
||||
respondFailure(toolCall, error.message);
|
||||
} else {
|
||||
respondFailure(toolCall, 'failed');
|
||||
}
|
||||
|
||||
commands.displayError(error.message ?? String(error));
|
||||
}
|
||||
} else {
|
||||
respondFailure(
|
||||
toolCall,
|
||||
index.describeToolNotFoundFailure(toolId),
|
||||
_('Tool not found: %s', toolId),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const initialBody = initialContext.body;
|
||||
let body = currentContext.body;
|
||||
const hadExternalBodyChanges = initialBody !== body;
|
||||
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
if (toolCall.parseError) {
|
||||
respondFailure(toolCall, toolCall.parseError);
|
||||
} else {
|
||||
body = await runTool(toolCall, body);
|
||||
}
|
||||
}
|
||||
|
||||
const appliedEdits = chatResponses.filter(r => r.isEdit && !r.isError);
|
||||
if (appliedEdits.length > 0 && body !== initialBody) {
|
||||
try {
|
||||
// Avoid overwriting user-created changes
|
||||
if (hadExternalBodyChanges) {
|
||||
throw new Error(_('The note changed while the request was running; edits were not applied. Try again.'));
|
||||
}
|
||||
|
||||
await commands.updateNoteBody(body, initialBody);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update body', error);
|
||||
|
||||
// All of the edit operations depend on updating the body, so they all
|
||||
// need to be marked as failures:
|
||||
const successfulEditResponses = new Set(appliedEdits);
|
||||
chatResponses = chatResponses.map(response => {
|
||||
if (!successfulEditResponses.has(response)) return response;
|
||||
return {
|
||||
...response,
|
||||
content: 'failed to update body',
|
||||
isError: true,
|
||||
};
|
||||
});
|
||||
|
||||
commands.displayError(error.message ?? String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return chatResponses;
|
||||
};
|
||||
|
||||
const isValidEditOp = (operation: string): operation is EditOp['op'] => {
|
||||
return knownOps.has(operation as EditOp['op']);
|
||||
};
|
||||
|
||||
const toolCallToEditOperation = (toolCall: ChatToolCall): EditOp => {
|
||||
const op = toolCall.toolName;
|
||||
if (!isValidEditOp(op)) {
|
||||
throw new Error(`Invalid edit operation: ${op}`);
|
||||
}
|
||||
|
||||
const args = toolCall.arguments;
|
||||
return {
|
||||
op,
|
||||
...('text' in args && typeof args.text === 'string' ? { text: args.text } : {}),
|
||||
...('anchor' in args && typeof args.anchor === 'string' ? { anchor: args.anchor } : {}),
|
||||
...('tag' in args && typeof args.tag === 'string' ? { tag: args.tag } : {}),
|
||||
} as EditOp;
|
||||
};
|
||||
|
||||
const countWords = (text: string): number => Countable.countOnce(text, { stripTags: true }).words;
|
||||
|
||||
const describeEditOperation = (editOp: EditOp) => {
|
||||
const describeWordsAdded = (count: number) => _n('Added %d word', 'Added %d words', count, count);
|
||||
const describeWordsRemoved = (count: number) => _n('Removed %d word', 'Removed %d words', count, count);
|
||||
|
||||
let actionDescription;
|
||||
if (editOp.op === 'replaceRange') {
|
||||
actionDescription = [
|
||||
describeWordsRemoved(countWords(editOp.anchor)),
|
||||
describeWordsAdded(countWords(editOp.text)),
|
||||
].join('\n');
|
||||
} else if (editOp.op === 'replaceFencedBlock') {
|
||||
actionDescription = _('Updated %s block', editOp.tag);
|
||||
} else if (editOp.op === 'replaceSelection') {
|
||||
actionDescription = _('Replaced selection');
|
||||
} else {
|
||||
actionDescription = describeWordsAdded(countWords(editOp.text));
|
||||
}
|
||||
return actionDescription;
|
||||
};
|
||||
|
||||
// Exported for tests.
|
||||
export const _internal = { systemPrompt, toolDefinitions, assertWithinTokenBudget, estimateTokens, runTools, noteBodyTokenBudget };
|
||||
export const _internal = { systemPrompt, assertWithinTokenBudget, estimateTokens, runTools, noteBodyTokenBudget };
|
||||
@@ -177,7 +177,7 @@ export default class AnthropicProvider extends ChatProviderBase {
|
||||
if (options?.tools) {
|
||||
body.tools = options.tools.map(tool => {
|
||||
return {
|
||||
name: tool.name,
|
||||
name: tool.id,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
};
|
||||
|
||||
@@ -2,8 +2,9 @@ import shim from '../../../shim';
|
||||
import JoplinError from '../../../JoplinError';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { rtrimSlashes } from '@joplin/utils/path';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification, ToolSpec } from '../types';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification } from '../types';
|
||||
import ChatProviderBase from './ChatProviderBase';
|
||||
import { ToolSpec } from '../tools/types';
|
||||
|
||||
const logger = Logger.create('OpenAiCompatibleProvider');
|
||||
|
||||
@@ -48,7 +49,7 @@ const convertTool = (tool: ToolSpec) => {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
name: tool.id,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
strict: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ProviderClassification, ToolSpec } from '../types';
|
||||
import { ToolSpec } from '../tools/types';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ProviderClassification } from '../types';
|
||||
import ChatProviderBase from './ChatProviderBase';
|
||||
|
||||
const parseUserCommand = (message: ChatMessage, availableTools: ToolSpec[]) => {
|
||||
@@ -6,14 +7,19 @@ const parseUserCommand = (message: ChatMessage, availableTools: ToolSpec[]) => {
|
||||
const reply = [];
|
||||
let repeat = 0;
|
||||
for (const line of message.content.split('\n')) {
|
||||
const command = line.match(/^\/(tool|reply-with|repeat) (.*)$/);
|
||||
const command = line.match(/^\/(tool|describe-tool|reply-with|repeat) (.*)$/);
|
||||
if (!command) continue;
|
||||
|
||||
if (command[1] === 'tool') {
|
||||
const isToolCall = command[1] === 'tool';
|
||||
const isDescribeToolRequest = command[1] === 'describe-tool';
|
||||
if (isToolCall || isDescribeToolRequest) {
|
||||
const args = command[2].split(' ');
|
||||
const toolName = args[0];
|
||||
if (!availableTools.some(tool => tool.name === toolName)) {
|
||||
const tool = availableTools.find(t => t.id === toolName);
|
||||
if (!tool) {
|
||||
reply.push(`tool not found: ${toolName}`);
|
||||
} else if (isDescribeToolRequest) {
|
||||
reply.push(`${toolName}: ${tool.description}`);
|
||||
} else {
|
||||
toolCalls.push({
|
||||
callId: `tool-call-${toolCalls.length}`,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { substrWithEllipsis } from '../../../string-utils';
|
||||
import { EditorToolContext, ToolDefinition } from './types';
|
||||
import { toolSettingKey, toolSettingName, toolsSectionName } from './utils/settings';
|
||||
import globalTools from './global/index';
|
||||
import buildEditorTools from './buildEditorTools';
|
||||
import Setting from '../../../models/Setting';
|
||||
|
||||
export default class ToolIndex {
|
||||
private tools_: ToolDefinition[];
|
||||
|
||||
public constructor(private editorContext_: EditorToolContext|null) {
|
||||
this.tools_ = [
|
||||
...globalTools,
|
||||
...(this.editorContext_ ? buildEditorTools(this.editorContext_) : []),
|
||||
];
|
||||
}
|
||||
|
||||
private isEnabled(toolId: string) {
|
||||
const tool = this.tools_.find(tool => tool.id === toolId);
|
||||
if (!tool) return false;
|
||||
// For tools with no corresponding setting
|
||||
if (tool.enabled !== undefined) return tool.enabled;
|
||||
|
||||
const settingName = toolSettingKey(toolId);
|
||||
return !!Setting.valueNoThrow(settingName, false);
|
||||
}
|
||||
|
||||
public getTools() {
|
||||
return this.tools_.map(tool => {
|
||||
const enabled = this.isEnabled(tool.id);
|
||||
return {
|
||||
...tool,
|
||||
description: enabled ? tool.description : [
|
||||
'(Disabled tool)',
|
||||
substrWithEllipsis(tool.description, 0, 64),
|
||||
`**Disabled**: The setting "${toolSettingName(tool.id)}" must be enabled to use this tool.`,
|
||||
].join('\n'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public findTool(id: string) {
|
||||
if (!this.isEnabled(id)) return null;
|
||||
return this.getTools().find(tool => tool.id === id);
|
||||
}
|
||||
|
||||
public describeToolNotFoundFailure(toolId: string) {
|
||||
const settingName = toolSettingName(toolId);
|
||||
// Return "disabled" vs "unknown" differently so the LLM gets actionable feedback.
|
||||
if (!settingName) return `Unknown tool: '${toolId}'`;
|
||||
|
||||
return [
|
||||
`# Tool \`${toolId}\` is disabled in Joplin's settings`,
|
||||
'',
|
||||
'If you need this tool, ask the user to enable it for you. The user can enable this tool by:',
|
||||
'1. opening Joplin\'s settings screen,',
|
||||
`2. opening the "${toolsSectionName()}" tab, and`,
|
||||
`3. enabling the "${settingName}" setting.`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { hasOwnProperty } from '@joplin/utils/object';
|
||||
import { _, _n } from '../../../locale';
|
||||
import { NoteContext, ToolDefinition, ToolError, ToolInput } from './types';
|
||||
import { EditorToolContext } from './types';
|
||||
const Countable = require('../../../countable/Countable');
|
||||
import buildTool from './utils/buildTool';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { applyAnchorEdits, supportedStructuredBlockTags } from './utils/applyNoteEdits';
|
||||
import findFencedBlock from '../utils/findFencedBlock';
|
||||
|
||||
const logger = Logger.create('buildEditorTools');
|
||||
|
||||
export type EditOp =
|
||||
| { op: 'replaceSelection'; text: string }
|
||||
| { op: 'insertBefore'; anchor: string; text: string }
|
||||
| { op: 'insertAfter'; anchor: string; text: string }
|
||||
| { op: 'appendToNote'; text: string }
|
||||
| { op: 'replaceRange'; anchor: string; text: string }
|
||||
| { op: 'replaceFencedBlock'; tag: string; text: string }
|
||||
;
|
||||
|
||||
const knownOps = new Set<EditOp['op']>([
|
||||
'replaceSelection', 'insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock',
|
||||
]);
|
||||
|
||||
// Tools that make changes to the current note body
|
||||
const isValidEditOp = (operation: string): operation is EditOp['op'] => {
|
||||
return knownOps.has(operation as EditOp['op']);
|
||||
};
|
||||
|
||||
// Tools that interact with the editor
|
||||
export const isEditorToolCall = (toolId: string) => {
|
||||
if (!toolId.startsWith('editor.')) return false;
|
||||
toolId = toolId.replace(/^editor\./, '');
|
||||
return isValidEditOp(toolId) || toolId === 'readNoteBody';
|
||||
};
|
||||
|
||||
const toolCallToEditOperation = (operationId: EditOp['op'], args: ToolInput): EditOp => {
|
||||
return {
|
||||
op: operationId,
|
||||
...('text' in args && typeof args.text === 'string' ? { text: args.text } : {}),
|
||||
...('anchor' in args && typeof args.anchor === 'string' ? { anchor: args.anchor } : {}),
|
||||
...('tag' in args && typeof args.tag === 'string' ? { tag: args.tag } : {}),
|
||||
} as EditOp;
|
||||
};
|
||||
|
||||
const countWords = (text: string): number => Countable.countOnce(text, { stripTags: true }).words;
|
||||
|
||||
const describeEditOperation = (editOp: EditOp) => {
|
||||
const describeWordsAdded = (count: number) => _n('Added %d word', 'Added %d words', count, count);
|
||||
const describeWordsRemoved = (count: number) => _n('Removed %d word', 'Removed %d words', count, count);
|
||||
|
||||
let actionDescription;
|
||||
if (editOp.op === 'replaceRange') {
|
||||
actionDescription = [
|
||||
describeWordsRemoved(countWords(editOp.anchor)),
|
||||
describeWordsAdded(countWords(editOp.text)),
|
||||
].join('\n');
|
||||
} else if (editOp.op === 'replaceFencedBlock') {
|
||||
actionDescription = _('Updated %s block', editOp.tag);
|
||||
} else if (editOp.op === 'replaceSelection') {
|
||||
actionDescription = _('Replaced selection');
|
||||
} else {
|
||||
actionDescription = describeWordsAdded(countWords(editOp.text));
|
||||
}
|
||||
return actionDescription;
|
||||
};
|
||||
|
||||
const hasStructuredBlock = (note: NoteContext) => {
|
||||
return supportedStructuredBlockTags.some(tag => !!findFencedBlock(note.body, tag, 0));
|
||||
};
|
||||
|
||||
const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
const result: ToolDefinition[] = [];
|
||||
const addSelectionOperations = () => {
|
||||
result.push(buildTool({
|
||||
id: 'editor.replaceSelection',
|
||||
userDescription: (_input: ToolInput, output: string) => output,
|
||||
description: 'Replaces the text currently selected by the user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (args: ToolInput) => {
|
||||
if (!hasOwnProperty(args, 'text') || typeof args.text !== 'string') {
|
||||
throw new ToolError('missing or invalid `text` property');
|
||||
} else {
|
||||
try {
|
||||
await commands.replaceSelection(args.text, note.selection);
|
||||
} catch (error) {
|
||||
logger.error(error.message ?? String(error));
|
||||
throw new ToolError('failed to replace selection');
|
||||
}
|
||||
return describeEditOperation({ op: 'replaceSelection', text: args.text });
|
||||
}
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const addEditOperations = () => {
|
||||
const buildEditTool = (id: EditOp['op']) => ({
|
||||
id: `editor.${id}`,
|
||||
userDescription: (args: ToolInput) => (
|
||||
describeEditOperation(toolCallToEditOperation(id, args))
|
||||
),
|
||||
handler: async (args: ToolInput) => {
|
||||
const editOperation = toolCallToEditOperation(id, args);
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(note.body, [editOperation], 0);
|
||||
await commands.updateNoteBody(newBody, note.body);
|
||||
|
||||
if (appliedEdits.length !== 1) {
|
||||
throw new Error(`Invalid state: Wrong number of applied edits, ${appliedEdits.length}`);
|
||||
}
|
||||
|
||||
const edit = appliedEdits[0];
|
||||
if (edit.status === 'applied') {
|
||||
return describeEditOperation(editOperation);
|
||||
} else {
|
||||
throw new ToolError(edit.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
result.push(
|
||||
{
|
||||
...buildEditTool('appendToNote'),
|
||||
description: 'Adds text to the end of the current note: Text is always added to the end of the note. This tool does not require an anchor.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const anchoredSchema = (anchorDescription: string, textDescription: string) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
anchor: { type: 'string', description: anchorDescription },
|
||||
text: { type: 'string', description: textDescription },
|
||||
},
|
||||
required: ['anchor', 'text'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
result.push(
|
||||
{
|
||||
...buildEditTool('insertBefore'),
|
||||
description: 'Add text before an `anchor` in the current note: Insert text immediately before the first occurrence of `anchor`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'Text to find',
|
||||
'What to insert just **before** the found text',
|
||||
),
|
||||
},
|
||||
{
|
||||
...buildEditTool('insertAfter'),
|
||||
description: 'Add text after an `anchor` in the current note: Inserts text immediately after the first occurrence of `anchor` in the current note.',
|
||||
inputSchema: anchoredSchema(
|
||||
'Text to find',
|
||||
'What to insert just **after** the found text',
|
||||
),
|
||||
},
|
||||
{
|
||||
...buildEditTool('replaceRange'),
|
||||
description: 'Remove or replace text in the current note: Replaces the first occurrence of `anchor` with `text`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'The text to replace',
|
||||
'What to replace it with',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const hasFencedBlock = hasStructuredBlock(note);
|
||||
if (hasFencedBlock) {
|
||||
result.push({
|
||||
...buildEditTool('replaceFencedBlock'),
|
||||
description: [
|
||||
'Replaces the inner content of the first ```<tag>``` fenced block in the current note.',
|
||||
`"text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`,
|
||||
'Use appendToNote to create a new fenced block.',
|
||||
].join(' '),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: {
|
||||
type: 'string',
|
||||
enum: supportedStructuredBlockTags,
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'The new content for the block content. Should not include the fenced block delimiters.',
|
||||
},
|
||||
},
|
||||
required: ['tag', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
const addReadOperation = () => {
|
||||
result.push(
|
||||
{
|
||||
id: 'editor.readNoteBody',
|
||||
// Always enabled in the editor context:
|
||||
enabled: true,
|
||||
userDescription: () => _('Read note'),
|
||||
description: 'Get the current, up-to-date content of the note. This returns the full content of the note that\'s currently open in Joplin\'s editor.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: () => {
|
||||
return Promise.resolve(note.body);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (note.selection) {
|
||||
addSelectionOperations();
|
||||
} else {
|
||||
addReadOperation();
|
||||
addEditOperations();
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default buildEditorTools;
|
||||
+10
-7
@@ -1,6 +1,8 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Folder from '../../../models/Folder';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import Note from '../../../../models/Note';
|
||||
import Folder from '../../../../models/Folder';
|
||||
import { _ } from '../../../../locale';
|
||||
import buildTool from '../utils/buildTool';
|
||||
import { ToolError, ToolInput } from '../types';
|
||||
|
||||
interface Input {
|
||||
title?: string;
|
||||
@@ -9,8 +11,9 @@ interface Input {
|
||||
is_todo?: boolean;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'create_note',
|
||||
userDescription: (input: ToolInput) => _('Created note: %s', input.title ?? _('(no title)')),
|
||||
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',
|
||||
@@ -22,7 +25,7 @@ const tool: McpTool = {
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
handler: async (input: Input, context) => {
|
||||
if (typeof input.title !== 'string' || !input.title.trim()) {
|
||||
throw new ToolError('Missing or invalid "title" parameter');
|
||||
}
|
||||
@@ -31,7 +34,7 @@ const tool: McpTool = {
|
||||
throw new ToolError('"is_todo" must be a boolean');
|
||||
}
|
||||
|
||||
let parentId = input.notebook_id;
|
||||
let parentId = input.notebook_id ?? context.selectedFolderId;
|
||||
if (parentId) {
|
||||
const folder = await Folder.load(parentId);
|
||||
if (!folder) throw new ToolError(`Notebook not found: ${parentId}`);
|
||||
@@ -50,6 +53,6 @@ const tool: McpTool = {
|
||||
|
||||
return { id: saved.id, title: saved.title, notebook_id: saved.parent_id };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
import Folder from '../../../models/Folder';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import { _ } from '../../../../locale';
|
||||
import Folder from '../../../../models/Folder';
|
||||
import { ToolError } from '../types';
|
||||
import buildTool from '../utils/buildTool';
|
||||
|
||||
interface Input {
|
||||
title?: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'create_notebook',
|
||||
userDescription: (input: Input) => _('Created notebook: %s', input.title ?? _('(no title)')),
|
||||
description: 'Create a new notebook. Optionally nest it under an existing notebook by passing parent_id.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -32,6 +35,6 @@ const tool: McpTool = {
|
||||
|
||||
return { id: saved.id, title: saved.title, parent_id: saved.parent_id || null };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
+7
-4
@@ -1,12 +1,15 @@
|
||||
import Note from '../../../models/Note';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import { _ } from '../../../../locale';
|
||||
import Note from '../../../../models/Note';
|
||||
import { ToolError } from '../types';
|
||||
import buildTool from '../utils/buildTool';
|
||||
|
||||
interface Input {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'delete_note',
|
||||
userDescription: () => _('Deleted 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',
|
||||
@@ -27,6 +30,6 @@ const tool: McpTool = {
|
||||
|
||||
return { id: input.id, trashed: true };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,23 @@
|
||||
import searchNotes from './searchNotes';
|
||||
import semanticSearchNotes from './semanticSearchNotes';
|
||||
import readNote from './readNote';
|
||||
import listNotebooks from './listNotebooks';
|
||||
import listTags from './listTags';
|
||||
import createNote from './createNote';
|
||||
import updateNote from './updateNote';
|
||||
import deleteNote from './deleteNote';
|
||||
import manageTags from './manageTags';
|
||||
import createNotebook from './createNotebook';
|
||||
|
||||
export default [
|
||||
searchNotes,
|
||||
semanticSearchNotes,
|
||||
readNote,
|
||||
listNotebooks,
|
||||
listTags,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
manageTags,
|
||||
createNotebook,
|
||||
];
|
||||
+7
-5
@@ -1,9 +1,11 @@
|
||||
import Folder from '../../../models/Folder';
|
||||
import { FolderEntity } from '../../database/types';
|
||||
import { McpTool } from '../types';
|
||||
import { _ } from '../../../../locale';
|
||||
import Folder from '../../../../models/Folder';
|
||||
import { FolderEntity } from '../../../database/types';
|
||||
import buildTool from '../utils/buildTool';
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'list_notebooks',
|
||||
userDescription: () => _('Read 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',
|
||||
@@ -23,6 +25,6 @@ const tool: McpTool = {
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
+6
-4
@@ -1,8 +1,10 @@
|
||||
import Tag from '../../../models/Tag';
|
||||
import { McpTool } from '../types';
|
||||
import { _ } from '../../../../locale';
|
||||
import Tag from '../../../../models/Tag';
|
||||
import buildTool from '../utils/buildTool';
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'list_tags',
|
||||
userDescription: () => _('Read all tags'),
|
||||
description: 'List all tags that have at least one note attached, with their ids and titles.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -14,6 +16,6 @@ const tool: McpTool = {
|
||||
tags: tags.map(t => ({ id: t.id, title: t.title })),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
+20
-5
@@ -1,6 +1,8 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Tag from '../../../models/Tag';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import { _n } from '../../../../locale';
|
||||
import Note from '../../../../models/Note';
|
||||
import Tag from '../../../../models/Tag';
|
||||
import { ToolError } from '../types';
|
||||
import buildTool from '../utils/buildTool';
|
||||
|
||||
interface Input {
|
||||
note_id?: string;
|
||||
@@ -8,8 +10,21 @@ interface Input {
|
||||
remove?: string[];
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'manage_tags',
|
||||
userDescription: (_input, output) => {
|
||||
const addList = output.added;
|
||||
const removeList = output.removed;
|
||||
|
||||
const result = [];
|
||||
if (addList.length) {
|
||||
result.push(_n('Added tag: %s', 'Added tags: %s', addList.length, addList.join(', ')));
|
||||
}
|
||||
if (removeList.length) {
|
||||
result.push(_n('Removed tag: %s', 'Removed tags: %s', removeList.length, removeList.join(', ')));
|
||||
}
|
||||
return result.join('\n');
|
||||
},
|
||||
description: 'Add or remove tags on a note. Tags are addressed by title; unknown tags in "add" are created automatically.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -60,7 +75,7 @@ const tool: McpTool = {
|
||||
tags: currentTags.map(t => t.title),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const checkStringArray = (value: unknown, paramName: string) => {
|
||||
if (value === undefined || value === null) return [];
|
||||
+9
-6
@@ -1,7 +1,9 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Folder from '../../../models/Folder';
|
||||
import Tag from '../../../models/Tag';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import Note from '../../../../models/Note';
|
||||
import Folder from '../../../../models/Folder';
|
||||
import Tag from '../../../../models/Tag';
|
||||
import { _ } from '../../../../locale';
|
||||
import buildTool from '../utils/buildTool';
|
||||
import { ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
id?: string;
|
||||
@@ -11,8 +13,9 @@ interface Input {
|
||||
|
||||
const defaultMaxChars = 0;
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'read_note',
|
||||
userDescription: (_input, output) => _('Read note: %s', output.title ?? _('(untitled)')),
|
||||
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',
|
||||
@@ -57,6 +60,6 @@ const tool: McpTool = {
|
||||
updated_time: note.updated_time,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
+8
-5
@@ -1,6 +1,8 @@
|
||||
import SearchEngineUtils from '../../search/SearchEngineUtils';
|
||||
import { NoteEntity } from '../../database/types';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import SearchEngineUtils from '../../../search/SearchEngineUtils';
|
||||
import { NoteEntity } from '../../../database/types';
|
||||
import { _ } from '../../../../locale';
|
||||
import buildTool from '../utils/buildTool';
|
||||
import { ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
query?: string;
|
||||
@@ -12,8 +14,9 @@ const defaultLimit = 20;
|
||||
const maxLimit = 100;
|
||||
const snippetChars = 240;
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'search_notes',
|
||||
userDescription: (input: Input) => _('Searched for: %s', input.query || _('(empty)')),
|
||||
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.',
|
||||
'',
|
||||
@@ -72,7 +75,7 @@ const tool: McpTool = {
|
||||
|
||||
return { results, total: notes.length };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const makeSnippet = (body: string, keywords: string[]) => {
|
||||
const normalised = body.replace(/\s+/g, ' ').trim();
|
||||
+8
-5
@@ -1,6 +1,8 @@
|
||||
import Note from '../../../models/Note';
|
||||
import SearchService from '../../ai/SearchService';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import { _ } from '../../../../locale';
|
||||
import Note from '../../../../models/Note';
|
||||
import SearchService from '../../../ai/SearchService';
|
||||
import { ToolError } from '../types';
|
||||
import buildTool from '../utils/buildTool';
|
||||
|
||||
interface Input {
|
||||
query?: string;
|
||||
@@ -9,8 +11,9 @@ interface Input {
|
||||
relevance?: 'strict' | 'normal' | 'loose';
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'semantic_search_notes',
|
||||
userDescription: (input: Input) => _('Searched for: %s', input.query || _('(empty)')),
|
||||
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".',
|
||||
@@ -78,6 +81,6 @@ const tool: McpTool = {
|
||||
|
||||
return { results, total: results.length };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
+9
-6
@@ -1,7 +1,9 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Folder from '../../../models/Folder';
|
||||
import { NoteEntity } from '../../database/types';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
import Note from '../../../../models/Note';
|
||||
import Folder from '../../../../models/Folder';
|
||||
import { NoteEntity } from '../../../database/types';
|
||||
import { _ } from '../../../../locale';
|
||||
import buildTool from '../utils/buildTool';
|
||||
import { ToolError } from '../types';
|
||||
|
||||
interface ReplaceTextOp {
|
||||
find: string;
|
||||
@@ -19,8 +21,9 @@ interface Input {
|
||||
todo_completed?: boolean;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
const tool = buildTool({
|
||||
id: 'update_note',
|
||||
userDescription: () => _('Updated note'),
|
||||
description: [
|
||||
'Update an existing note. Only the fields you pass are changed; omitted fields keep their current value.',
|
||||
'',
|
||||
@@ -103,6 +106,6 @@ const tool: McpTool = {
|
||||
|
||||
return { id: saved.id, updated_time: saved.updated_time };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { JsonSchema } from '../types';
|
||||
|
||||
export interface EditorCommands {
|
||||
replaceSelection: (text: string, originalText: string)=> Promise<void>;
|
||||
updateNoteBody: (body: string, originalBody: string)=> Promise<void>;
|
||||
}
|
||||
|
||||
export interface NoteContext {
|
||||
title: string;
|
||||
noteId: string;
|
||||
folderId: string;
|
||||
body: string;
|
||||
selection: string | null;
|
||||
}
|
||||
|
||||
export interface EditorToolContext {
|
||||
note: NoteContext;
|
||||
commands: EditorCommands;
|
||||
}
|
||||
|
||||
export type ToolInput = Record<string, 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.
|
||||
export class ToolError extends Error {}
|
||||
|
||||
export type ToolOutput = string|Record<string, unknown>;
|
||||
|
||||
export interface ToolSpec {
|
||||
id: string;
|
||||
description: string;
|
||||
// Information provided by the model to the tool
|
||||
inputSchema: JsonSchema;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
selectedFolderId?: string;
|
||||
}
|
||||
|
||||
export type ToolDefinition<Output extends ToolOutput|unknown = unknown> = ToolSpec & {
|
||||
enabled?: boolean;
|
||||
handler: (input: ToolInput, context: ToolContext)=> Promise<Output>;
|
||||
// A human-readable description of an action completed by the tool
|
||||
userDescription: (input: ToolInput, output: Output)=> string;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { applyAnchorEdits } from './applyNoteEdits';
|
||||
|
||||
describe('applyNoteEdits', () => {
|
||||
it('appends with paragraph break (covered in detail below)', () => {
|
||||
const { newBody } = applyAnchorEdits('hello', [
|
||||
{ op: 'appendToNote', text: 'world' },
|
||||
], 0);
|
||||
expect(newBody).toBe('hello\n\nworld');
|
||||
});
|
||||
|
||||
it('inserts before/after anchor', () => {
|
||||
const before = applyAnchorEdits('one two three', [
|
||||
{ op: 'insertBefore', anchor: 'two', text: 'X ' },
|
||||
], 0);
|
||||
expect(before.newBody).toBe('one X two three');
|
||||
|
||||
const after = applyAnchorEdits('one two three', [
|
||||
{ op: 'insertAfter', anchor: 'two', text: ' X' },
|
||||
], 0);
|
||||
expect(after.newBody).toBe('one two X three');
|
||||
});
|
||||
|
||||
it('replaces range and reports missing anchor', () => {
|
||||
const replaced = applyAnchorEdits('alpha beta gamma', [
|
||||
{ op: 'replaceRange', anchor: 'beta', text: 'BETA' },
|
||||
], 0);
|
||||
expect(replaced.newBody).toBe('alpha BETA gamma');
|
||||
|
||||
const missing = applyAnchorEdits('alpha beta gamma', [
|
||||
{ op: 'replaceRange', anchor: 'delta', text: 'X' },
|
||||
], 0);
|
||||
expect(missing.newBody).toBe('alpha beta gamma');
|
||||
expect(missing.appliedEdits[0].status).toBe('anchor-not-found');
|
||||
});
|
||||
|
||||
it('picks anchor closest to cursor on duplicates', () => {
|
||||
// "foo" appears at index 0 and index 8. Cursor near second occurrence.
|
||||
const body = 'foo bar foo baz';
|
||||
const { newBody } = applyAnchorEdits(body, [
|
||||
{ op: 'insertAfter', anchor: 'foo', text: '!' },
|
||||
], 10);
|
||||
expect(newBody).toBe('foo bar foo! baz');
|
||||
});
|
||||
|
||||
it('appends with blank line for Markdown paragraph break', () => {
|
||||
// Empty body — no separator needed.
|
||||
expect(applyAnchorEdits('', [{ op: 'appendToNote', text: 'first' }], 0).newBody).toBe('first');
|
||||
// No trailing newline — needs full \n\n.
|
||||
expect(applyAnchorEdits('hello', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
|
||||
// One trailing newline — add one more to make a blank line.
|
||||
expect(applyAnchorEdits('hello\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
|
||||
// Already has blank line — no extra separator.
|
||||
expect(applyAnchorEdits('hello\n\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
|
||||
});
|
||||
|
||||
it('rejects invalid edits without mutating the body', () => {
|
||||
// Empty anchor, missing text, wrong shape, unknown op.
|
||||
const body = 'hello world';
|
||||
const cases = [
|
||||
{ op: 'insertBefore', anchor: '', text: 'X' },
|
||||
{ op: 'insertAfter', anchor: 'world' }, // missing text
|
||||
{ op: 'appendToNote' }, // missing text
|
||||
{ op: 'mysteryOp', text: 'X' },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = applyAnchorEdits(body, cases as any, 0);
|
||||
expect(result.newBody).toBe(body);
|
||||
expect(result.appliedEdits.every(e => e.status === 'invalid')).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces the inner content of a fenced block', () => {
|
||||
const body = 'Intro paragraph.\n\n```jsoncanvas\n{"nodes":[],"edges":[]}\n```\n\nOutro.';
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"nodes":[{"id":"a"}],"edges":[]}' },
|
||||
], 0);
|
||||
expect(appliedEdits[0].status).toBe('applied');
|
||||
expect(newBody).toContain('```jsoncanvas\n{"nodes":[{"id":"a"}],"edges":[]}\n```');
|
||||
// Surrounding prose is preserved.
|
||||
expect(newBody).toContain('Intro paragraph.');
|
||||
expect(newBody).toContain('Outro.');
|
||||
});
|
||||
|
||||
it('picks the fenced block closest to the cursor', () => {
|
||||
const first = '```jsoncanvas\n{"v":1}\n```';
|
||||
const second = '```jsoncanvas\n{"v":2}\n```';
|
||||
const body = `${first}\n\nSome prose between.\n\n${second}`;
|
||||
// Cursor near the second block — should edit that one, leaving the first untouched.
|
||||
const cursorNearSecond = body.indexOf(second) + 5;
|
||||
const { newBody } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":99}' },
|
||||
], cursorNearSecond);
|
||||
expect(newBody).toContain('{"v":1}'); // first untouched
|
||||
expect(newBody).toContain('{"v":99}'); // second replaced
|
||||
expect(newBody).not.toContain('{"v":2}');
|
||||
|
||||
// Cursor at 0 — first block wins (fallback behaviour preserved).
|
||||
const { newBody: firstWins } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":0}' },
|
||||
], 0);
|
||||
expect(firstWins).toContain('{"v":0}');
|
||||
expect(firstWins).toContain('{"v":2}'); // second untouched
|
||||
expect(firstWins).not.toContain('{"v":1}');
|
||||
});
|
||||
|
||||
it('reports anchor-not-found when fenced block missing', () => {
|
||||
const body = 'No whiteboard in this note.';
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{}' },
|
||||
], 0);
|
||||
expect(newBody).toBe(body);
|
||||
expect(appliedEdits[0].status).toBe('anchor-not-found');
|
||||
});
|
||||
|
||||
it('rejects replaceFencedBlock with unsupported tag', () => {
|
||||
// Plain code fences (```js, ```python, ...) are not structured-document
|
||||
// formats — the model should use anchor-based ops instead.
|
||||
const body = '```js\nconsole.log("x");\n```';
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceFencedBlock', tag: 'js', text: 'console.log("y");' },
|
||||
], 0);
|
||||
expect(newBody).toBe(body);
|
||||
expect(appliedEdits[0].status).toBe('invalid');
|
||||
});
|
||||
|
||||
it('refuses replaceRange anchor covering most of the body', () => {
|
||||
const body = 'short text';
|
||||
// Anchor is >50% of body — would be destructive; refuse.
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [
|
||||
{ op: 'replaceRange', anchor: 'short text', text: '' },
|
||||
], 0);
|
||||
expect(newBody).toBe(body);
|
||||
expect(appliedEdits[0].status).toBe('invalid');
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { EditOp } from './noteChat';
|
||||
import findFencedBlock from './utils/findFencedBlock';
|
||||
import type { EditOp } from '../buildEditorTools';
|
||||
import findFencedBlock from '../../utils/findFencedBlock';
|
||||
|
||||
export type ApplyEditStatus = 'applied' | 'anchor-not-found' | 'invalid';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ToolDefinition, ToolOutput } from '../types';
|
||||
|
||||
const buildTool = <OutputType extends ToolOutput> (spec: ToolDefinition<OutputType>) => {
|
||||
return spec as ToolDefinition<unknown>;
|
||||
};
|
||||
|
||||
export default buildTool;
|
||||
@@ -0,0 +1,17 @@
|
||||
import Setting from '../../../../models/Setting';
|
||||
import { ToolDefinition } from '../types';
|
||||
import { isEditorToolCall } from '../buildEditorTools';
|
||||
|
||||
export const toolSettingKey = (toolId: string) => (
|
||||
isEditorToolCall(toolId) ? 'ai.tool.edit_current.enabled' : `ai.tool.${toolId}.enabled`
|
||||
);
|
||||
|
||||
export const isToolEnabled = (tool: ToolDefinition) => !!Setting.value(toolSettingKey(tool.id));
|
||||
|
||||
export const toolSettingName = (toolId: string) => {
|
||||
const key = toolSettingKey(toolId);
|
||||
return Setting.keyExists(key) ? Setting.settingMetadata(key).label?.() : null;
|
||||
};
|
||||
|
||||
export const toolsSectionName = () => Setting.sectionNameToLabel('ai.tools');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ToolSpec } from './tools/types';
|
||||
|
||||
export enum ChatRole {
|
||||
System = 'system',
|
||||
User = 'user',
|
||||
@@ -44,13 +46,6 @@ export interface ResponseFormat {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
// Information provided by the model to the tool
|
||||
inputSchema: JsonSchema;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
temperature?: number;
|
||||
tools?: ToolSpec[];
|
||||
|
||||
@@ -8,16 +8,16 @@ 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',
|
||||
'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.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 = () => {
|
||||
@@ -45,17 +45,28 @@ describe('McpServer', () => {
|
||||
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);
|
||||
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',
|
||||
});
|
||||
const names = response.result.tools.map((t: { name: string }) => t.name);
|
||||
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']));
|
||||
expect(names).not.toContain('create_note');
|
||||
expect(names).not.toContain('update_note');
|
||||
// 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 () => {
|
||||
@@ -66,7 +77,7 @@ describe('McpServer', () => {
|
||||
});
|
||||
|
||||
test('returns isError when calling a disabled tool', async () => {
|
||||
Setting.setValue('mcp.tool.search_notes.enabled', false);
|
||||
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' } },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import ToolIndex from '../ai/tools/ToolIndex';
|
||||
import { JsonRpcRequest, JsonRpcResponse, JsonRpcErrorCodes, McpProtocolVersion, ToolCallResult } from './types';
|
||||
import { ToolError } from '../ai/tools/types';
|
||||
|
||||
const logger = Logger.create('McpServer');
|
||||
|
||||
@@ -75,7 +76,7 @@ export default class McpServer {
|
||||
|
||||
private handleToolsList() {
|
||||
return {
|
||||
tools: enabledTools().map(t => ({
|
||||
tools: new ToolIndex(null).getTools().map(t => ({
|
||||
name: t.id,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
@@ -88,15 +89,14 @@ export default class McpServer {
|
||||
if (!params || typeof params.name !== 'string') {
|
||||
throw new InvalidParamsError('Missing or invalid "name" parameter');
|
||||
}
|
||||
const tool = findTool(params.name);
|
||||
const index = new ToolIndex(null);
|
||||
const tool = index.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}'`);
|
||||
return toolErrorResult(index.describeToolNotFoundFailure(params.name));
|
||||
}
|
||||
const input = params.arguments ?? {};
|
||||
try {
|
||||
const payload = await tool.handler(input);
|
||||
const payload = await tool.handler(input, {});
|
||||
return {
|
||||
content: [{ type: 'text', text: serialisePayload(payload) }],
|
||||
};
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,8 +1,4 @@
|
||||
// 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>;
|
||||
import { ToolDefinition, ToolOutput } from '../ai/tools/types';
|
||||
|
||||
export interface ToolTextContent {
|
||||
type: 'text';
|
||||
@@ -21,22 +17,7 @@ export interface ToolCallResult {
|
||||
// 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 type McpTool<Output extends ToolOutput> = ToolDefinition<Output>;
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
|
||||
@@ -10,26 +10,24 @@ Joplin exposes a small, fixed set of tools. Each can be turned on or off individ
|
||||
|
||||
| Tool | What it does | Default |
|
||||
|---|---|---|
|
||||
| Search notes | Keyword search using Joplin's regular search syntax. | On |
|
||||
| Semantic search | Search by meaning, using the [local embeddings index](https://github.com/laurent22/joplin/blob/dev/readme/apps/ai_semantic_search.md). | On |
|
||||
| Read note | Return one note (title, markdown body, notebook, tags). | On |
|
||||
| List notebooks | List notebooks with their hierarchy. | On |
|
||||
| List tags | List tags. | On |
|
||||
| Search notes | Keyword search using Joplin's regular search syntax. | **Off** |
|
||||
| Semantic search | Search by meaning, using the [local embeddings index](https://github.com/laurent22/joplin/blob/dev/readme/apps/ai_semantic_search.md). | **Off** |
|
||||
| Read note | Return one note (title, markdown body, notebook, tags). | **Off** |
|
||||
| List notebooks | List notebooks with their hierarchy. | **Off** |
|
||||
| List tags | List tags. | **Off** |
|
||||
| Create note | Create a new note in a chosen notebook. | **Off** |
|
||||
| Update note | Change the title, body, notebook, or to-do state of an existing note. | **Off** |
|
||||
| Trash note | Move a note to the trash. | **Off** |
|
||||
| Edit tags on a note | Add or remove tags by title. | **Off** |
|
||||
| Create notebook | Create a new notebook, optionally inside an existing one. | **Off** |
|
||||
|
||||
The "write" tools default to off so you have to deliberately let an AI app modify your data.
|
||||
|
||||
## Turning it on
|
||||
|
||||
The MCP server runs on top of the [Web Clipper service](https://github.com/laurent22/joplin/blob/dev/readme/apps/clipper.md), so the Web Clipper must be running.
|
||||
|
||||
1. Open the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/apps/config_screen.md) and go to **Web Clipper**. Make sure the service is started, and note the port number and authorisation token.
|
||||
2. Go to the **MCP** section. Tick **Enable MCP server**.
|
||||
3. Decide which tools to allow. The read-only tools are pre-ticked; the write tools are not.
|
||||
2. Go to the **AI** section. Tick **Enable MCP server**.
|
||||
3. Open the **Tools** section. Decide which tools to allow.
|
||||
|
||||
Joplin's MCP server is HTTP-based and listens on the same port as the Web Clipper.
|
||||
|
||||
|
||||
@@ -37,18 +37,9 @@ Unknown methods return JSON-RPC `MethodNotFound`. Malformed `tools/call` params
|
||||
|
||||
## Tools
|
||||
|
||||
Tools live under `packages/lib/services/mcp/tools/`. Each module exports an `McpTool`:
|
||||
Tools live under `packages/lib/services/ai/tools/`. Each module exports a `ToolDefinition`.
|
||||
|
||||
```ts
|
||||
interface McpTool {
|
||||
id: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema;
|
||||
handler: (input) => Promise<unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
Handlers return their raw payload. The dispatcher JSON-serialises it into MCP text content. There is no need to wrap responses in `{ content, isError }` boilerplate.
|
||||
Tool handlers return their raw payload. The dispatcher JSON-serialises it into MCP text content. There is no need to wrap responses in `{ content, isError }` boilerplate.
|
||||
|
||||
| Tool | Purpose | Default |
|
||||
|---|---|---|
|
||||
@@ -82,18 +73,18 @@ The split lets us distinguish expected, LLM-recoverable failures from genuine bu
|
||||
|
||||
## 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.
|
||||
`registry.ts` holds the static list of all tools. `findTool(id)` returns a tool only if it exists *and* its `ai.tool.<id>.enabled` setting is true. `enabledTools()` filters the same way.
|
||||
|
||||
Adding a tool means: write the file in `tools/`, register it in `registry.ts`, add the `mcp.tool.<id>.enabled` setting to `builtInMetadata.ts`, and add it to the table above.
|
||||
Adding a tool means: write the file in `tools/`, register it in `registry.ts`, add the `ai.tool.<id>.enabled` setting to `builtInMetadata.ts`, and add it to the table above.
|
||||
|
||||
## Settings
|
||||
|
||||
All MCP settings live in the dedicated `mcp` section of Settings.
|
||||
All MCP settings live in the dedicated `ai.tools` section of Settings.
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `mcp.enabled` | false | Master toggle. Server returns 403 when off. |
|
||||
| `mcp.tool.<id>.enabled` | varies (see table above) | Per-tool toggle. Disabled tools are hidden from `tools/list`. |
|
||||
| `ai.tool.<id>.enabled` | varies (see table above) | Per-tool toggle. Disabled tools are hidden from `tools/list`. |
|
||||
|
||||
There is no scope/permission system on the auth token — for v1, the per-tool toggles are the granularity. Token scopes could be added later without breaking existing setups.
|
||||
|
||||
|
||||
Reference in new issue
Block a user