update_note
Modify the content of a specific note by providing the note ID and new text. Ensures accurate updates within the TriliumNext Notes system.
Instructions
Update the content of an existing note
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| content | Yes | New content for the note | |
| noteId | Yes | ID of the note to update |
Implementation Reference
- MCP tool definition including name, description, and inputSchema for the update_note tool.{ name: "update_note", description: "Update note with support for title-only updates, content overwrite, content append, or file replacement. ⚠️ REQUIRED: ALWAYS call get_note first to obtain current hash. ⚠️ SIMPLER RULES: Note type and MIME type are IMMUTABLE - cannot be changed after creation. MODE SELECTION: Use 'append' when user wants to add/insert content (e.g., 'append to note', 'add to the end', 'insert content', 'add more content', 'continue writing', 'add to bottom'). Use 'overwrite' when replacing entire content (e.g., 'replace content', 'overwrite note', 'update the whole note', 'completely replace'). TITLE-ONLY: Efficient title changes without content modification. FILE UPDATES: Replace file content only with SAME file type (image→image, file→file). To change file types, create a new note instead. PREVENTS: Type mismatches, file type conflicts, and overwriting changes made by other users. ONLY use when user explicitly requests note update. WORKFLOW: get_note → review content → update_note with returned hash", inputSchema: { type: "object", properties: { noteId: { type: "string", description: "ID of the note to update" }, title: { type: "string", description: "New title for the note. If provided alone (without content), performs efficient title-only update without affecting note content or blobId." }, content: { type: "string", description: "Content of the note. Content requirements by note type: TEXT notes require HTML content (plain text auto-wrapped in <p> tags, e.g., '<p>Hello world</p>', '<strong>bold</strong>'); CODE/MERMAID notes require plain text ONLY (HTML tags rejected, e.g., 'def fibonacci(n):'); ⚠️ SYSTEM NOTES MUST REMAIN EMPTY: RENDER (HTML handled by note type), SEARCH (queries in search properties), RELATION_MAP (visual maps), NOTE_MAP (visual hierarchies), BOOK (container notes), WEBVIEW (use #webViewSrc label); IMPORTANT: When updating notes with template relations (Board, Calendar, Grid View, List View, Table, Geo Map), the note must remain EMPTY - these templates provide specialized layouts and content should be added as child notes instead." }, fileUri: { type: "string", description: "File data source for file/image note updates. Replaces the existing file content with new file data. ⚠️ FILE TYPE MUST MATCH: The new file must have the same type as the current note (image files for image notes, other files for file notes). Supports: 1) Local file path: '/path/to/new_document.pdf', 2) Base64 data URI: 'data:application/pdf;base64,JVBERi0xLjcK...', 3) Raw base64 string. To change file types, create a new note instead." }, expectedHash: { type: "string", description: "⚠️ REQUIRED: Blob ID (content hash) from get_note response. This is Trilium's built-in content identifier that ensures data integrity by verifying the note hasn't been modified since you retrieved it. If you see an error about missing blobId, you MUST call get_note first to get the current blobId." }, revision: { type: "boolean", description: "Whether to create a revision before updating (default: true for safety, title-only updates skip revision for efficiency)", default: true }, mode: { type: "string", enum: ["overwrite", "append"], description: "Content update mode. REQUIRED when updating content for text/code notes, optional for file-only updates. CRITICAL: Choose based on user intent: 'append' = add/insert content while preserving existing content (use for 'add to', 'append', 'insert', 'add more', 'continue writing'); 'overwrite' = completely replace all existing content (use for 'replace', 'overwrite', 'update all', 'completely replace'). Default behavior is not available - you MUST explicitly choose when updating content." } }, required: ["noteId", "expectedHash"] } },
- src/index.ts:96-97 (registration)Registers the update_note tool in the MCP server's request handler switch statement, delegating to handleUpdateNoteRequest.case "update_note": return await handleUpdateNoteRequest(request.params.arguments, this.axiosInstance, this);
- src/modules/noteHandler.ts:95-219 (handler)Request handler for update_note: validates permissions, required params, fetches current note for immutability checks, constructs NoteOperation, and delegates to core handleUpdateNote.export async function handleUpdateNoteRequest( args: any, axiosInstance: any, permissionChecker: PermissionChecker ): Promise<{ content: Array<{ type: string; text: string }> }> { if (!permissionChecker.hasPermission("WRITE")) { throw new McpError(ErrorCode.InvalidRequest, "Permission denied: Not authorized to update notes."); } // Validate that expectedHash is provided (required for data integrity) if (!args.expectedHash) { throw new McpError( ErrorCode.InvalidParams, "Missing required parameter 'expectedHash'. You must call get_note first to retrieve the current blobId (content hash) before updating. This ensures data integrity by preventing overwriting changes made by other users." ); } // Validate that either title, content, or fileUri is provided if (!args.title && !args.content && !args.fileUri) { throw new McpError( ErrorCode.InvalidParams, "Either 'title', 'content', or 'fileUri' (or any combination) must be provided for update operation." ); } // Get current note for type validation let currentNote = null; try { const currentNoteResponse = await axiosInstance.get(`/notes/${args.noteId}`); currentNote = currentNoteResponse.data; } catch (error) { throw new McpError( ErrorCode.InvalidParams, `Failed to retrieve current note for validation: ${error instanceof Error ? error.message : 'Unknown error'}` ); } // SIMPLER RULE 1: Note type is immutable - cannot be changed after creation if (args.type && args.type !== currentNote.type) { throw new McpError( ErrorCode.InvalidParams, `Note type cannot be changed after creation. Current type: '${currentNote.type}', requested type: '${args.type}'. Create a new note instead of changing the type.` ); } // SIMPLER RULE 2: MIME type is immutable - cannot be changed after creation if (args.mime && args.mime !== currentNote.mime) { throw new McpError( ErrorCode.InvalidParams, `MIME type cannot be changed after creation. Current MIME type: '${currentNote.mime}', requested MIME type: '${args.mime}'. Create a new note instead of changing the MIME type.` ); } // SIMPLER RULE 3: File type validation - file must match note type if (args.fileUri) { // Only file/image notes can have fileUri if (!['file', 'image'].includes(currentNote.type)) { throw new McpError( ErrorCode.InvalidParams, `Parameter 'fileUri' can only be used with file or image notes. Current note type: '${currentNote.type}'.` ); } // Validate that the new file matches the existing note type const { parseFileDataSource, detectNoteTypeFromMime } = await import('../utils/fileUtils.js'); try { const fileData = parseFileDataSource(args.fileUri); const detectedType = detectNoteTypeFromMime(fileData.mimeType); if (!detectedType || detectedType !== currentNote.type) { throw new McpError( ErrorCode.InvalidParams, `File type mismatch: Cannot replace ${currentNote.type} note content with ${detectedType || 'unsupported'} file. The file type must match the note type. Create a new note for different file types.` ); } } catch (parseError) { if (parseError instanceof McpError) { throw parseError; } throw new McpError( ErrorCode.InvalidParams, `Failed to parse or validate file: ${parseError instanceof Error ? parseError.message : 'Unknown error'}` ); } } // SIMPLER RULE 4: Content updates must use current note type (no type parameter needed) if (args.content) { // Use current note type for content validation - args.type is not needed if (!currentNote.type) { throw new McpError( ErrorCode.InvalidParams, "Cannot determine current note type for content validation." ); } } try { const noteOperation: NoteOperation = { noteId: args.noteId, title: args.title, type: currentNote.type, // Always use current note type (immutable) content: args.content, mime: currentNote.mime, // Always use current MIME type (immutable) fileUri: args.fileUri, revision: args.revision !== false, // Default to true (safe behavior) expectedHash: args.expectedHash, mode: args.mode }; const result = await handleUpdateNote(noteOperation, axiosInstance); return { content: [{ type: "text", text: result.message }] }; } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError(ErrorCode.InvalidParams, error instanceof Error ? error.message : String(error)); } }
- src/modules/noteManager.ts:425-684 (handler)Core handler implementing the update logic: supports title-only, content overwrite/append, file replacement with hash validation, revisions, type immutability, and content processing.export async function handleUpdateNote( args: NoteOperation, axiosInstance: any ): Promise<NoteUpdateResponse> { const { noteId, title, type, content: rawContent, mime, fileUri, revision = true, expectedHash, mode } = args; if (!noteId || !expectedHash) { throw new Error("noteId and expectedHash are required for update operation."); } // Mode is required only for content updates (non-file notes) if (type !== 'file' && !mode) { throw new Error("mode is required for update operation. Please specify either 'overwrite' or 'append'."); } // Handle file content updates (both 'file' and 'image' types) if (type === 'file' || type === 'image') { // Import FileManager only when needed const { FileManager } = await import('./fileManager.js'); const { parseFileDataSource } = await import('../utils/fileUtils.js'); // If fileUri is provided, update file content if (fileUri) { // Use FileManager to handle the file upload (supports file paths, base64, data URIs) const fileManager = new FileManager(axiosInstance); try { // First update metadata if title is provided (type and mime are not changeable) if (title) { const patchData: any = {}; if (title) patchData.title = title; await axiosInstance.patch(`/notes/${noteId}`, patchData, { headers: { "Content-Type": "application/json" } }); } // Then upload new file content using fileUri const fileData = parseFileDataSource(fileUri); await fileManager.uploadFileContentFromData(noteId, fileData, mime || fileData.mimeType); return { noteId, message: `File note updated: ${noteId} (${title || 'Title unchanged'})`, revisionCreated: false }; } catch (error) { throw new Error(`File update failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } else { // File metadata-only update (title only, since type and mime are not changeable) if (title) { const patchData: any = {}; if (title) patchData.title = title; try { await axiosInstance.patch(`/notes/${noteId}`, patchData, { headers: { "Content-Type": "application/json" } }); return { noteId, message: `File note metadata updated: ${noteId} (title updated to "${title}")`, revisionCreated: false }; } catch (error) { throw new Error(`File metadata update failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } else { throw new Error("No changes specified for file note update. Provide title or fileUri."); } } } // Check if this is a metadata-only update (title only, since type and mime are not changeable) const isMetadataOnlyUpdate = title && !rawContent && !fileUri; // Check if this is a multi-parameter update (title + content) const isMultiParamUpdate = title && (rawContent || fileUri); // For content updates (with or without title), validate required fields if ((rawContent || fileUri) && !type) { throw new Error("type is required when updating content."); } let revisionCreated = false; // Step 1: Get current note state for validation try { const currentNote = await axiosInstance.get(`/notes/${noteId}`); const currentContent = await axiosInstance.get(`/notes/${noteId}/content`, { responseType: 'text' }); // Step 2: Hash validation if provided if (expectedHash) { const currentBlobId = currentNote.data.blobId; if (currentBlobId !== expectedHash) { return { noteId, message: `CONFLICT: Note has been modified by another user. ` + `Current blobId: ${currentBlobId}, expected: ${expectedHash}. ` + `Please get the latest note content and retry.`, revisionCreated: false, conflict: true }; } } // Handle metadata-only update (efficient PATCH operation) if (isMetadataOnlyUpdate) { // For metadata-only updates, skip revision creation for efficiency const patchData: any = { title }; logVerboseApi("PATCH", `/notes/${noteId}`, patchData); const response = await axiosInstance.patch(`/notes/${noteId}`, patchData, { headers: { "Content-Type": "application/json" } }); if (response.status !== 200) { throw new Error(`Unexpected response status: ${response.status}`); } return { noteId, message: `Note ${noteId} title updated successfully to "${title}"`, revisionCreated: false, conflict: false }; } // Handle content updates (with optional title change) // Step 3: Get existing template relations for content validation let existingTemplateRelation: string | undefined; try { // Check if the note has existing template relations const existingAttributes = currentNote.data.attributes || []; existingTemplateRelation = existingAttributes.find( (attr: any) => attr.type === 'relation' && attr.name === 'template' )?.value; } catch (error) { // If we can't read existing attributes, proceed without template validation logVerbose("handleUpdateNote", "Could not read existing attributes for template validation", error); } // Step 4: Content type validation with template awareness (always enabled) let finalContent = rawContent; const validationResult = await validateContentForNoteType( rawContent as string, type as NoteType, currentContent.data, existingTemplateRelation ); if (!validationResult.valid) { return { noteId, message: `CONTENT_VALIDATION_ERROR: ${validationResult.error}`, revisionCreated: false, conflict: false }; } // Use validated/corrected content finalContent = validationResult.content; // Step 5: Create revision if requested if (revision) { try { await axiosInstance.post(`/notes/${noteId}/revision`); revisionCreated = true; } catch (error) { console.error(`Warning: Failed to create revision for note ${noteId}:`, error); // Continue with update even if revision creation fails } } // Step 6: Process and update content based on mode // Content is optional - if not provided, default to empty string finalContent = finalContent || ""; let processedContent: string; if (mode === 'append') { // For append mode, get current content and append new content const newProcessed = await processContentArray(finalContent, currentNote.data.type); if (newProcessed.error) { throw new Error(`New content processing error: ${newProcessed.error}`); } // Append new content to existing content (currentContent.data is already processed) processedContent = currentContent.data + newProcessed.content; } else if (mode === 'overwrite') { // For overwrite mode, replace entire content const processed = await processContentArray(finalContent, currentNote.data.type); if (processed.error) { throw new Error(`Content processing error: ${processed.error}`); } processedContent = processed.content; } else { throw new Error(`Invalid mode: ${mode}. Mode must be either 'overwrite' or 'append'.`); } const contentResponse = await axiosInstance.put(`/notes/${noteId}/content`, processedContent, { headers: { "Content-Type": "text/plain" } }); if (contentResponse.status !== 204) { throw new Error(`Unexpected response status: ${contentResponse.status}`); } // Step 7: Update title if provided (multi-parameter update) if (isMultiParamUpdate && title) { const patchData: any = { title }; logVerboseApi("PATCH", `/notes/${noteId}`, patchData); const titleResponse = await axiosInstance.patch(`/notes/${noteId}`, patchData, { headers: { "Content-Type": "application/json" } }); if (titleResponse.status !== 200) { throw new Error(`Unexpected response status for title update: ${titleResponse.status}`); } } const revisionMsg = revisionCreated ? " (revision created)" : " (no revision)"; const correctionMsg = (finalContent !== rawContent) ? " (content auto-corrected)" : ""; const modeMsg = mode === 'append' ? " (content appended)" : " (content overwritten)"; const titleMsg = (isMultiParamUpdate && title) ? ` (title updated to "${title}")` : ""; return { noteId, message: `Note ${noteId} updated successfully${revisionMsg}${correctionMsg}${modeMsg}${titleMsg}`, revisionCreated, conflict: false }; } catch (error) { if ((error as any).response?.status === 404) { throw new Error(`Note ${noteId} not found`); } throw error; } }
- src/utils/validationUtils.ts:47-67 (schema)Zod schema for validating update_note input parameters with refinements for required fields.export const updateNoteSchema = z.object({ noteId: z.string().min(1, 'Note ID cannot be empty'), title: z.string().min(1, 'Title cannot be empty').optional(), type: z.enum(['text', 'code', 'render', 'search', 'relationMap', 'book', 'noteMap', 'mermaid', 'webView']).optional(), content: z.string().optional(), mime: z.string().optional(), revision: z.boolean().optional(), expectedHash: z.string().min(1, 'Expected hash cannot be empty') }).refine( (data) => data.title || data.content, { message: "Either 'title' or 'content' (or both) must be provided for update operation", path: ['title', 'content'] } ).refine( (data) => !data.content || data.type, { message: "Parameter 'type' is required when updating content", path: ['type'] } );