roam-research-mcp 1.6.0 → 2.4.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.
@@ -2,8 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
- import { initializeGraph } from '@roam-research/roam-api-sdk';
6
- import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT } from '../config/environment.js';
5
+ import { HTTP_STREAM_PORT, validateEnvironment } from '../config/environment.js';
6
+ import { createRegistryFromEnv } from '../config/graph-registry.js';
7
7
  import { toolSchemas } from '../tools/schemas.js';
8
8
  import { ToolHandlers } from '../tools/tool-handlers.js';
9
9
  import { readFileSync } from 'node:fs';
@@ -20,28 +20,34 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
20
20
  const serverVersion = packageJson.version;
21
21
  export class RoamServer {
22
22
  constructor() {
23
+ this.toolHandlersCache = new Map();
24
+ // Validate environment first
25
+ validateEnvironment();
23
26
  try {
24
- this.graph = initializeGraph({
25
- token: API_TOKEN,
26
- graph: GRAPH_NAME,
27
- });
27
+ this.registry = createRegistryFromEnv();
28
28
  }
29
29
  catch (error) {
30
30
  const errorMessage = error instanceof Error ? error.message : String(error);
31
- throw new McpError(ErrorCode.InternalError, `Failed to initialize Roam graph: ${errorMessage}`);
32
- }
33
- try {
34
- this.toolHandlers = new ToolHandlers(this.graph);
35
- }
36
- catch (error) {
37
- const errorMessage = error instanceof Error ? error.message : String(error);
38
- throw new McpError(ErrorCode.InternalError, `Failed to initialize tool handlers: ${errorMessage}`);
31
+ throw new McpError(ErrorCode.InternalError, `Failed to initialize graph registry: ${errorMessage}`);
39
32
  }
40
33
  // Ensure toolSchemas is not empty before proceeding
41
34
  if (Object.keys(toolSchemas).length === 0) {
42
35
  throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts');
43
36
  }
44
37
  }
38
+ /**
39
+ * Get or create a ToolHandlers instance for a specific graph
40
+ * Handlers are cached per-graph for efficiency
41
+ */
42
+ getToolHandlers(graph, graphKey) {
43
+ const cached = this.toolHandlersCache.get(graphKey);
44
+ if (cached) {
45
+ return cached;
46
+ }
47
+ const handlers = new ToolHandlers(graph);
48
+ this.toolHandlersCache.set(graphKey, handlers);
49
+ return handlers;
50
+ }
45
51
  // Helper to create and configure MCP server instance
46
52
  createMcpServer(nameSuffix = '') {
47
53
  const server = new Server({
@@ -59,6 +65,25 @@ export class RoamServer {
59
65
  this.setupRequestHandlers(server);
60
66
  return server;
61
67
  }
68
+ /**
69
+ * Extract graph and write_key from tool arguments
70
+ */
71
+ extractGraphParams(args) {
72
+ const { graph, write_key, ...cleanedArgs } = args;
73
+ return {
74
+ graphKey: graph,
75
+ writeKey: write_key,
76
+ cleanedArgs,
77
+ };
78
+ }
79
+ /**
80
+ * Resolve graph for a tool call with validation
81
+ */
82
+ resolveGraph(toolName, graphKey, writeKey) {
83
+ const resolvedKey = graphKey ?? this.registry.defaultKey;
84
+ const graph = this.registry.resolveGraphForTool(toolName, graphKey, writeKey);
85
+ return { graph, resolvedKey };
86
+ }
62
87
  // Refactored to accept a Server instance
63
88
  setupRequestHandlers(mcpServer) {
64
89
  // List available tools
@@ -81,139 +106,145 @@ export class RoamServer {
81
106
  // Handle tool calls
82
107
  mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
83
108
  try {
109
+ const args = (request.params.arguments ?? {});
110
+ const { graphKey, writeKey, cleanedArgs } = this.extractGraphParams(args);
111
+ const { graph, resolvedKey } = this.resolveGraph(request.params.name, graphKey, writeKey);
112
+ const toolHandlers = this.getToolHandlers(graph, resolvedKey);
84
113
  switch (request.params.name) {
85
114
  case 'roam_markdown_cheatsheet': {
86
- const content = await this.toolHandlers.getRoamMarkdownCheatsheet();
115
+ const graphInfo = this.registry.getGraphInfoMarkdown();
116
+ const cheatsheet = await toolHandlers.getRoamMarkdownCheatsheet();
117
+ const content = graphInfo + cheatsheet;
87
118
  return {
88
119
  content: [{ type: 'text', text: content }],
89
120
  };
90
121
  }
91
122
  case 'roam_remember': {
92
- const { memory, categories } = request.params.arguments;
93
- const result = await this.toolHandlers.remember(memory, categories);
123
+ const { memory, categories, heading, parent_uid } = cleanedArgs;
124
+ const result = await toolHandlers.remember(memory, categories, heading, parent_uid);
94
125
  return {
95
126
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
96
127
  };
97
128
  }
98
129
  case 'roam_fetch_page_by_title': {
99
- const { title, format } = request.params.arguments;
100
- const content = await this.toolHandlers.fetchPageByTitle(title, format);
130
+ const { title, format } = cleanedArgs;
131
+ const content = await toolHandlers.fetchPageByTitle(title, format);
101
132
  return {
102
133
  content: [{ type: 'text', text: content }],
103
134
  };
104
135
  }
105
136
  case 'roam_create_page': {
106
- const { title, content } = request.params.arguments;
107
- const result = await this.toolHandlers.createPage(title, content);
137
+ const { title, content } = cleanedArgs;
138
+ const result = await toolHandlers.createPage(title, content);
108
139
  return {
109
140
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
110
141
  };
111
142
  }
112
143
  case 'roam_import_markdown': {
113
- const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = request.params.arguments;
114
- const result = await this.toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
144
+ const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = cleanedArgs;
145
+ const result = await toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
115
146
  return {
116
147
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
117
148
  };
118
149
  }
119
150
  case 'roam_add_todo': {
120
- const { todos } = request.params.arguments;
121
- const result = await this.toolHandlers.addTodos(todos);
151
+ const { todos } = cleanedArgs;
152
+ const result = await toolHandlers.addTodos(todos);
122
153
  return {
123
154
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
124
155
  };
125
156
  }
126
157
  case 'roam_create_outline': {
127
- const { outline, page_title_uid, block_text_uid } = request.params.arguments;
128
- const result = await this.toolHandlers.createOutline(outline, page_title_uid, block_text_uid);
158
+ const { outline, page_title_uid, block_text_uid } = cleanedArgs;
159
+ const result = await toolHandlers.createOutline(outline, page_title_uid, block_text_uid);
129
160
  return {
130
161
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
131
162
  };
132
163
  }
133
164
  case 'roam_search_for_tag': {
134
- const { primary_tag, page_title_uid, near_tag } = request.params.arguments;
135
- const result = await this.toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
165
+ const { primary_tag, page_title_uid, near_tag } = cleanedArgs;
166
+ const result = await toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
136
167
  return {
137
168
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
138
169
  };
139
170
  }
140
171
  case 'roam_search_by_status': {
141
- const { status, page_title_uid, include, exclude } = request.params.arguments;
142
- const result = await this.toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
172
+ const { status, page_title_uid, include, exclude } = cleanedArgs;
173
+ const result = await toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
143
174
  return {
144
175
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
145
176
  };
146
177
  }
147
178
  case 'roam_search_block_refs': {
148
- const params = request.params.arguments;
149
- const result = await this.toolHandlers.searchBlockRefs(params);
179
+ const params = cleanedArgs;
180
+ const result = await toolHandlers.searchBlockRefs(params);
150
181
  return {
151
182
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
152
183
  };
153
184
  }
154
185
  case 'roam_search_hierarchy': {
155
- const params = request.params.arguments;
186
+ const params = cleanedArgs;
156
187
  // Validate that either parent_uid or child_uid is provided, but not both
157
188
  if ((!params.parent_uid && !params.child_uid) || (params.parent_uid && params.child_uid)) {
158
189
  throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
159
190
  }
160
- const result = await this.toolHandlers.searchHierarchy(params);
191
+ const result = await toolHandlers.searchHierarchy(params);
161
192
  return {
162
193
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
163
194
  };
164
195
  }
165
196
  case 'roam_find_pages_modified_today': {
166
- const { max_num_pages } = request.params.arguments;
167
- const result = await this.toolHandlers.findPagesModifiedToday(max_num_pages || 50);
197
+ const { max_num_pages } = cleanedArgs;
198
+ const result = await toolHandlers.findPagesModifiedToday(max_num_pages || 50);
168
199
  return {
169
200
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
170
201
  };
171
202
  }
172
203
  case 'roam_search_by_text': {
173
- const params = request.params.arguments;
174
- const result = await this.toolHandlers.searchByText(params);
204
+ const params = cleanedArgs;
205
+ const result = await toolHandlers.searchByText(params);
175
206
  return {
176
207
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
177
208
  };
178
209
  }
179
210
  case 'roam_search_by_date': {
180
- const params = request.params.arguments;
181
- const result = await this.toolHandlers.searchByDate(params);
211
+ const params = cleanedArgs;
212
+ const result = await toolHandlers.searchByDate(params);
182
213
  return {
183
214
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
184
215
  };
185
216
  }
186
217
  case 'roam_recall': {
187
- const { sort_by = 'newest', filter_tag } = request.params.arguments;
188
- const result = await this.toolHandlers.recall(sort_by, filter_tag);
218
+ const { sort_by = 'newest', filter_tag } = cleanedArgs;
219
+ const result = await toolHandlers.recall(sort_by, filter_tag);
189
220
  return {
190
221
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
191
222
  };
192
223
  }
193
224
  case 'roam_datomic_query': {
194
- const { query, inputs } = request.params.arguments;
195
- const result = await this.toolHandlers.executeDatomicQuery({ query, inputs });
225
+ const { query, inputs } = cleanedArgs;
226
+ const result = await toolHandlers.executeDatomicQuery({ query, inputs });
196
227
  return {
197
228
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
198
229
  };
199
230
  }
200
231
  case 'roam_process_batch_actions': {
201
- const { actions } = request.params.arguments;
202
- const result = await this.toolHandlers.processBatch(actions);
232
+ const { actions } = cleanedArgs;
233
+ const result = await toolHandlers.processBatch(actions);
203
234
  return {
204
235
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
205
236
  };
206
237
  }
207
238
  case 'roam_fetch_block_with_children': {
208
- const { block_uid, depth } = request.params.arguments;
209
- const result = await this.toolHandlers.fetchBlockWithChildren(block_uid, depth);
239
+ const { block_uid, depth } = cleanedArgs;
240
+ const result = await toolHandlers.fetchBlockWithChildren(block_uid, depth);
210
241
  return {
211
242
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
212
243
  };
213
244
  }
214
245
  case 'roam_create_table': {
215
- const { parent_uid, order, headers, rows } = request.params.arguments;
216
- const result = await this.toolHandlers.createTable({
246
+ const { parent_uid, order, headers, rows } = cleanedArgs;
247
+ const result = await toolHandlers.createTable({
217
248
  parent_uid,
218
249
  order,
219
250
  headers,
@@ -223,9 +254,23 @@ export class RoamServer {
223
254
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
224
255
  };
225
256
  }
257
+ case 'roam_move_block': {
258
+ const { block_uid, parent_uid, order = 'last' } = cleanedArgs;
259
+ const result = await toolHandlers.moveBlock(block_uid, parent_uid, order);
260
+ return {
261
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
262
+ };
263
+ }
226
264
  case 'roam_update_page_markdown': {
227
- const { title, markdown, dry_run = false } = request.params.arguments;
228
- const result = await this.toolHandlers.updatePageMarkdown(title, markdown, dry_run);
265
+ const { title, markdown, dry_run = false } = cleanedArgs;
266
+ const result = await toolHandlers.updatePageMarkdown(title, markdown, dry_run);
267
+ return {
268
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
269
+ };
270
+ }
271
+ case 'roam_rename_page': {
272
+ const { old_title, uid, new_title } = cleanedArgs;
273
+ const result = await toolHandlers.renamePage({ old_title, uid, new_title });
229
274
  return {
230
275
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
231
276
  };
@@ -24,7 +24,8 @@ export function validateBlockString(str, allowEmpty = false) {
24
24
  /**
25
25
  * Validates a Roam UID.
26
26
  * UIDs must be either:
27
- * - 9 alphanumeric characters (standard Roam UID)
27
+ * - 9 alphanumeric characters (standard Roam block UID)
28
+ * - A date format like MM-DD-YYYY (daily page UID)
28
29
  * - A placeholder like {{uid:name}}
29
30
  * @param uid The UID to validate
30
31
  * @param required If true, UID is required
@@ -38,11 +39,15 @@ export function validateUid(uid, required = true) {
38
39
  if (UID_PLACEHOLDER_REGEX.test(uid)) {
39
40
  return null;
40
41
  }
41
- // Check if it's a valid Roam UID (9 alphanumeric characters)
42
- if (!/^[a-zA-Z0-9_-]{9}$/.test(uid)) {
43
- return 'uid must be 9 alphanumeric characters or a {{uid:name}} placeholder';
42
+ // Check if it's a valid Roam block UID (9 alphanumeric characters)
43
+ if (/^[a-zA-Z0-9_-]{9}$/.test(uid)) {
44
+ return null;
44
45
  }
45
- return null;
46
+ // Check if it's a daily page UID (MM-DD-YYYY format)
47
+ if (/^\d{2}-\d{2}-\d{4}$/.test(uid)) {
48
+ return null;
49
+ }
50
+ return 'uid must be 9 alphanumeric characters, MM-DD-YYYY date, or a {{uid:name}} placeholder';
46
51
  }
47
52
  /**
48
53
  * Validates an outline level.
@@ -1,47 +1,66 @@
1
1
  import { q } from '@roam-research/roam-api-sdk';
2
+ // Roam block UIDs are 9 alphanumeric characters (with _ and -)
3
+ const REF_PATTERN = /\(\(([a-zA-Z0-9_-]{9})\)\)/g;
2
4
  /**
3
- * Collects all referenced block UIDs from text
5
+ * Extract all block UIDs referenced in text
4
6
  */
5
- export const collectRefs = (text, depth = 0, refs = new Set()) => {
6
- if (depth >= 4)
7
- return refs; // Max recursion depth
8
- const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
7
+ export function collectRefs(text) {
8
+ const refs = new Set();
9
9
  let match;
10
- while ((match = refRegex.exec(text)) !== null) {
11
- const [_, uid] = match;
12
- refs.add(uid);
10
+ while ((match = REF_PATTERN.exec(text)) !== null) {
11
+ refs.add(match[1]);
13
12
  }
13
+ REF_PATTERN.lastIndex = 0; // Reset for reuse
14
14
  return refs;
15
- };
15
+ }
16
16
  /**
17
- * Resolves block references in text by replacing them with their content
17
+ * Resolve block references in text by replacing ((uid)) with block content.
18
+ * Handles nested refs up to maxDepth, with circular reference protection.
19
+ *
20
+ * @param graph - Roam graph connection
21
+ * @param text - Text containing ((uid)) references
22
+ * @param depth - Current recursion depth (internal)
23
+ * @param maxDepth - Maximum depth to resolve nested refs (default: 4)
24
+ * @param seen - UIDs already resolved in this chain (circular protection)
18
25
  */
19
- export const resolveRefs = async (graph, text, depth = 0) => {
20
- if (depth >= 4)
21
- return text; // Max recursion depth
22
- const refs = collectRefs(text, depth);
26
+ export async function resolveRefs(graph, text, depth = 0, maxDepth = 4, seen = new Set()) {
27
+ if (depth >= maxDepth)
28
+ return text;
29
+ const refs = collectRefs(text);
23
30
  if (refs.size === 0)
24
31
  return text;
25
- // Get referenced block contents
32
+ // Filter out already-seen refs to prevent circular resolution
33
+ const newRefs = Array.from(refs).filter(uid => !seen.has(uid));
34
+ if (newRefs.length === 0)
35
+ return text;
36
+ // Track these refs as seen
37
+ newRefs.forEach(uid => seen.add(uid));
38
+ // Batch fetch all referenced blocks
26
39
  const refQuery = `[:find ?uid ?string
27
40
  :in $ [?uid ...]
28
41
  :where [?b :block/uid ?uid]
29
42
  [?b :block/string ?string]]`;
30
- const refResults = await q(graph, refQuery, [Array.from(refs)]);
31
- // Create lookup map of uid -> string
32
- const refMap = new Map();
33
- refResults.forEach(([uid, string]) => {
34
- refMap.set(uid, string);
35
- });
36
- // Replace references with their content
37
- let resolvedText = text;
38
- for (const uid of refs) {
39
- const refContent = refMap.get(uid);
40
- if (refContent) {
41
- // Recursively resolve nested references
42
- const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
43
- resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
43
+ const results = await q(graph, refQuery, [newRefs]);
44
+ // Build lookup map
45
+ const refMap = new Map(results);
46
+ // Collect refs that have nested refs needing resolution
47
+ const nestedTexts = new Map();
48
+ for (const uid of newRefs) {
49
+ const content = refMap.get(uid);
50
+ if (content && collectRefs(content).size > 0) {
51
+ nestedTexts.set(uid, content);
44
52
  }
45
53
  }
46
- return resolvedText;
47
- };
54
+ // Resolve nested refs in parallel
55
+ if (nestedTexts.size > 0) {
56
+ const resolvedEntries = await Promise.all(Array.from(nestedTexts.entries()).map(async ([uid, content]) => {
57
+ const resolved = await resolveRefs(graph, content, depth + 1, maxDepth, seen);
58
+ return [uid, resolved];
59
+ }));
60
+ resolvedEntries.forEach(([uid, resolved]) => refMap.set(uid, resolved));
61
+ }
62
+ // Replace all refs in a single pass
63
+ return text.replace(REF_PATTERN, (match, uid) => {
64
+ return refMap.get(uid) ?? match;
65
+ });
66
+ }
@@ -1,4 +1,4 @@
1
- import { q, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
1
+ import { q, updateBlock as updateRoamBlock, moveBlock as moveRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
@@ -189,6 +189,43 @@ export class BlockOperations {
189
189
  throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
190
190
  }
191
191
  }
192
+ async moveBlock(block_uid, parent_uid, order = 'last') {
193
+ if (!block_uid) {
194
+ throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
195
+ }
196
+ if (!parent_uid) {
197
+ throw new McpError(ErrorCode.InvalidRequest, 'parent_uid is required');
198
+ }
199
+ // Verify the block exists
200
+ const blockQuery = `[:find ?uid .
201
+ :where [?b :block/uid "${block_uid}"]
202
+ [?b :block/uid ?uid]]`;
203
+ const blockExists = await q(this.graph, blockQuery, []);
204
+ if (!blockExists) {
205
+ throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
206
+ }
207
+ try {
208
+ await moveRoamBlock(this.graph, {
209
+ action: 'move-block',
210
+ location: {
211
+ 'parent-uid': parent_uid,
212
+ order: order
213
+ },
214
+ block: {
215
+ uid: block_uid
216
+ }
217
+ });
218
+ return {
219
+ success: true,
220
+ block_uid,
221
+ new_parent_uid: parent_uid,
222
+ order
223
+ };
224
+ }
225
+ catch (error) {
226
+ throw new McpError(ErrorCode.InternalError, `Failed to move block: ${error.message}`);
227
+ }
228
+ }
192
229
  async updateBlocks(updates) {
193
230
  if (!Array.isArray(updates) || updates.length === 0) {
194
231
  throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
@@ -1,6 +1,7 @@
1
1
  import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
+ import { generateBlockUid } from '../../markdown-utils.js';
4
5
  import { resolveRefs } from '../helpers/refs.js';
5
6
  import { SearchOperations } from './search/index.js';
6
7
  import { pageUidCache } from '../../cache/page-uid-cache.js';
@@ -9,7 +10,7 @@ export class MemoryOperations {
9
10
  this.graph = graph;
10
11
  this.searchOps = new SearchOperations(graph);
11
12
  }
12
- async remember(memory, categories) {
13
+ async remember(memory, categories, heading, parent_uid) {
13
14
  // Get today's date
14
15
  const today = new Date();
15
16
  const dateStr = formatRoamDate(today);
@@ -47,6 +48,48 @@ export class MemoryOperations {
47
48
  }
48
49
  }
49
50
  }
51
+ // Determine parent block for the memory
52
+ let targetParentUid;
53
+ if (parent_uid) {
54
+ // Use provided parent_uid directly
55
+ targetParentUid = parent_uid;
56
+ }
57
+ else if (heading) {
58
+ // Search for heading block on today's page, create if not found
59
+ const headingQuery = `[:find ?uid
60
+ :in $ ?page-uid ?text
61
+ :where
62
+ [?page :block/uid ?page-uid]
63
+ [?page :block/children ?block]
64
+ [?block :block/string ?text]
65
+ [?block :block/uid ?uid]]`;
66
+ const headingResults = await q(this.graph, headingQuery, [pageUid, heading]);
67
+ if (headingResults && headingResults.length > 0) {
68
+ targetParentUid = headingResults[0][0];
69
+ }
70
+ else {
71
+ // Create the heading block
72
+ const headingBlockUid = generateBlockUid();
73
+ try {
74
+ await batchActions(this.graph, {
75
+ action: 'batch-actions',
76
+ actions: [{
77
+ action: 'create-block',
78
+ location: { 'parent-uid': pageUid, order: 'last' },
79
+ block: { uid: headingBlockUid, string: heading }
80
+ }]
81
+ });
82
+ targetParentUid = headingBlockUid;
83
+ }
84
+ catch (error) {
85
+ throw new McpError(ErrorCode.InternalError, `Failed to create heading block: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ }
88
+ }
89
+ else {
90
+ // Default: use daily page root
91
+ targetParentUid = pageUid;
92
+ }
50
93
  // Get memories tag from environment
51
94
  const memoriesTag = process.env.MEMORIES_TAG;
52
95
  if (!memoriesTag) {
@@ -57,15 +100,18 @@ export class MemoryOperations {
57
100
  // Handle multi-word categories
58
101
  return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
59
102
  }).join(' ') || '';
60
- // Create block with memory, memories tag, and optional categories
61
- const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
103
+ // Create block with memory, then all tags together at the end
104
+ const blockContent = `${memory} ${categoryTags} ${memoriesTag}`.trim();
105
+ // Pre-generate UID so we can return it
106
+ const blockUid = generateBlockUid();
62
107
  const actions = [{
63
108
  action: 'create-block',
64
109
  location: {
65
- 'parent-uid': pageUid,
110
+ 'parent-uid': targetParentUid,
66
111
  order: 'last'
67
112
  },
68
113
  block: {
114
+ uid: blockUid,
69
115
  string: blockContent
70
116
  }
71
117
  }];
@@ -81,7 +127,7 @@ export class MemoryOperations {
81
127
  catch (error) {
82
128
  throw new McpError(ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}`);
83
129
  }
84
- return { success: true };
130
+ return { success: true, block_uid: blockUid, parent_uid: targetParentUid };
85
131
  }
86
132
  async recall(sort_by = 'newest', filter_tag) {
87
133
  // Get memories tag from environment