roam-research-mcp 0.32.4 → 0.36.0

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/README.md CHANGED
@@ -121,7 +121,7 @@ The server provides powerful tools for interacting with Roam Research:
121
121
  10. `roam_search_by_text`: Search for blocks containing specific text.
122
122
  11. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.
123
123
  12. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates.
124
- 13. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
124
+ 13. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag.
125
125
  14. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.)
126
126
  15. `roam_recall`: Retrieve all stored memories.
127
127
  16. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools.
@@ -129,7 +129,7 @@ The server provides powerful tools for interacting with Roam Research:
129
129
  18. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.)
130
130
 
131
131
  **Deprecated Tools**:
132
- The following tools have been deprecated as of `v.0.30.0` in favor of the more powerful and flexible `roam_process_batch_actions`:
132
+ The following tools have been deprecated as of `v1.36.0` in favor of the more powerful and flexible `roam_process_batch_actions`:
133
133
 
134
134
  - `roam_create_block`: Use `roam_process_batch_actions` with the `create-block` action.
135
135
  - `roam_update_block`: Use `roam_process_batch_actions` with the `update-block` action.
@@ -176,6 +176,20 @@ Please note that while the `roam_process_batch_actions` tool can set block headi
176
176
 
177
177
  ---
178
178
 
179
+ ## Propose Improvements
180
+
181
+ ### Pagination for Search Tools
182
+
183
+ The `roam_search_for_tag` and `roam_search_by_text` tools now support `limit` and `offset` parameters, enabling basic pagination. To achieve full, robust pagination (e.g., retrieving "page 2" of results), the client consuming these tools would need to:
184
+
185
+ 1. Make an initial call with `limit` and `offset=0` to get the first set of results and the `total_count`.
186
+ 2. Calculate the total number of pages based on `total_count` and the desired `limit`.
187
+ 3. Make subsequent calls, incrementing the `offset` by `limit` for each "page" of results.
188
+
189
+ Example: To get the second page of 10 results, the call would be `roam_search_by_text(text: "your query", limit: 10, offset: 10)`.
190
+
191
+ ---
192
+
179
193
  ## Example Prompts
180
194
 
181
195
  Here are some examples of how to creatively use the Roam tool in an LLM to interact with your Roam graph, particularly leveraging `roam_process_batch_actions` for complex operations.
@@ -351,3 +365,9 @@ This will:
351
365
  ## License
352
366
 
353
367
  MIT License
368
+
369
+ ---
370
+
371
+ ## About the Author
372
+
373
+ This project is maintained by [Ian Shen](https://github.com/2b3pro).
@@ -15,6 +15,7 @@
15
15
  - {{[[TODO]]}} todo text
16
16
  - {{[[DONE]]}} todo text
17
17
  - LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$`
18
+ - Bullet points use dashes not asterisks.
18
19
 
19
20
  ## Roam-specific Markdown:
20
21
 
@@ -112,12 +113,70 @@ The provided markdown structure represents a Roam Research Kanban board. It star
112
113
  This markdown structure allows embedding custom HTML or other content using Hiccup syntax. The `:hiccup` keyword is followed by a Clojure-like vector defining the HTML elements and their attributes in one block. This provides a powerful way to inject dynamic or custom components into your Roam graph. Example: `:hiccup [:iframe {:width "600" :height "400" :src "https://www.example.com"}]`
113
114
 
114
115
  ## Specific notes and preferences concerning my Roam Research graph
115
- ### What To Tag
116
+ ### When creating new pages
116
117
 
117
- NONE
118
+ - After creating a new page, ensure you add a new block on the daily page that references it if one doesn't already exist: `Created page: [[…]]`
118
119
 
119
- ### Don't Include…
120
+ ### What To Tag
120
121
 
121
- NONE
122
+ - Always think in terms of Future Me: How will I find this gem of information in this block in the future? What about this core idea might I be looking for? Aim for high relevance and helpfulness.
123
+ - My notes in Roam are interconnected via wrapped terms/expressions [[…]] and hashtags (#…). The general salience guidelines are as follows:
124
+
125
+ - What greater domain would this core idea be best categorization?
126
+ - If block is parent of children blocks, can a word/phrase in the parent be wrapped in brackets.
127
+ - If not, could a hashtag be appended to the block? (This is like a "please see more"-on-this-subject categorization).
128
+ - Wrap any proper names—no titles—(Dr. [[Werner Erhard]]), organizations, abbreviations e.g. `[NASA]([[National Aeronautics and Space Administration (NASA)]])`
129
+ - Any synonyms or multi-worded phrases of already established categories/pages, e.g. `[frameworks for making quality decisions]([[decision-making frameworks]])`
130
+
131
+ #### Advanced tagging methodology combining [[zettelkasten]] principles with serendipity engineering for maximum intellectual discovery potential. #[[knowledge management]] #[[tagging methodology]]
132
+
133
+ - # Core Philosophy
134
+ - Tag for **intellectual collision** and **future conversation**, not just categorization. Every tag should maximize the potential for unexpected discoveries and cross-domain insights.
135
+ - Traditional academic categorization creates silos; serendipity-engineered tagging creates **conceptual magnets** that attract surprising connections.
136
+ - ## Zettelkasten-Informed Principles
137
+ - **Atomic Thought Units**: Each tagged concept should function as a standalone intellectual object capable of connecting to ANY other domain
138
+ - **Conversation-Driven Linking**: Tag by the intellectual dialogues and debates a concept could inform, not just its subject matter
139
+ - **Temporal Intellectual Journey**: Tag for different phases of understanding - beginner discoveries, expert refinements, teaching moments
140
+ - **Intellectual Genealogy**: Create concept lineages that trace how ideas evolve and connect across domains
141
+ - ## Serendipity Engineering Techniques
142
+ - **Conceptual Collision Matrix**: Force unlikely intellectual meetings by tagging across disparate domains
143
+ - Example: #[[parenting techniques]] + [[cognitive engineering]] = developing children's meta-cognitive awareness
144
+ - Example: #[[cooking methods]] + [[cultural rotation]] = how different cultures balance flavors → balancing competing AI instructions
145
+ - **Anti-Obvious Tagging**: Deliberately tag against natural categorization to maximize surprise
146
+ - Tag by **structural similarities** rather than surface content: #[[has feedback loops]], #[[requires calibration]], #[[exhibits emergence]]
147
+ - Connect concepts through **metaphorical bridges**: meditation techniques + bias detection (both about noticing the unnoticed)
148
+ - **Question Cascade Strategy**: Tag by what questions a concept could answer across unexpected domains
149
+ - Instead of "this is about X," ask "what problems could this solve that I haven't thought of yet?"
150
+ - Examples: #[[how do experts prevent tunnel vision?]], #[[what creates cognitive flexibility?]], #[[how do systems self-correct?]]
151
+ - **Future Collision Potential**: Tag with temporal discovery triggers
152
+ - [[will be relevant in 5 years]], #[[connects to unborn projects]], #[[solves problems I don't know I have yet]]
153
+ - ## Practical Implementation
154
+ - **The Serendipity Test**: Before tagging, ask "Could this concept surprise me by connecting to something completely unrelated?"
155
+ - **Cross-Domain Bridge Tags**: Use structural rather than content-based categorization
156
+ - [[flow dynamics]] - connects fluid mechanics, music, conversation, AI prompt sequences
157
+ - [[calibration processes]] - bridges instrument tuning, relationships, AI parameters, personal habits
158
+ - [[perspective switching]] - connects photography, negotiation, cultural analysis, prompt engineering
159
+ - **Problem-Solution Pairing**: Tag by the problems solved rather than methods used
160
+ - [[breaking cognitive constraints]], #[[expanding solution spaces]], #[[preventing expert blindness]]
161
+ - **Intellectual State Triggers**: Tag for when you'd actually search based on mental/emotional context
162
+ - [[feeling stuck in patterns]], #[[needing fresh perspective]], #[[frustrated with conventional approaches]]
163
+ - ## Tag Maintenance Strategy
164
+ - **Regular Collision Audits**: Periodically review tags to identify missed connection opportunities
165
+ - **Surprise Discovery Log**: Track when unexpected tag connections lead to insights, then engineer more of those patterns
166
+ - **Question Evolution**: Update question-based tags as your intellectual interests and problems evolve
167
+ - **Cross-Reference Integration**: Ensure new tagging approach complements existing page reference `[[…]]` and hashtag `#[[…]]` conventions
168
+
169
+ ### How to Tag (nuances)
170
+
171
+ - Consider that when linking a parent block, all children blocks will also accompany future search results. Linking a childblock will only link the child block, not siblings.
172
+ - The convention for tags is lower case unless proper nouns. When tagging might affect the capitalization within a sentence, use aliases. e.g. `[Cognitive biases]([[cognitive biases]]) of the person are listed…"
173
+ - Can longer phrases and expressions be aliased to existing notes? `How one can leverage [[cognitive dissonance]] to [hypnotically influence others using language]([[hypnotic language techniques]])`
174
+ - Reformat quotes in this format structure: `<quote> —[[<Quoted Person>]] #quote <hashtags>` with 2-3 additional relevant hashtag links
175
+ - Anything that needs follow-up, further research, preface with `{{[[TODO]]}}`
176
+ - Scheduled review: Tag future dates in ordinal format so that it can come up for review when relevant, e.g. `[[For review]]: [[August 12th, 2026]]` Optionally, prepend with label ("Deadline", "For review", "Approved", "Pending", "Deferred", "Postponed until"). Place on parent block if relevant (if children blocks are related).
177
+
178
+ ### Constraints
179
+
180
+ - Don't overtag.
122
181
 
123
182
  ⭐️📋 END (Cheat Sheet LOADED) < < < 📋⭐️
@@ -262,13 +262,14 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
262
262
  const actions = [];
263
263
  // Helper function to recursively create actions
264
264
  function createBlockActions(blocks, parentUid, order) {
265
- for (const block of blocks) {
265
+ for (let i = 0; i < blocks.length; i++) {
266
+ const block = blocks[i];
266
267
  // Create the current block
267
268
  const action = {
268
269
  action: 'create-block',
269
270
  location: {
270
271
  'parent-uid': parentUid,
271
- order
272
+ order: typeof order === 'number' ? order + i : i
272
273
  },
273
274
  block: {
274
275
  uid: block.uid,
@@ -8,42 +8,102 @@ export class TagSearchHandler extends BaseSearchHandler {
8
8
  this.params = params;
9
9
  }
10
10
  async execute() {
11
- const { primary_tag, page_title_uid, near_tag, exclude_tag } = this.params;
11
+ const { primary_tag, page_title_uid, near_tag, exclude_tag, case_sensitive = false, limit = -1, offset = 0 } = this.params;
12
+ let nearTagUid;
13
+ if (near_tag) {
14
+ nearTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, near_tag);
15
+ if (!nearTagUid) {
16
+ return {
17
+ success: false,
18
+ matches: [],
19
+ message: `Near tag "${near_tag}" not found.`,
20
+ total_count: 0
21
+ };
22
+ }
23
+ }
24
+ let excludeTagUid;
25
+ if (exclude_tag) {
26
+ excludeTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, exclude_tag);
27
+ if (!excludeTagUid) {
28
+ return {
29
+ success: false,
30
+ matches: [],
31
+ message: `Exclude tag "${exclude_tag}" not found.`,
32
+ total_count: 0
33
+ };
34
+ }
35
+ }
12
36
  // Get target page UID if provided for scoped search
13
37
  let targetPageUid;
14
38
  if (page_title_uid) {
15
39
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
40
  }
17
- // Build query to find blocks referencing the page
18
- let queryArgs = [primary_tag];
41
+ const searchTags = [];
42
+ if (case_sensitive) {
43
+ searchTags.push(primary_tag);
44
+ }
45
+ else {
46
+ searchTags.push(primary_tag);
47
+ searchTags.push(primary_tag.charAt(0).toUpperCase() + primary_tag.slice(1));
48
+ searchTags.push(primary_tag.toUpperCase());
49
+ searchTags.push(primary_tag.toLowerCase());
50
+ }
51
+ const tagWhereClauses = searchTags.map(tag => {
52
+ // Roam tags can be [[tag name]] or #tag-name or #[[tag name]]
53
+ // The :node/title for a tag page is just the tag name without any # or [[ ]]
54
+ return `[?ref-page :node/title "${tag}"]`;
55
+ }).join(' ');
56
+ let inClause = `:in $`;
57
+ let queryLimit = limit === -1 ? '' : `:limit ${limit}`;
58
+ let queryOffset = offset === 0 ? '' : `:offset ${offset}`;
59
+ let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID
19
60
  let queryWhereClauses = `
20
- [?ref-page :node/title ?title-match]
21
- [(clojure.string/lower-case ?title-match) ?lower-title]
22
- [(clojure.string/lower-case ?title) ?search-title]
23
- [(= ?lower-title ?search-title)]
61
+ (or ${tagWhereClauses})
24
62
  [?b :block/refs ?ref-page]
25
63
  [?b :block/string ?block-str]
26
64
  [?b :block/uid ?block-uid]
27
65
  [?b :block/page ?p]
28
- [?p :node/title ?page-title]`;
29
- let inClause = `:in $ ?title`;
66
+ [?p :node/title ?page-title]
67
+ [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting
68
+ if (nearTagUid) {
69
+ queryWhereClauses += `
70
+ [?b :block/refs ?near-tag-page]
71
+ [?near-tag-page :block/uid "${nearTagUid}"]`;
72
+ }
73
+ if (excludeTagUid) {
74
+ queryWhereClauses += `
75
+ (not [?b :block/refs ?exclude-tag-page])
76
+ [?exclude-tag-page :block/uid "${excludeTagUid}"]`;
77
+ }
30
78
  if (targetPageUid) {
31
79
  inClause += ` ?target-page-uid`;
32
- queryArgs.push(targetPageUid);
33
80
  queryWhereClauses += `
34
81
  [?p :block/uid ?target-page-uid]`;
35
82
  }
36
83
  const queryStr = `[:find ?block-uid ?block-str ?page-title
37
- ${inClause}
84
+ ${inClause} ${queryLimit} ${queryOffset} ${queryOrder}
38
85
  :where
39
86
  ${queryWhereClauses}]`;
87
+ const queryArgs = [];
88
+ if (targetPageUid) {
89
+ queryArgs.push(targetPageUid);
90
+ }
40
91
  const rawResults = await q(this.graph, queryStr, queryArgs);
92
+ // Query to get total count without limit
93
+ const countQueryStr = `[:find (count ?b)
94
+ ${inClause}
95
+ :where
96
+ ${queryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query
97
+ const totalCountResults = await q(this.graph, countQueryStr, queryArgs);
98
+ const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
41
99
  // Resolve block references in content
42
100
  const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
43
101
  const resolvedContent = await resolveRefs(this.graph, content);
44
102
  return [uid, resolvedContent, pageTitle];
45
103
  }));
46
104
  const searchDescription = `referencing "${primary_tag}"`;
47
- return SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
105
+ const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
106
+ formattedResults.total_count = totalCount;
107
+ return formattedResults;
48
108
  }
49
109
  }
@@ -8,29 +8,68 @@ export class TextSearchHandler extends BaseSearchHandler {
8
8
  this.params = params;
9
9
  }
10
10
  async execute() {
11
- const { text, page_title_uid } = this.params;
11
+ const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0 } = this.params;
12
12
  // Get target page UID if provided for scoped search
13
13
  let targetPageUid;
14
14
  if (page_title_uid) {
15
15
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
16
  }
17
- // Build query to find blocks containing the text
18
- const queryStr = `[:find ?block-uid ?block-str ?page-title
19
- :in $ ?search-text
20
- :where
21
- [?b :block/string ?block-str]
22
- [(clojure.string/includes? ?block-str ?search-text)]
23
- [?b :block/uid ?block-uid]
24
- [?b :block/page ?p]
25
- [?p :node/title ?page-title]]`;
26
- const queryParams = [text];
17
+ const searchTerms = [];
18
+ if (case_sensitive) {
19
+ searchTerms.push(text);
20
+ }
21
+ else {
22
+ searchTerms.push(text);
23
+ // Add capitalized version (e.g., "Hypnosis")
24
+ searchTerms.push(text.charAt(0).toUpperCase() + text.slice(1));
25
+ // Add all caps version (e.g., "HYPNOSIS")
26
+ searchTerms.push(text.toUpperCase());
27
+ // Add all lowercase version (e.g., "hypnosis")
28
+ searchTerms.push(text.toLowerCase());
29
+ }
30
+ const whereClauses = searchTerms.map(term => `[(clojure.string/includes? ?block-str "${term}")]`).join(' ');
31
+ let queryStr;
32
+ let queryParams = [];
33
+ let queryLimit = limit === -1 ? '' : `:limit ${limit}`;
34
+ let queryOffset = offset === 0 ? '' : `:offset ${offset}`;
35
+ let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID
36
+ let baseQueryWhereClauses = `
37
+ [?b :block/string ?block-str]
38
+ (or ${whereClauses})
39
+ [?b :block/uid ?block-uid]
40
+ [?b :block/page ?p]
41
+ [?p :node/title ?page-title]
42
+ [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting
43
+ if (targetPageUid) {
44
+ queryStr = `[:find ?block-uid ?block-str ?page-title
45
+ :in $ ?page-uid ${queryLimit} ${queryOffset} ${queryOrder}
46
+ :where
47
+ ${baseQueryWhereClauses}
48
+ [?p :block/uid ?page-uid]]`;
49
+ queryParams = [targetPageUid];
50
+ }
51
+ else {
52
+ queryStr = `[:find ?block-uid ?block-str ?page-title
53
+ :in $ ${queryLimit} ${queryOffset} ${queryOrder}
54
+ :where
55
+ ${baseQueryWhereClauses}]`;
56
+ }
27
57
  const rawResults = await q(this.graph, queryStr, queryParams);
58
+ // Query to get total count without limit
59
+ const countQueryStr = `[:find (count ?b)
60
+ :in $
61
+ :where
62
+ ${baseQueryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query
63
+ const totalCountResults = await q(this.graph, countQueryStr, queryParams);
64
+ const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
28
65
  // Resolve block references in content
29
66
  const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
30
67
  const resolvedContent = await resolveRefs(this.graph, content);
31
68
  return [uid, resolvedContent, pageTitle];
32
69
  }));
33
70
  const searchDescription = `containing "${text}"`;
34
- return SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
71
+ const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
72
+ formattedResults.total_count = totalCount;
73
+ return formattedResults;
35
74
  }
36
75
  }
@@ -1,6 +1,65 @@
1
+ /**
2
+ * Capitalizes each word in a string
3
+ */
4
+ import { q } from '@roam-research/roam-api-sdk';
1
5
  /**
2
6
  * Capitalizes each word in a string
3
7
  */
4
8
  export const capitalizeWords = (str) => {
5
9
  return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
6
10
  };
11
+ /**
12
+ * Retrieves a block's UID based on its exact text content.
13
+ * This function is intended for internal use by other MCP tools.
14
+ * @param graph The Roam graph instance.
15
+ * @param blockText The exact text content of the block to find.
16
+ * @returns The UID of the block if found, otherwise null.
17
+ */
18
+ export const getBlockUidByText = async (graph, blockText) => {
19
+ const query = `[:find ?uid .
20
+ :in $ ?blockString
21
+ :where [?b :block/string ?blockString]
22
+ [?b :block/uid ?uid]]`;
23
+ const result = await q(graph, query, [blockText]);
24
+ return result && result.length > 0 ? result[0][0] : null;
25
+ };
26
+ /**
27
+ * Retrieves all UIDs nested under a given block_uid or block_text (exact match).
28
+ * This function is intended for internal use by other MCP tools.
29
+ * @param graph The Roam graph instance.
30
+ * @param rootIdentifier The UID or exact text content of the root block.
31
+ * @returns An array of UIDs of all descendant blocks, including the root block's UID.
32
+ */
33
+ export const getNestedUids = async (graph, rootIdentifier) => {
34
+ let rootUid = rootIdentifier;
35
+ // If the rootIdentifier is not a UID (simple check for 9 alphanumeric characters), try to resolve it as block text
36
+ if (!rootIdentifier.match(/^[a-zA-Z0-9]{9}$/)) {
37
+ rootUid = await getBlockUidByText(graph, rootIdentifier);
38
+ }
39
+ if (!rootUid) {
40
+ return []; // No root block found
41
+ }
42
+ const query = `[:find ?child-uid
43
+ :in $ ?root-uid
44
+ :where
45
+ [?root-block :block/uid ?root-uid]
46
+ [?root-block :block/children ?child-block]
47
+ [?child-block :block/uid ?child-uid]]`;
48
+ const results = await q(graph, query, [rootUid]);
49
+ return results.map(r => r[0]);
50
+ };
51
+ /**
52
+ * Retrieves all UIDs nested under a given block_text (exact match).
53
+ * This function is intended for internal use by other MCP tools.
54
+ * It strictly requires an exact text match for the root block.
55
+ * @param graph The Roam graph instance.
56
+ * @param blockText The exact text content of the root block.
57
+ * @returns An array of UIDs of all descendant blocks, including the root block's UID.
58
+ */
59
+ export const getNestedUidsByText = async (graph, blockText) => {
60
+ const rootUid = await getBlockUidByText(graph, blockText);
61
+ if (!rootUid) {
62
+ return []; // No root block found with exact text match
63
+ }
64
+ return getNestedUids(graph, rootUid);
65
+ };
@@ -16,7 +16,7 @@ export class BlockRetrievalOperations {
16
16
  const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading
17
17
  :in $ [?parentUid ...]
18
18
  :where [?parent :block/uid ?parentUid]
19
- [?child :block/parents ?parent]
19
+ [?parent :block/children ?child]
20
20
  [?child :block/uid ?childUid]
21
21
  [?child :block/string ?childString]
22
22
  [?child :block/order ?childOrder]
@@ -6,6 +6,188 @@ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../.
6
6
  export class OutlineOperations {
7
7
  constructor(graph) {
8
8
  this.graph = graph;
9
+ /**
10
+ * Helper function to check if string is a valid Roam UID (9 characters)
11
+ */
12
+ this.isValidUid = (str) => {
13
+ return typeof str === 'string' && str.length === 9;
14
+ };
15
+ }
16
+ /**
17
+ * Helper function to find block with improved relationship checks
18
+ */
19
+ async findBlockWithRetry(pageUid, blockString, maxRetries = 5, initialDelay = 1000) {
20
+ // Try multiple query strategies
21
+ const queries = [
22
+ // Strategy 1: Direct page and string match
23
+ `[:find ?b-uid ?order
24
+ :where [?p :block/uid "${pageUid}"]
25
+ [?b :block/page ?p]
26
+ [?b :block/string "${blockString}"]
27
+ [?b :block/order ?order]
28
+ [?b :block/uid ?b-uid]]`,
29
+ // Strategy 2: Parent-child relationship
30
+ `[:find ?b-uid ?order
31
+ :where [?p :block/uid "${pageUid}"]
32
+ [?b :block/parents ?p]
33
+ [?b :block/string "${blockString}"]
34
+ [?b :block/order ?order]
35
+ [?b :block/uid ?b-uid]]`,
36
+ // Strategy 3: Broader page relationship
37
+ `[:find ?b-uid ?order
38
+ :where [?p :block/uid "${pageUid}"]
39
+ [?b :block/page ?page]
40
+ [?p :block/page ?page]
41
+ [?b :block/string "${blockString}"]
42
+ [?b :block/order ?order]
43
+ [?b :block/uid ?b-uid]]`
44
+ ];
45
+ for (let retry = 0; retry < maxRetries; retry++) {
46
+ // Try each query strategy
47
+ for (const queryStr of queries) {
48
+ const blockResults = await q(this.graph, queryStr, []);
49
+ if (blockResults && blockResults.length > 0) {
50
+ // Use the most recently created block
51
+ const sorted = blockResults.sort((a, b) => b[1] - a[1]);
52
+ return sorted[0][0];
53
+ }
54
+ }
55
+ // Exponential backoff
56
+ const delay = initialDelay * Math.pow(2, retry);
57
+ await new Promise(resolve => setTimeout(resolve, delay));
58
+ }
59
+ throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
60
+ }
61
+ ;
62
+ /**
63
+ * Helper function to create and verify block with improved error handling
64
+ */
65
+ async createAndVerifyBlock(content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) {
66
+ try {
67
+ // Initial delay before any operations
68
+ if (!isRetry) {
69
+ await new Promise(resolve => setTimeout(resolve, initialDelay));
70
+ }
71
+ for (let retry = 0; retry < maxRetries; retry++) {
72
+ console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
73
+ // Create block using batchActions
74
+ const batchResult = await batchActions(this.graph, {
75
+ action: 'batch-actions',
76
+ actions: [{
77
+ action: 'create-block',
78
+ location: {
79
+ 'parent-uid': parentUid,
80
+ order: 'last'
81
+ },
82
+ block: { string: content }
83
+ }]
84
+ });
85
+ if (!batchResult) {
86
+ throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
87
+ }
88
+ // Wait with exponential backoff
89
+ const delay = initialDelay * Math.pow(2, retry);
90
+ await new Promise(resolve => setTimeout(resolve, delay));
91
+ try {
92
+ // Try to find the block using our improved findBlockWithRetry
93
+ return await this.findBlockWithRetry(parentUid, content);
94
+ }
95
+ catch (error) {
96
+ const errorMessage = error instanceof Error ? error.message : String(error);
97
+ // console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
98
+ if (retry === maxRetries - 1)
99
+ throw error;
100
+ }
101
+ }
102
+ throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
103
+ }
104
+ catch (error) {
105
+ // If this is already a retry, throw the error
106
+ if (isRetry)
107
+ throw error;
108
+ // Otherwise, try one more time with a clean slate
109
+ // console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
110
+ await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
111
+ return this.createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
112
+ }
113
+ }
114
+ ;
115
+ /**
116
+ * Helper function to fetch a block and its children recursively
117
+ */
118
+ async fetchBlockWithChildren(blockUid, level = 1) {
119
+ const query = `
120
+ [:find ?childUid ?childString ?childOrder
121
+ :in $ ?parentUid
122
+ :where
123
+ [?parentEntity :block/uid ?parentUid]
124
+ [?parentEntity :block/children ?childEntity] ; This ensures direct children
125
+ [?childEntity :block/uid ?childUid]
126
+ [?childEntity :block/string ?childString]
127
+ [?childEntity :block/order ?childOrder]]
128
+ `;
129
+ const blockQuery = `
130
+ [:find ?string
131
+ :in $ ?uid
132
+ :where
133
+ [?e :block/uid ?uid]
134
+ [?e :block/string ?string]]
135
+ `;
136
+ try {
137
+ const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
138
+ if (!blockStringResult || blockStringResult.length === 0) {
139
+ return null;
140
+ }
141
+ const text = blockStringResult[0][0];
142
+ const childrenResults = await q(this.graph, query, [blockUid]);
143
+ const children = [];
144
+ if (childrenResults && childrenResults.length > 0) {
145
+ // Sort children by order
146
+ const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
147
+ for (const childResult of sortedChildren) {
148
+ const childUid = childResult[0];
149
+ const nestedChild = await this.fetchBlockWithChildren(childUid, level + 1);
150
+ if (nestedChild) {
151
+ children.push(nestedChild);
152
+ }
153
+ }
154
+ }
155
+ // The order of the root block is not available from this query, so we set it to 0
156
+ return { uid: blockUid, text, level, order: 0, children: children.length > 0 ? children : undefined };
157
+ }
158
+ catch (error) {
159
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
160
+ }
161
+ }
162
+ ;
163
+ /**
164
+ * Recursively fetches a nested structure of blocks under a given root block UID.
165
+ */
166
+ async fetchNestedStructure(rootUid) {
167
+ const query = `[:find ?child-uid ?child-string ?child-order
168
+ :in $ ?parent-uid
169
+ :where
170
+ [?parent :block/uid ?parent-uid]
171
+ [?parent :block/children ?child]
172
+ [?child :block/uid ?child-uid]
173
+ [?child :block/string ?child-string]
174
+ [?child :block/order ?child-order]]`;
175
+ const directChildrenResult = await q(this.graph, query, [rootUid]);
176
+ if (directChildrenResult.length === 0) {
177
+ return [];
178
+ }
179
+ const nestedBlocks = [];
180
+ for (const [childUid, childString, childOrder] of directChildrenResult) {
181
+ const children = await this.fetchNestedStructure(childUid);
182
+ nestedBlocks.push({
183
+ uid: childUid,
184
+ text: childString,
185
+ level: 0, // Level is not easily determined here, so we set it to 0
186
+ children: children,
187
+ order: childOrder
188
+ });
189
+ }
190
+ return nestedBlocks.sort((a, b) => a.order - b.order);
9
191
  }
10
192
  /**
11
193
  * Creates an outline structure on a Roam Research page, optionally under a specific block.
@@ -86,147 +268,6 @@ export class OutlineOperations {
86
268
  };
87
269
  // Get or create the target page
88
270
  const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
89
- // Helper function to find block with improved relationship checks
90
- const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
91
- // Try multiple query strategies
92
- const queries = [
93
- // Strategy 1: Direct page and string match
94
- `[:find ?b-uid ?order
95
- :where [?p :block/uid "${pageUid}"]
96
- [?b :block/page ?p]
97
- [?b :block/string "${blockString}"]
98
- [?b :block/order ?order]
99
- [?b :block/uid ?b-uid]]`,
100
- // Strategy 2: Parent-child relationship
101
- `[:find ?b-uid ?order
102
- :where [?p :block/uid "${pageUid}"]
103
- [?b :block/parents ?p]
104
- [?b :block/string "${blockString}"]
105
- [?b :block/order ?order]
106
- [?b :block/uid ?b-uid]]`,
107
- // Strategy 3: Broader page relationship
108
- `[:find ?b-uid ?order
109
- :where [?p :block/uid "${pageUid}"]
110
- [?b :block/page ?page]
111
- [?p :block/page ?page]
112
- [?b :block/string "${blockString}"]
113
- [?b :block/order ?order]
114
- [?b :block/uid ?b-uid]]`
115
- ];
116
- for (let retry = 0; retry < maxRetries; retry++) {
117
- // Try each query strategy
118
- for (const queryStr of queries) {
119
- const blockResults = await q(this.graph, queryStr, []);
120
- if (blockResults && blockResults.length > 0) {
121
- // Use the most recently created block
122
- const sorted = blockResults.sort((a, b) => b[1] - a[1]);
123
- return sorted[0][0];
124
- }
125
- }
126
- // Exponential backoff
127
- const delay = initialDelay * Math.pow(2, retry);
128
- await new Promise(resolve => setTimeout(resolve, delay));
129
- }
130
- throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
131
- };
132
- // Helper function to create and verify block with improved error handling
133
- const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
134
- try {
135
- // Initial delay before any operations
136
- if (!isRetry) {
137
- await new Promise(resolve => setTimeout(resolve, initialDelay));
138
- }
139
- for (let retry = 0; retry < maxRetries; retry++) {
140
- console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
141
- // Create block using batchActions
142
- const batchResult = await batchActions(this.graph, {
143
- action: 'batch-actions',
144
- actions: [{
145
- action: 'create-block',
146
- location: {
147
- 'parent-uid': parentUid,
148
- order: 'last'
149
- },
150
- block: { string: content }
151
- }]
152
- });
153
- if (!batchResult) {
154
- throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
155
- }
156
- // Wait with exponential backoff
157
- const delay = initialDelay * Math.pow(2, retry);
158
- await new Promise(resolve => setTimeout(resolve, delay));
159
- try {
160
- // Try to find the block using our improved findBlockWithRetry
161
- return await findBlockWithRetry(parentUid, content);
162
- }
163
- catch (error) {
164
- const errorMessage = error instanceof Error ? error.message : String(error);
165
- // console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
166
- if (retry === maxRetries - 1)
167
- throw error;
168
- }
169
- }
170
- throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
171
- }
172
- catch (error) {
173
- // If this is already a retry, throw the error
174
- if (isRetry)
175
- throw error;
176
- // Otherwise, try one more time with a clean slate
177
- // console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
178
- await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
179
- return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
180
- }
181
- };
182
- // Helper function to check if string is a valid Roam UID (9 characters)
183
- const isValidUid = (str) => {
184
- return typeof str === 'string' && str.length === 9;
185
- };
186
- // Helper function to fetch a block and its children recursively
187
- const fetchBlockWithChildren = async (blockUid, level = 1) => {
188
- const query = `
189
- [:find ?childUid ?childString ?childOrder
190
- :in $ ?parentUid
191
- :where
192
- [?parentEntity :block/uid ?parentUid]
193
- [?parentEntity :block/children ?childEntity] ; This ensures direct children
194
- [?childEntity :block/uid ?childUid]
195
- [?childEntity :block/string ?childString]
196
- [?childEntity :block/order ?childOrder]]
197
- `;
198
- const blockQuery = `
199
- [:find ?string
200
- :in $ ?uid
201
- :where
202
- [?e :block/uid ?uid]
203
- [?e :block/string ?string]]
204
- `;
205
- try {
206
- const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
207
- if (!blockStringResult || blockStringResult.length === 0) {
208
- return null;
209
- }
210
- const text = blockStringResult[0][0];
211
- const childrenResults = await q(this.graph, query, [blockUid]);
212
- const children = [];
213
- if (childrenResults && childrenResults.length > 0) {
214
- // Sort children by order
215
- const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
216
- for (const childResult of sortedChildren) {
217
- const childUid = childResult[0];
218
- const nestedChild = await fetchBlockWithChildren(childUid, level + 1);
219
- if (nestedChild) {
220
- children.push(nestedChild);
221
- }
222
- }
223
- }
224
- return { uid: blockUid, text, level, children: children.length > 0 ? children : undefined };
225
- }
226
- catch (error) {
227
- throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
228
- }
229
- };
230
271
  // Get or create the parent block
231
272
  let targetParentUid;
232
273
  if (!block_text_uid) {
@@ -234,7 +275,7 @@ export class OutlineOperations {
234
275
  }
235
276
  else {
236
277
  try {
237
- if (isValidUid(block_text_uid)) {
278
+ if (this.isValidUid(block_text_uid)) {
238
279
  // First try to find block by UID
239
280
  const uidQuery = `[:find ?uid
240
281
  :where [?e :block/uid "${block_text_uid}"]
@@ -250,12 +291,12 @@ export class OutlineOperations {
250
291
  }
251
292
  else {
252
293
  // Create header block and get its UID if not a valid UID
253
- targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
294
+ targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid);
254
295
  }
255
296
  }
256
297
  catch (error) {
257
298
  const errorMessage = error instanceof Error ? error.message : String(error);
258
- throw new McpError(ErrorCode.InternalError, `Failed to ${isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
299
+ throw new McpError(ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
259
300
  }
260
301
  }
261
302
  // Initialize result variable
@@ -325,9 +366,9 @@ export class OutlineOperations {
325
366
  for (const item of topLevelOutlineItems) {
326
367
  try {
327
368
  // Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
328
- const foundUid = await findBlockWithRetry(targetParentUid, item.text);
369
+ const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
329
370
  if (foundUid) {
330
- const nestedBlock = await fetchBlockWithChildren(foundUid);
371
+ const nestedBlock = await this.fetchBlockWithChildren(foundUid);
331
372
  if (nestedBlock) {
332
373
  createdBlocks.push(nestedBlock);
333
374
  }
@@ -346,7 +387,7 @@ export class OutlineOperations {
346
387
  created_uids: createdBlocks
347
388
  };
348
389
  }
349
- async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
390
+ async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
350
391
  // First get the page UID
351
392
  let targetPageUid = page_uid;
352
393
  if (!targetPageUid && page_title) {
@@ -393,15 +434,20 @@ export class OutlineOperations {
393
434
  throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
394
435
  }
395
436
  // Find block by exact string match within the page
396
- const findBlockQuery = `[:find ?uid
397
- :where [?p :block/uid "${targetPageUid}"]
437
+ const findBlockQuery = `[:find ?b-uid
438
+ :in $ ?page-uid ?block-string
439
+ :where [?p :block/uid ?page-uid]
398
440
  [?b :block/page ?p]
399
- [?b :block/string "${parent_string}"]]`;
400
- const blockResults = await q(this.graph, findBlockQuery, []);
401
- if (!blockResults || blockResults.length === 0) {
402
- throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
441
+ [?b :block/string ?block-string]
442
+ [?b :block/uid ?b-uid]]`;
443
+ const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, parent_string]);
444
+ if (blockResults && blockResults.length > 0) {
445
+ targetParentUid = blockResults[0][0];
446
+ }
447
+ else {
448
+ // If parent_string block doesn't exist, create it
449
+ targetParentUid = await this.createAndVerifyBlock(parent_string, targetPageUid);
403
450
  }
404
- targetParentUid = blockResults[0][0];
405
451
  }
406
452
  // If no parent specified, use page as parent
407
453
  if (!targetParentUid) {
@@ -423,8 +469,8 @@ export class OutlineOperations {
423
469
  if (!result) {
424
470
  throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
425
471
  }
426
- // Get the created block UIDs
427
- const createdUids = result.created_uids || [];
472
+ // After successful batch action, get all nested UIDs under the parent
473
+ const createdUids = await this.fetchNestedStructure(targetParentUid);
428
474
  return {
429
475
  success: true,
430
476
  page_uid: targetPageUid,
@@ -438,26 +484,42 @@ export class OutlineOperations {
438
484
  action: 'create-block',
439
485
  location: {
440
486
  "parent-uid": targetParentUid,
441
- order
487
+ "order": order
442
488
  },
443
489
  block: { string: content }
444
490
  }];
445
491
  try {
446
- const result = await batchActions(this.graph, {
492
+ await batchActions(this.graph, {
447
493
  action: 'batch-actions',
448
494
  actions
449
495
  });
450
- if (!result) {
451
- throw new McpError(ErrorCode.InternalError, 'Failed to create content block via batch action');
452
- }
453
496
  }
454
497
  catch (error) {
455
498
  throw new McpError(ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}`);
456
499
  }
500
+ // For single-line content, we still need to fetch the UID and construct a NestedBlock
501
+ const createdUids = [];
502
+ try {
503
+ const foundUid = await this.findBlockWithRetry(targetParentUid, content);
504
+ if (foundUid) {
505
+ createdUids.push({
506
+ uid: foundUid,
507
+ text: content,
508
+ level: 0,
509
+ order: 0,
510
+ children: []
511
+ });
512
+ }
513
+ }
514
+ catch (error) {
515
+ // Log warning but don't re-throw, as the block might be created, just not immediately verifiable
516
+ // console.warn(`Could not verify single block creation for "${content}": ${error.message}`);
517
+ }
457
518
  return {
458
519
  success: true,
459
520
  page_uid: targetPageUid,
460
- parent_uid: targetParentUid
521
+ parent_uid: targetParentUid,
522
+ created_uids: createdUids
461
523
  };
462
524
  }
463
525
  }
@@ -40,7 +40,7 @@ export const toolSchemas = {
40
40
  },
41
41
  roam_create_page: {
42
42
  name: 'roam_create_page',
43
- description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
43
+ description: 'Create a new standalone page in Roam with optional content, including structured outlines, using explicit nesting levels and headings (H1-H3). This is the preferred method for creating a new page with an outline in a single step. Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
44
44
  inputSchema: {
45
45
  type: 'object',
46
46
  properties: {
@@ -80,7 +80,7 @@ export const toolSchemas = {
80
80
  },
81
81
  roam_create_outline: {
82
82
  name: 'roam_create_outline',
83
- description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
83
+ description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. To create a new page with an outline, use the `roam_create_page` tool instead. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
84
84
  inputSchema: {
85
85
  type: 'object',
86
86
  properties: {
@@ -129,7 +129,7 @@ export const toolSchemas = {
129
129
  },
130
130
  roam_import_markdown: {
131
131
  name: 'roam_import_markdown',
132
- description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
132
+ description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page. If a `parent_string` is provided and the block does not exist, it will be created. Returns a nested structure of the created blocks.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
133
133
  inputSchema: {
134
134
  type: 'object',
135
135
  properties: {
@@ -151,7 +151,7 @@ export const toolSchemas = {
151
151
  },
152
152
  parent_string: {
153
153
  type: 'string',
154
- description: 'Optional: Exact string content of the parent block to add content under (used if parent_uid is not provided; requires page_uid or page_title).'
154
+ description: 'Optional: Exact string content of an existing parent block to add content under (used if parent_uid is not provided; requires page_uid or page_title). If the block does not exist, it will be created.'
155
155
  },
156
156
  order: {
157
157
  type: 'string',
@@ -165,7 +165,7 @@ export const toolSchemas = {
165
165
  },
166
166
  roam_search_for_tag: {
167
167
  name: 'roam_search_for_tag',
168
- description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby. Example: Use this to search for memories that are tagged with the MEMORIES_TAG.',
168
+ description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag. This tool supports pagination via the `limit` and `offset` parameters. Use this tool to search for memories tagged with the MEMORIES_TAG.',
169
169
  inputSchema: {
170
170
  type: 'object',
171
171
  properties: {
@@ -180,6 +180,21 @@ export const toolSchemas = {
180
180
  near_tag: {
181
181
  type: 'string',
182
182
  description: 'Optional: Another tag to filter results by - will only return blocks where both tags appear',
183
+ },
184
+ case_sensitive: {
185
+ type: 'boolean',
186
+ description: 'Optional: Whether the search should be case-sensitive. If false, it will search for the provided tag, capitalized versions, and first word capitalized versions.',
187
+ default: false
188
+ },
189
+ limit: {
190
+ type: 'integer',
191
+ description: 'Optional: The maximum number of results to return. Defaults to 1. Use -1 for no limit.',
192
+ default: 1
193
+ },
194
+ offset: {
195
+ type: 'integer',
196
+ description: 'Optional: The number of results to skip before returning matches. Useful for pagination. Defaults to 0.',
197
+ default: 0
183
198
  }
184
199
  },
185
200
  required: ['primary_tag']
@@ -273,7 +288,7 @@ export const toolSchemas = {
273
288
  },
274
289
  roam_search_by_text: {
275
290
  name: 'roam_search_by_text',
276
- description: 'Search for blocks containing specific text across all pages or within a specific page.',
291
+ description: 'Search for blocks containing specific text across all pages or within a specific page. This tool supports pagination via the `limit` and `offset` parameters.',
277
292
  inputSchema: {
278
293
  type: 'object',
279
294
  properties: {
@@ -284,6 +299,21 @@ export const toolSchemas = {
284
299
  page_title_uid: {
285
300
  type: 'string',
286
301
  description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
302
+ },
303
+ case_sensitive: {
304
+ type: 'boolean',
305
+ description: 'Optional: Whether the search should be case-sensitive. If false, it will search for the provided text, capitalized versions, and first word capitalized versions.',
306
+ default: false
307
+ },
308
+ limit: {
309
+ type: 'integer',
310
+ description: 'Optional: The maximum number of results to return. Defaults to 1. Use -1 for no limit.',
311
+ default: 1
312
+ },
313
+ offset: {
314
+ type: 'integer',
315
+ description: 'Optional: The number of results to skip before returning matches. Useful for pagination. Defaults to 0.',
316
+ default: 0
287
317
  }
288
318
  },
289
319
  required: ['text']
@@ -354,7 +384,7 @@ export const toolSchemas = {
354
384
  },
355
385
  roam_recall: {
356
386
  name: 'roam_recall',
357
- description: 'Retrieve all stored memories on page titled MEMORIES_TAG, or tagged block content with the same name. Returns a combined, deduplicated list of memories. Optionally filter blcoks with a specified tag and sort by creation date.',
387
+ description: 'Retrieve all stored memories on page titled MEMORIES_TAG, or tagged block content with the same name. Returns a combined, deduplicated list of memories. Optionally filter blcoks with a specific tag and sort by creation date.',
358
388
  inputSchema: {
359
389
  type: 'object',
360
390
  properties: {
@@ -373,13 +403,13 @@ export const toolSchemas = {
373
403
  },
374
404
  roam_datomic_query: {
375
405
  name: 'roam_datomic_query',
376
- description: 'Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. This provides direct access to Roam\'s query engine. Note: Roam graph is case-sensitive.\nList of some of Roam\'s data model Namespaces and Attributes: ancestor (descendants), attrs (lookup), block (children, heading, open, order, page, parents, props, refs, string, text-align, uid), children (view-type), create (email, time), descendant (ancestors), edit (email, seen-by, time), entity (attrs), log (id), node (title), page (uid, title), refs (text).\nPredicates (clojure.string/includes?, clojure.string/starts-with?, clojure.string/ends-with?, <, >, <=, >=, =, not=, !=).\nAggregates (distinct, count, sum, max, min, avg, limit).\nTips: Use :block/parents for all ancestor levels, :block/children for direct descendants only; combine clojure.string for complex matching, use distinct to deduplicate, leverage Pull patterns for hierarchies, handle case-sensitivity carefully, and chain ancestry rules for multi-level queries.',
406
+ description: 'Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. This provides direct access to Roam\'s query engine. Note: Roam graph is case-sensitive.\n\n__Optimal Use Cases for `roam_datomic_query`:__\n- __Regex Search:__ Use for scenarios requiring regex, as Datalog does not natively support full regular expressions. It can fetch broader results for client-side post-processing.\n- __Highly Complex Boolean Logic:__ Ideal for intricate combinations of "AND", "OR", and "NOT" conditions across multiple terms or attributes.\n- __Arbitrary Sorting Criteria:__ The go-to for highly customized sorting needs beyond default options.\n- __Proximity Search:__ For advanced search capabilities involving proximity, which are difficult to implement efficiently with simpler tools.\n\nList of some of Roam\'s data model Namespaces and Attributes: ancestor (descendants), attrs (lookup), block (children, heading, open, order, page, parents, props, refs, string, text-align, uid), children (view-type), create (email, time), descendant (ancestors), edit (email, seen-by, time), entity (attrs), log (id), node (title), page (uid, title), refs (text).\nPredicates (clojure.string/includes?, clojure.string/starts-with?, clojure.string/ends-with?, <, >, <=, >=, =, not=, !=).\nAggregates (distinct, count, sum, max, min, avg, limit).\nTips: Use :block/parents for all ancestor levels, :block/children for direct descendants only; combine clojure.string for complex matching, use distinct to deduplicate, leverage Pull patterns for hierarchies, handle case-sensitivity carefully, and chain ancestry rules for multi-level queries.',
377
407
  inputSchema: {
378
408
  type: 'object',
379
409
  properties: {
380
410
  query: {
381
411
  type: 'string',
382
- description: 'The Datomic query to execute (in Datalog syntax)'
412
+ description: 'The Datomic query to execute (in Datalog syntax). Example: `[:find ?block-string :where [?block :block/string ?block-string] (or [(clojure.string/includes? ?block-string "hypnosis")] [(clojure.string/includes? ?block-string "trance")] [(clojure.string/includes? ?block-string "suggestion")]) :limit 25]`'
383
413
  },
384
414
  inputs: {
385
415
  type: 'array',
@@ -445,10 +475,7 @@ export const toolSchemas = {
445
475
  description: 'The UID of the parent block or page.'
446
476
  },
447
477
  "order": {
448
- oneOf: [
449
- { type: 'integer', description: 'Zero-indexed position.' },
450
- { type: 'string', enum: ['first', 'last'], description: 'Position keyword.' }
451
- ],
478
+ type: ['integer', 'string'],
452
479
  description: 'The position of the block under its parent (e.g., 0, 1, 2) or a keyword ("first", "last").'
453
480
  }
454
481
  }
@@ -463,7 +490,7 @@ export const toolSchemas = {
463
490
  },
464
491
  roam_fetch_block_with_children: {
465
492
  name: 'roam_fetch_block_with_children',
466
- description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth.',
493
+ description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth. Returns a nested object structure containing the block\'s UID, text, order, and an array of its children.',
467
494
  inputSchema: {
468
495
  type: 'object',
469
496
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.32.4",
3
+ "version": "0.36.0",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "build"
29
29
  ],
30
30
  "scripts": {
31
- "build": "tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
31
+ "build": "echo \"Using custom instructions: .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md\" && tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
32
32
  "clean": "rm -rf build",
33
33
  "watch": "tsc --watch",
34
34
  "inspector": "npx @modelcontextprotocol/inspector build/index.js",