triliumnext-mcp 0.3.10-beta.2 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -9,6 +9,7 @@ import { handleCreateNoteRequest, handleUpdateNoteRequest, handleDeleteNoteReque
9
9
  import { handleSearchNotesRequest } from "./modules/searchHandler.js";
10
10
  import { handleResolveNoteRequest } from "./modules/resolveHandler.js";
11
11
  import { handleManageAttributes, handleReadAttributes } from "./modules/attributeHandler.js";
12
+ import { handleListAttributesRequest } from "./modules/attributeListHandler.js";
12
13
  const TRILIUM_API_URL = process.env.TRILIUM_API_URL;
13
14
  const TRILIUM_API_TOKEN = process.env.TRILIUM_API_TOKEN;
14
15
  const PERMISSIONS = process.env.PERMISSIONS || "READ;WRITE";
@@ -77,6 +78,8 @@ class TriliumServer {
77
78
  return await handleReadAttributes(request.params.arguments, this.axiosInstance, this);
78
79
  case "manage_attributes":
79
80
  return await handleManageAttributes(request.params.arguments, this.axiosInstance, this);
81
+ case "list_attributes":
82
+ return await handleListAttributesRequest(request.params.arguments, this.axiosInstance, this);
80
83
  default:
81
84
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
82
85
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Attribute List Handler Module
3
+ * Centralized request handling for attribute listing operations
4
+ */
5
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
6
+ import { handleListAttributes } from "./attributeListManager.js";
7
+ /**
8
+ * Handle list_attributes tool requests
9
+ */
10
+ export async function handleListAttributesRequest(args, axiosInstance, permissionChecker) {
11
+ if (!permissionChecker.hasPermission("READ")) {
12
+ throw new McpError(ErrorCode.InvalidRequest, "Permission denied: Not authorized to list attributes.");
13
+ }
14
+ try {
15
+ const listOperation = {
16
+ noteId: args.noteId,
17
+ hierarchyLevel: args.hierarchyLevel || 'immediate',
18
+ limit: args.limit || 50
19
+ };
20
+ const result = await handleListAttributes(listOperation, axiosInstance);
21
+ return {
22
+ content: [{
23
+ type: "text",
24
+ text: JSON.stringify(result, null, 2)
25
+ }]
26
+ };
27
+ }
28
+ catch (error) {
29
+ if (error instanceof McpError) {
30
+ throw error;
31
+ }
32
+ throw new McpError(ErrorCode.InvalidParams, error instanceof Error ? error.message : String(error));
33
+ }
34
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Attribute List Management Module
3
+ * Handles attribute listing operations using search_notes internally
4
+ */
5
+ import { handleSearchNotes } from './searchManager.js';
6
+ import { logVerboseInput, logVerboseOutput } from '../utils/verboseUtils.js';
7
+ /**
8
+ * Handle list attributes operation using search_notes internally
9
+ */
10
+ export async function handleListAttributes(args, axiosInstance) {
11
+ logVerboseInput('handleListAttributes', args);
12
+ // Build search criteria based on hierarchy navigation
13
+ const searchCriteria = buildHierarchySearchCriteria(args);
14
+ // Use search_notes internally to find notes
15
+ const searchOperation = {
16
+ searchCriteria: searchCriteria,
17
+ limit: args.limit || 100
18
+ };
19
+ const searchResults = await handleSearchNotes(searchOperation, axiosInstance);
20
+ // Extract and organize attributes from search results
21
+ const attributes = extractAttributesFromResults(searchResults.results, args);
22
+ // Generate summary
23
+ const summary = generateAttributeSummary(attributes, args);
24
+ const result = {
25
+ attributes: attributes,
26
+ summary: summary
27
+ };
28
+ logVerboseOutput('handleListAttributes', result);
29
+ return result;
30
+ }
31
+ /**
32
+ * Build search criteria for hierarchy navigation
33
+ */
34
+ function buildHierarchySearchCriteria(args) {
35
+ const criteria = [];
36
+ const hierarchyLevel = args.hierarchyLevel || 'immediate';
37
+ if (hierarchyLevel === 'immediate') {
38
+ // For immediate level, search for direct parents and children
39
+ criteria.push({
40
+ property: 'parents.noteId',
41
+ type: 'noteProperty',
42
+ op: '=',
43
+ value: args.noteId
44
+ });
45
+ criteria.push({
46
+ property: 'children.noteId',
47
+ type: 'noteProperty',
48
+ op: '=',
49
+ value: args.noteId,
50
+ logic: 'OR'
51
+ });
52
+ }
53
+ else {
54
+ // For all levels, search for ancestors and descendants
55
+ criteria.push({
56
+ property: 'ancestors.noteId',
57
+ type: 'noteProperty',
58
+ op: '=',
59
+ value: args.noteId
60
+ });
61
+ criteria.push({
62
+ property: 'descendants.noteId',
63
+ type: 'noteProperty',
64
+ op: '=',
65
+ value: args.noteId,
66
+ logic: 'OR'
67
+ });
68
+ }
69
+ // Exclude the anchor note itself
70
+ criteria.push({
71
+ property: 'noteId',
72
+ type: 'noteProperty',
73
+ op: '!=',
74
+ value: args.noteId,
75
+ logic: 'AND'
76
+ });
77
+ return criteria;
78
+ }
79
+ /**
80
+ * Extract and organize attributes from search results
81
+ */
82
+ function extractAttributesFromResults(searchResults, args) {
83
+ const attributes = [];
84
+ for (const note of searchResults) {
85
+ if (note.attributes && Array.isArray(note.attributes)) {
86
+ for (const attribute of note.attributes) {
87
+ const attributeInfo = {
88
+ noteId: note.noteId,
89
+ noteTitle: note.title,
90
+ noteType: note.type,
91
+ attributeId: attribute.attributeId,
92
+ attributeType: attribute.type,
93
+ attributeName: attribute.name,
94
+ attributeValue: attribute.value || '',
95
+ position: attribute.position || 10,
96
+ isInheritable: attribute.isInheritable || false,
97
+ hierarchyPath: buildHierarchyPath(note, args)
98
+ };
99
+ attributes.push(attributeInfo);
100
+ }
101
+ }
102
+ }
103
+ return attributes;
104
+ }
105
+ /**
106
+ * Build hierarchy path for attribute context
107
+ */
108
+ function buildHierarchyPath(note, args) {
109
+ const hierarchyLevel = args.hierarchyLevel || 'immediate';
110
+ const noteTitle = note.title || 'Unknown';
111
+ if (hierarchyLevel === 'immediate') {
112
+ return `Related to ${args.noteId}: ${noteTitle}`;
113
+ }
114
+ else {
115
+ return `Hierarchy-connected to ${args.noteId}: ${noteTitle}`;
116
+ }
117
+ }
118
+ /**
119
+ * Generate summary of attributes found
120
+ */
121
+ function generateAttributeSummary(attributes, args) {
122
+ const totalAttributes = attributes.length;
123
+ if (totalAttributes === 0) {
124
+ return `No attributes found`;
125
+ }
126
+ // Group by attribute type
127
+ const labelCount = attributes.filter(attr => attr.attributeType === 'label').length;
128
+ const relationCount = attributes.filter(attr => attr.attributeType === 'relation').length;
129
+ // Group by note
130
+ const uniqueNotes = new Set(attributes.map(attr => attr.noteId)).size;
131
+ let summary = `Found ${totalAttributes} attributes across ${uniqueNotes} notes`;
132
+ if (labelCount > 0 || relationCount > 0) {
133
+ summary += ` (${labelCount} labels, ${relationCount} relations)`;
134
+ }
135
+ // Add hierarchy context
136
+ const hierarchyLevel = args.hierarchyLevel || 'immediate';
137
+ if (hierarchyLevel === 'immediate') {
138
+ summary += ` in immediate hierarchy (parents and children)`;
139
+ }
140
+ else {
141
+ summary += ` in full hierarchy (ancestors and descendants)`;
142
+ }
143
+ // Add unique attribute names count
144
+ const uniqueAttributeNames = new Set(attributes.map(attr => attr.attributeName)).size;
145
+ summary += ` with ${uniqueAttributeNames} unique attribute types`;
146
+ return summary;
147
+ }
@@ -129,35 +129,52 @@ async function checkDuplicateTitleInDirectory(parentNoteId, title, axiosInstance
129
129
  * Handle create note operation
130
130
  */
131
131
  export async function handleCreateNote(args, axiosInstance) {
132
- const { parentNoteId, title, type, content: rawContent, mime, attributes, forceCreate = false } = args;
132
+ const { parentNoteId, title, type, content: rawContent, mime, attributes } = args;
133
133
  // Validate required parameters
134
134
  if (!parentNoteId || !title || !type) {
135
135
  throw new Error("parentNoteId, title, and type are required for create operation.");
136
136
  }
137
- // Check for duplicate title in the same directory (unless forceCreate is true)
138
- if (!forceCreate) {
139
- const duplicateCheck = await checkDuplicateTitleInDirectory(parentNoteId, title, axiosInstance);
140
- if (duplicateCheck.found) {
141
- return {
142
- message: `Found existing note with title "${title}" in this directory. Please choose how to proceed:`,
143
- duplicateFound: true,
144
- duplicateNoteId: duplicateCheck.duplicateNoteId,
145
- choices: {
146
- skip: "Skip creation - do nothing",
147
- createAnyway: "Create anyway - create duplicate note with same title (set forceCreate: true)",
148
- updateExisting: "Update existing - replace content of existing note with new content"
149
- },
150
- nextSteps: `Please specify your choice by calling create_note again with your preferred action. To update the existing note, use update_note with noteId: ${duplicateCheck.duplicateNoteId}`
151
- };
152
- }
137
+ // Check for duplicate title in the same directory
138
+ const duplicateCheck = await checkDuplicateTitleInDirectory(parentNoteId, title, axiosInstance);
139
+ if (duplicateCheck.found) {
140
+ return {
141
+ message: `Found existing note with title "${title}" in this directory. Please choose how to proceed:`,
142
+ duplicateFound: true,
143
+ duplicateNoteId: duplicateCheck.duplicateNoteId,
144
+ choices: {
145
+ skip: "Skip creation - do nothing",
146
+ updateExisting: "Update existing - replace content of existing note with new content"
147
+ },
148
+ nextSteps: `Please specify your choice by calling create_note again with a different title, or use update_note with noteId: ${duplicateCheck.duplicateNoteId}`
149
+ };
153
150
  }
154
151
  // Process content to ETAPI format
155
152
  // Content is optional - if not provided, default to empty string
156
153
  const content = rawContent || "";
157
154
  // Extract template relation for content validation
158
155
  const templateRelation = extractTemplateRelation(attributes);
156
+ // Clean template relation for consistent processing
157
+ const cleanedTemplateRelation = templateRelation ? templateRelation.trim() : '';
158
+ // Auto-correct note type for container templates
159
+ let correctedType = type;
160
+ if (templateRelation) {
161
+ // List of container templates that require 'book' type
162
+ const containerTemplates = [
163
+ 'board', '_template_board',
164
+ 'grid view', '_template_grid_view',
165
+ 'list view', '_template_list_view',
166
+ 'geo map', '_template_geo_map',
167
+ 'calendar', '_template_calendar'
168
+ ];
169
+ const isContainerTemplate = cleanedTemplateRelation &&
170
+ containerTemplates.includes(cleanedTemplateRelation.toLowerCase());
171
+ if (isContainerTemplate && type !== 'book') {
172
+ logVerbose("handleCreateNote", `Auto-correcting note type from '${type}' to 'book' for ${templateRelation} template`);
173
+ correctedType = 'book';
174
+ }
175
+ }
159
176
  // Validate content with template-aware rules
160
- const contentValidation = await validateContentForNoteType(content, type, undefined, templateRelation);
177
+ const contentValidation = await validateContentForNoteType(content, correctedType, undefined, templateRelation);
161
178
  if (!contentValidation.valid) {
162
179
  return {
163
180
  message: `CONTENT_VALIDATION_ERROR: ${contentValidation.error}`,
@@ -168,7 +185,7 @@ export async function handleCreateNote(args, axiosInstance) {
168
185
  // Use validated content (may have been auto-corrected)
169
186
  const validatedContent = contentValidation.content;
170
187
  // Process content to ETAPI format
171
- const processed = await processContentArray(validatedContent, type);
188
+ const processed = await processContentArray(validatedContent, correctedType);
172
189
  if (processed.error) {
173
190
  throw new Error(`Content processing error: ${processed.error}`);
174
191
  }
@@ -177,7 +194,7 @@ export async function handleCreateNote(args, axiosInstance) {
177
194
  const noteData = {
178
195
  parentNoteId,
179
196
  title,
180
- type,
197
+ type: correctedType,
181
198
  content: processedContent
182
199
  };
183
200
  // Add MIME type if specified
@@ -5,6 +5,55 @@
5
5
  import { buildSearchQuery } from "./searchQueryBuilder.js";
6
6
  import { trimNoteResults } from "../utils/noteFormatter.js";
7
7
  import { createSearchDebugInfo } from "../utils/verboseUtils.js";
8
+ /**
9
+ * Filters out parent notes from search results when performing hierarchy searches
10
+ * This prevents the parent folder itself from appearing in results when searching for its children/descendants
11
+ *
12
+ * @param searchResults - The raw search results from Trilium API
13
+ * @param searchArgs - The original search arguments used to detect hierarchy searches
14
+ * @returns Filtered search results with parent notes excluded
15
+ */
16
+ function filterParentNotes(searchResults, searchArgs) {
17
+ // If no search criteria, return results as-is (no filtering needed)
18
+ if (!searchArgs.searchCriteria || !Array.isArray(searchArgs.searchCriteria)) {
19
+ return searchResults;
20
+ }
21
+ // Check if any search criteria involves hierarchy navigation (parents, children, ancestors)
22
+ const hasHierarchySearch = searchArgs.searchCriteria.some(criteria => {
23
+ if (criteria.type !== 'noteProperty')
24
+ return false;
25
+ const property = criteria.property;
26
+ return property.startsWith('parents.') ||
27
+ property.startsWith('children.') ||
28
+ property.startsWith('ancestors.');
29
+ });
30
+ // If no hierarchy search, return results as-is
31
+ if (!hasHierarchySearch) {
32
+ return searchResults;
33
+ }
34
+ // Extract target note IDs from hierarchy search criteria
35
+ const targetNoteIds = new Set();
36
+ searchArgs.searchCriteria.forEach(criteria => {
37
+ if (criteria.type === 'noteProperty' && criteria.value) {
38
+ const property = criteria.property;
39
+ // Handle different hierarchy property patterns
40
+ if (property.endsWith('.noteId') && criteria.op === '=') {
41
+ targetNoteIds.add(criteria.value);
42
+ }
43
+ // For other hierarchy properties, we might need to resolve note IDs from titles
44
+ // This is a simplified approach - in practice, you'd need to resolve titles to IDs
45
+ }
46
+ });
47
+ // If no target note IDs found, return results as-is
48
+ if (targetNoteIds.size === 0) {
49
+ return searchResults;
50
+ }
51
+ // Filter out notes that match the target note IDs (these are the parents we want to exclude)
52
+ const filteredResults = searchResults.filter(note => {
53
+ return !targetNoteIds.has(note.noteId);
54
+ });
55
+ return filteredResults;
56
+ }
8
57
  /**
9
58
  * Handle search notes operation
10
59
  */
@@ -26,7 +75,10 @@ export async function handleSearchNotes(args, axiosInstance) {
26
75
  // Prepare verbose debug info if enabled
27
76
  const verboseInfo = createSearchDebugInfo(query, args);
28
77
  let searchResults = response.data.results || [];
29
- const trimmedResults = trimNoteResults(searchResults);
78
+ // Apply parent filtering for hierarchy searches to exclude parent notes from results
79
+ // This implements the documented behavior: "Parent note filtering automatically applied to avoid showing parent in children/descendant lists"
80
+ const filteredResults = filterParentNotes(searchResults, args);
81
+ const trimmedResults = trimNoteResults(filteredResults);
30
82
  return {
31
83
  results: trimmedResults,
32
84
  debugInfo: verboseInfo
@@ -9,7 +9,7 @@ export function createWriteTools() {
9
9
  return [
10
10
  {
11
11
  name: "create_note",
12
- description: "Create a new note in TriliumNext with duplicate title detection. When a note with the same title already exists in the same directory, you'll be presented with choices: skip creation, create anyway (with forceCreate: true), or update the existing note. ONLY use this tool when the user explicitly requests note creation (e.g., 'create a note', 'make a new note'). DO NOT use this tool proactively or when the user is only asking questions about their notes. TIP: For code notes, content is plain text (no HTML processing).",
12
+ description: "Create a new note in TriliumNext with duplicate title detection. When a note with the same title already exists in the same directory, you'll be presented with choices: skip creation or update the existing note. ONLY use this tool when the user explicitly requests note creation (e.g., 'create a note', 'make a new note'). DO NOT use this tool proactively or when the user is only asking questions about their notes. TIP: For code notes, content is plain text (no HTML processing).",
13
13
  inputSchema: {
14
14
  type: "object",
15
15
  properties: {
@@ -29,7 +29,7 @@ export function createWriteTools() {
29
29
  },
30
30
  content: {
31
31
  type: "string",
32
- description: "Content of the note (optional). 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):'); ⚠️ OMIT CONTENT for: 1) WEBVIEW notes (use #webViewSrc label instead), 2) Container templates (Board, Calendar, Grid View, List View, Table, Geo Map), 3) System notes: RENDER, SEARCH, RELATION_MAP, NOTE_MAP, BOOK - these must be EMPTY to work properly. When omitted, note will be created with empty content.\n\n📋 WORKFLOW FOR SPECIAL NOTE TYPES:\n• RENDER notes: Create empty → create child HTML code note → add ~renderNote relation to child\n• CALENDAR/BOARD/GRID/LIST/TABLE/GEO templates: Create empty with ~template relation → add child notes with proper labels\n• TEXT SNIPPET templates: Create with ~template relation + content\n• Most other types: Add content directly during creation"
32
+ description: "Content of the note (optional). 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):'); ⚠️ OMIT CONTENT for: 1) WEBVIEW notes (use #webViewSrc label instead), 2) Container templates (Board, Calendar, Grid View, List View, Table, Geo Map), 3) SYSTEM notes: RENDER, SEARCH, RELATION_MAP, NOTE_MAP, BOOK - these must be EMPTY to work properly. When omitted, note will be created with empty content.\n\n📋 WORKFLOW FOR SPECIAL NOTE TYPES:\n• RENDER notes: Create empty → create child HTML code note → add ~renderNote relation to child\n• CALENDAR/BOARD/GRID/LIST/TABLE/GEO templates: Create empty with ~template relation → add child notes with proper labels\n• TEXT SNIPPET templates: Create with ~template relation + content\n• Most other types: Add content directly during creation"
33
33
  },
34
34
  mime: {
35
35
  type: "string",
@@ -37,7 +37,7 @@ export function createWriteTools() {
37
37
  },
38
38
  attributes: {
39
39
  type: "array",
40
- description: "Optional attributes to create with the note (labels and relations). Enables one-step note creation with metadata. ⚠️ CRITICAL: Always add template relations during create_note when possible!\n\n📋 TEMPLATE RELATIONS (add during create_note):\n• ~template = 'Board' (kanban task boards)\n• ~template = 'Calendar' (calendar event displays)\n• ~template = 'Grid View' (grid layouts)\n• ~template = 'List View' (list layouts)\n• ~template = 'Table' (table structures)\n• ~template = 'Geo Map' (geographic maps)\n• ~template = 'Text Snippet' (reusable text)\n\n⚠️ SPECIAL CASES requiring separate steps:\n• ~renderNote = '<noteId>' (RENDER notes - requires existing child note ID)\n• Custom relations pointing to specific notes (use note IDs after creation)\n\n📋 LABELS & OTHER RELATIONS:\n• Labels: #tag format (e.g., #important, #project)\n• Relations: ~connection format (e.g., ~author, ~publisher)\n• Template relations use human-readable names (auto-translated to system IDs)\n\n⚠️ TEMPLATE RESTRICTIONS: Container templates MUST be empty notes - add content as child notes",
40
+ description: "Optional attributes to create with the note (labels and relations). Enables one-step note creation with metadata. ⚠️ CRITICAL: Always add template relations during create_note when possible!\n\n📋 TEMPLATE RELATIONS (add during create_note):\n• ~template = 'Board' (kanban task boards)\n• ~template = 'Calendar' (calendar event displays)\n• ~template = 'Grid View' (grid layouts)\n• ~template = 'List View' (list layouts)\n• ~template = 'Table' (table structures)\n• ~template = 'Geo Map' (geographic maps)\n• ~template = 'Text Snippet' (reusable text)\n\n⚠️ SPECIAL CASES requiring separate steps:\n• ~renderNote = '<noteId>' (RENDER notes - requires existing child note ID)\n• Custom relations pointing to specific notes (use note IDs after creation)\n\n📋 LABELS & OTHER RELATIONS:\n• Labels: #tag format (e.g., #important, #project)\n• Relations: ~connection format (e.g., ~author, ~publisher)\n• Template relations use human-readable names (auto-translated to system IDs)\n\n⚠️ TEMPLATE RESTRICTIONS: Container templates MUST be book note type with empty content - add content as child notes",
41
41
  items: {
42
42
  type: "object",
43
43
  properties: {
@@ -68,11 +68,6 @@ export function createWriteTools() {
68
68
  required: ["type", "name"]
69
69
  }
70
70
  },
71
- forceCreate: {
72
- type: "boolean",
73
- description: "Bypass duplicate title check and create note even if a note with the same title already exists in the same directory. Use this when you want to intentionally create duplicate notes.",
74
- default: false
75
- },
76
71
  },
77
72
  required: ["parentNoteId", "title", "type"],
78
73
  },
@@ -187,7 +182,7 @@ export function createReadTools() {
187
182
  return [
188
183
  {
189
184
  name: "get_note",
190
- description: "Get a note and its content by ID. Perfect for when someone wants to see what's in a note, extract specific information, or prepare for search and replace operations. Getting the full content lets you see the context and create better regex patterns for extraction or replacement.",
185
+ description: "Get a note and its content by ID. Perfect for when someone wants to see what's in a note, extract specific information, or prepare for search and replace operations. Getting the full content lets you see the context and extract information accurately. For extracting information from multiple notes: use search_notes to find relevant notes, then use get_note (with useRegex=false for LLM analysis, or useRegex=true for simple pattern matching).",
191
186
  inputSchema: {
192
187
  type: "object",
193
188
  properties: {
@@ -197,16 +192,16 @@ export function createReadTools() {
197
192
  },
198
193
  searchPattern: {
199
194
  type: "string",
200
- description: "Optional pattern to search for within the note. Use when you need to find specific text or extract information.",
195
+ description: "Optional pattern to search for within the note. ⚠️ IMPORTANT: When users request to extract specific types of information (URLs, emails, dates, code snippets, etc.), automatically generate appropriate regex patterns based on the extraction request. Use useRegex=true for pattern matching and searchFlags='gi' for comprehensive results.",
201
196
  },
202
197
  useRegex: {
203
198
  type: "boolean",
204
- description: "Whether to use regex patterns (default: true).",
205
- default: true
199
+ description: "Whether to use regex patterns for searchPattern. Set to true for simple pattern matching, or false to let LLM read and analyze full content for more accurate extraction (recommended for complex cases like URLs, emails with context).",
200
+ default: false
206
201
  },
207
202
  searchFlags: {
208
203
  type: "string",
209
- description: "Search options. Defaults to 'gi' (find all matches, case-insensitive).",
204
+ description: "Search options. Defaults to 'gi' (find all matches, case-insensitive). Use 'gi' for comprehensive extraction of all patterns.",
210
205
  default: "gi"
211
206
  },
212
207
  },
@@ -322,6 +317,33 @@ export function createReadAttributeTools() {
322
317
  },
323
318
  required: ["noteId"]
324
319
  }
320
+ },
321
+ {
322
+ name: "list_attributes",
323
+ description: "List attributes from note hierarchy using search_notes internally. Explore attributes across immediate hierarchy (parents and children) or full hierarchy (ancestors and descendants). Returns comprehensive attribute information including note context, attribute details, and hierarchy relationships. Perfect for understanding template usage, label patterns, and relation networks across your note structure.",
324
+ inputSchema: {
325
+ type: "object",
326
+ properties: {
327
+ noteId: {
328
+ type: "string",
329
+ description: "Anchor note ID for hierarchy navigation"
330
+ },
331
+ hierarchyLevel: {
332
+ type: "string",
333
+ enum: ["immediate", "all"],
334
+ description: "Hierarchy navigation depth: 'immediate' (direct parents and children only) or 'all' (include ancestors and descendants)",
335
+ default: "immediate"
336
+ },
337
+ limit: {
338
+ type: "number",
339
+ description: "Maximum number of results to return",
340
+ default: 50,
341
+ minimum: 1,
342
+ maximum: 200
343
+ }
344
+ },
345
+ required: ["noteId"]
346
+ }
325
347
  }
326
348
  ];
327
349
  }
@@ -206,8 +206,16 @@ export async function validateContentForNoteType(content, noteType, currentConte
206
206
  const textContent = content.trim();
207
207
  // Get template-aware content rules
208
208
  const rules = getTemplateContentRules(noteType, templateRelation);
209
- // Check if content is allowed at all
210
- if (!rules.allowContent) {
209
+ // Special handling for container templates: empty content is always valid
210
+ if (!textContent && rules.enforceEmpty) {
211
+ return {
212
+ valid: true,
213
+ content: "",
214
+ corrected: false
215
+ };
216
+ }
217
+ // Check if content is allowed at all (only for non-empty content)
218
+ if (!rules.allowContent && textContent) {
211
219
  return {
212
220
  valid: false,
213
221
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triliumnext-mcp",
3
- "version": "0.3.10-beta.2",
3
+ "version": "0.3.11",
4
4
  "description": "A model context protocol server for TriliumNext Notes",
5
5
  "type": "module",
6
6
  "bin": {