tana-mcp-codemode 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tana-mcp-codemode",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Codemode MCP server for Tana — AI writes TypeScript that executes against Tana Local API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/api/tana.ts CHANGED
@@ -25,6 +25,115 @@ import type {
25
25
  } from "./types";
26
26
  import { format } from "./format";
27
27
 
28
+ /**
29
+ * Build deep-object query params (style: deepObject, explode: true).
30
+ * Used by search and getFieldOptions.
31
+ */
32
+ function addQueryParams(params: string[], obj: Record<string, unknown>, prefix: string) {
33
+ for (const [key, value] of Object.entries(obj)) {
34
+ const paramKey = prefix ? `${prefix}[${key}]` : key;
35
+ if (value === null || value === undefined) continue;
36
+ if (typeof value === "object" && !Array.isArray(value)) {
37
+ addQueryParams(params, value as Record<string, unknown>, paramKey);
38
+ } else if (Array.isArray(value)) {
39
+ value.forEach((item, i) => {
40
+ if (typeof item === "object") {
41
+ addQueryParams(params, item as Record<string, unknown>, `${paramKey}[${i}]`);
42
+ } else {
43
+ params.push(`${encodeURIComponent(`${paramKey}[${i}]`)}=${encodeURIComponent(String(item))}`);
44
+ }
45
+ });
46
+ } else {
47
+ params.push(`${encodeURIComponent(paramKey)}=${encodeURIComponent(String(value))}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ /** Parse predetermined options: ` - Name <!-- node-id: ABC -->` */
53
+ function parseOptionsFromMarkdown(md: string): { id: string; name: string }[] {
54
+ const results: { id: string; name: string }[] = [];
55
+ const lines = md.split("\n");
56
+ let inOptions = false;
57
+ for (const line of lines) {
58
+ if (line.includes("**Options**:")) { inOptions = true; continue; }
59
+ if (inOptions) {
60
+ const match = line.match(/^\s+- (.+?)\s*<!--\s*node-id:\s*(\S+)\s*-->/);
61
+ if (match) {
62
+ results.push({ id: match[2], name: match[1] });
63
+ } else if (line.trim() && !line.startsWith(" ")) {
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ return results;
69
+ }
70
+
71
+ /** Parse `[name](tana:id)` references from markdown */
72
+ function parseRefsFromMarkdown(md: string): { id: string; name: string }[] {
73
+ const results: { id: string; name: string }[] = [];
74
+ const re = /\[([^\]]+)\]\(tana:([^)]+)\)/g;
75
+ let match;
76
+ while ((match = re.exec(md)) !== null) {
77
+ results.push({ id: match[2], name: match[1] });
78
+ }
79
+ return results;
80
+ }
81
+
82
+ /**
83
+ * Extract option values for a specific field from node markdown.
84
+ * Scopes parsing to only the target field's section to avoid noise
85
+ * from other fields, tags, and node names.
86
+ */
87
+ function parseFieldValues(md: string, fieldName: string): { id: string; name: string }[] {
88
+ const results: { id: string; name: string }[] = [];
89
+ const lines = md.split("\n");
90
+ let inField = false;
91
+ let fieldIndent = -1;
92
+
93
+ for (const line of lines) {
94
+ // Detect the field section: " - **FieldName**:" or " - **FieldName**: value <!-- node-id: xxx -->"
95
+ if (!inField && line.includes(`**${fieldName}**`)) {
96
+ inField = true;
97
+ fieldIndent = line.search(/\S/);
98
+
99
+ // Inline single-value: " - **Type**: negotiation <!-- node-id: xxx -->"
100
+ const inlineMatch = line.match(
101
+ new RegExp(`\\*\\*${fieldName}\\*\\*:\\s*(.+?)\\s*<!--\\s*node-id:\\s*(\\S+)\\s*-->`)
102
+ );
103
+ if (inlineMatch) {
104
+ results.push({ id: inlineMatch[2], name: inlineMatch[1].trim() });
105
+ }
106
+ // Also check for tana-link format: " - **Type**: [value](tana:id)"
107
+ const linkMatch = line.match(
108
+ new RegExp(`\\*\\*${fieldName}\\*\\*:\\s*\\[([^\\]]+)\\]\\(tana:([^)]+)\\)`)
109
+ );
110
+ if (linkMatch) {
111
+ results.push({ id: linkMatch[2], name: linkMatch[1] });
112
+ }
113
+ continue;
114
+ }
115
+
116
+ if (inField) {
117
+ const currentIndent = line.search(/\S/);
118
+ // Left the field's section (same or less indentation)
119
+ if (line.trim() && currentIndent <= fieldIndent) break;
120
+
121
+ // Child value: " - value <!-- node-id: xxx -->"
122
+ const childMatch = line.match(/^\s+- (.+?)\s*<!--\s*node-id:\s*(\S+)\s*-->/);
123
+ if (childMatch) {
124
+ results.push({ id: childMatch[2], name: childMatch[1].trim() });
125
+ continue;
126
+ }
127
+ // Child value (tana-link): " - [value](tana:id)"
128
+ const childLinkMatch = line.match(/^\s+- \[([^\]]+)\]\(tana:([^)]+)\)/);
129
+ if (childLinkMatch) {
130
+ results.push({ id: childLinkMatch[2], name: childLinkMatch[1] });
131
+ }
132
+ }
133
+ }
134
+ return results;
135
+ }
136
+
28
137
  /**
29
138
  * TanaAPI Interface
30
139
  *
@@ -87,12 +196,11 @@ export interface TanaAPI {
87
196
  };
88
197
 
89
198
  fields: {
90
- /** Set a field to an option value */
199
+ /** Set a field to option value(s). Pass string[] for multi-value fields. */
91
200
  setOption(
92
201
  nodeId: string,
93
202
  attributeId: string,
94
- optionId: string,
95
- mode?: "replace" | "append"
203
+ optionId: string | string[]
96
204
  ): Promise<{ success: boolean }>;
97
205
  /** Set a field to a string value, or null to clear it */
98
206
  setContent(
@@ -101,6 +209,11 @@ export interface TanaAPI {
101
209
  content: string | null,
102
210
  mode?: "replace" | "append"
103
211
  ): Promise<{ success: boolean }>;
212
+ /** Discover available options for an Options-type field */
213
+ getFieldOptions(
214
+ fieldId: string,
215
+ options?: { tagId?: string; workspaceId?: string; limit?: number }
216
+ ): Promise<{ id: string; name: string }[]>;
104
217
  };
105
218
 
106
219
  calendar: {
@@ -142,30 +255,8 @@ export function createTanaAPI(
142
255
  query: SearchQuery,
143
256
  options?: SearchOptions
144
257
  ): Promise<SearchResult[]> {
145
- // Build deep object query params (style: deepObject, explode: true)
146
258
  const params: string[] = [];
147
-
148
- function addQueryParams(obj: Record<string, unknown>, prefix: string) {
149
- for (const [key, value] of Object.entries(obj)) {
150
- const paramKey = prefix ? `${prefix}[${key}]` : key;
151
- if (value === null || value === undefined) continue;
152
- if (typeof value === "object" && !Array.isArray(value)) {
153
- addQueryParams(value as Record<string, unknown>, paramKey);
154
- } else if (Array.isArray(value)) {
155
- value.forEach((item, i) => {
156
- if (typeof item === "object") {
157
- addQueryParams(item as Record<string, unknown>, `${paramKey}[${i}]`);
158
- } else {
159
- params.push(`${encodeURIComponent(`${paramKey}[${i}]`)}=${encodeURIComponent(String(item))}`);
160
- }
161
- });
162
- } else {
163
- params.push(`${encodeURIComponent(paramKey)}=${encodeURIComponent(String(value))}`);
164
- }
165
- }
166
- }
167
-
168
- addQueryParams(query as unknown as Record<string, unknown>, "query");
259
+ addQueryParams(params, query as unknown as Record<string, unknown>, "query");
169
260
  if (options?.limit) params.push(`limit=${options.limit}`);
170
261
  const effectiveWorkspaceIds = options?.workspaceIds
171
262
  ?? (defaultSearchWorkspaceIds?.length ? defaultSearchWorkspaceIds : undefined);
@@ -346,17 +437,48 @@ export function createTanaAPI(
346
437
  },
347
438
 
348
439
  fields: {
440
+ // Workaround: append mode is broken in Tana API (clears field instead of appending).
441
+ // For arrays, we set first value via API, then import remaining into the field tuple.
442
+ // See claudedocs/api-bugs.md
349
443
  async setOption(
350
444
  nodeId: string,
351
445
  attributeId: string,
352
- optionId: string,
353
- mode?: "replace" | "append"
446
+ optionId: string | string[],
447
+ _mode?: "replace" | "append"
354
448
  ): Promise<{ success: boolean }> {
355
- const result = await client.post<{ nodeId: string; message: string }>(
449
+ const ids = Array.isArray(optionId) ? optionId : [optionId];
450
+ if (ids.length === 0) return { success: true };
451
+
452
+ // Step 1: set first value via API (creates the field tuple)
453
+ await client.post<{ nodeId: string; message: string }>(
356
454
  `/nodes/${nodeId}/fields/${attributeId}/option`,
357
- { optionId, mode }
455
+ { optionId: ids[0], mode: "replace" }
358
456
  );
359
- return { success: !!result.nodeId };
457
+
458
+ if (ids.length === 1) return { success: true };
459
+
460
+ // Step 2: find the field tuple via getChildren
461
+ const { children } = await client.get<Children>(
462
+ `/nodes/${nodeId}/children?limit=100`
463
+ );
464
+ let tupleId: string | null = null;
465
+ for (const child of children) {
466
+ if (child.docType === "tuple") {
467
+ const sub = await client.get<Children>(
468
+ `/nodes/${child.id}/children?limit=20`
469
+ );
470
+ if (sub.children.some(s => s.id === attributeId)) {
471
+ tupleId = child.id;
472
+ break;
473
+ }
474
+ }
475
+ }
476
+ if (!tupleId) return { success: false };
477
+
478
+ // Step 3: import remaining values into the tuple
479
+ const refs = ids.slice(1).map(id => `- [[^${id}]]`).join("\n");
480
+ await client.post<ImportResult>(`/nodes/${tupleId}/import`, { content: refs });
481
+ return { success: true };
360
482
  },
361
483
 
362
484
  async setContent(
@@ -371,6 +493,74 @@ export function createTanaAPI(
371
493
  );
372
494
  return { success: !!result.nodeId };
373
495
  },
496
+
497
+ async getFieldOptions(
498
+ fieldId: string,
499
+ options?: { tagId?: string; workspaceId?: string; limit?: number }
500
+ ): Promise<{ id: string; name: string }[]> {
501
+ const result = await client.get<{ markdown: string }>(
502
+ `/nodes/${fieldId}?maxDepth=2`
503
+ );
504
+ const md = result.markdown;
505
+
506
+ // Pattern A: Predetermined options (field definition lists them)
507
+ if (md.includes("**Options**:")) {
508
+ return parseOptionsFromMarkdown(md);
509
+ }
510
+
511
+ // Pattern B: Instance of supertag
512
+ if (md.includes("Options from supertag")) {
513
+ const sourceTagMatch = md.match(/\(tana:([^)]+)\)/);
514
+ if (sourceTagMatch) {
515
+ const params: string[] = [];
516
+ addQueryParams(params, { hasType: sourceTagMatch[1] } as Record<string, unknown>, "query");
517
+ if (options?.workspaceId) {
518
+ params.push(`workspaceIds[0]=${encodeURIComponent(options.workspaceId)}`);
519
+ }
520
+ if (options?.limit) params.push(`limit=${options.limit}`);
521
+ const results = await client.get<SearchResult[]>(`/nodes/search?${params.join("&")}`);
522
+ return results.map(r => ({ id: r.id, name: r.name }));
523
+ }
524
+ }
525
+
526
+ // Pattern C: Source search node
527
+ const searchNodeMatch = md.match(/\*\*Options List\*\*:.*?\(tana:([^)]+)\)/);
528
+ if (searchNodeMatch) {
529
+ const searchResult = await client.get<{ markdown: string }>(
530
+ `/nodes/${searchNodeMatch[1]}?maxDepth=1`
531
+ );
532
+ return parseRefsFromMarkdown(searchResult.markdown);
533
+ }
534
+
535
+ // Pattern D: Ad-hoc — sample from existing nodes that have this field set
536
+ // Extract field name from definition for scoped parsing
537
+ const fieldNameMatch = md.match(/^- (.+?) #field-definition/m);
538
+ if (!fieldNameMatch) return [];
539
+
540
+ const fieldName = fieldNameMatch[1];
541
+ const limit = options?.limit ?? 10;
542
+ const params: string[] = [];
543
+ const query: Record<string, unknown> = options?.tagId
544
+ ? { and: [{ hasType: options.tagId }, { field: { fieldId, state: "set" } }] }
545
+ : { field: { fieldId, state: "set" } };
546
+ addQueryParams(params, query, "query");
547
+ if (options?.workspaceId) {
548
+ params.push(`workspaceIds[0]=${encodeURIComponent(options.workspaceId)}`);
549
+ }
550
+ params.push(`limit=${limit}`);
551
+
552
+ const searchResults = await client.get<SearchResult[]>(`/nodes/search?${params.join("&")}`);
553
+ const seen = new Map<string, string>();
554
+ for (const node of searchResults) {
555
+ const nodeMd = await client.get<{ markdown: string }>(
556
+ `/nodes/${node.id}?maxDepth=1`
557
+ );
558
+ for (const { id, name } of parseFieldValues(nodeMd.markdown, fieldName)) {
559
+ if (!seen.has(id)) seen.set(id, name);
560
+ }
561
+ }
562
+ return Array.from(seen, ([id, name]) => ({ id, name }));
563
+ },
374
564
  },
375
565
 
376
566
  calendar: {
@@ -389,9 +579,15 @@ export function createTanaAPI(
389
579
  },
390
580
 
391
581
  async import(parentNodeId: string, content: string): Promise<ImportResult> {
392
- return client.post<ImportResult>(`/nodes/${parentNodeId}/import`, {
393
- content,
394
- });
582
+ // API returns { createdNodes: [{id,name}], ... } — normalize to ImportResult
583
+ const raw = await client.post<{
584
+ createdNodes: { id: string; name: string }[];
585
+ message: string;
586
+ }>(`/nodes/${parentNodeId}/import`, { content });
587
+ return {
588
+ success: true,
589
+ nodeIds: raw.createdNodes.map(n => n.id),
590
+ };
395
591
  },
396
592
 
397
593
  format(data: unknown): string {
package/src/prompts.ts CHANGED
@@ -28,8 +28,9 @@ tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email
28
28
  tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
29
29
 
30
30
  ### Fields
31
- tana.fields.setOption(nodeId, attributeId, optionId, mode?) // mode: "replace" | "append" (default: "replace")
31
+ tana.fields.setOption(nodeId, attributeId, optionId) // optionId: string | string[] (array for multi-value)
32
32
  tana.fields.setContent(nodeId, attributeId, content, mode?) // content: string | null (null clears field), mode: "replace" | "append"
33
+ tana.fields.getFieldOptions(fieldId, options?) → { id, name }[] // discover available options for a field
33
34
 
34
35
  ### Calendar
35
36
  tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
@@ -110,6 +110,8 @@ function createTrackedTanaAPI(
110
110
  trackNodeId(nodeId);
111
111
  return track("fields.setContent", () => tana.fields.setContent(nodeId, attributeId, content));
112
112
  },
113
+ getFieldOptions: (...args) =>
114
+ track("fields.getFieldOptions", () => tana.fields.getFieldOptions(...args)),
113
115
  },
114
116
 
115
117
  calendar: {