tana-mcp-codemode 0.2.0 → 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,12 +1,25 @@
1
1
  {
2
2
  "name": "tana-mcp-codemode",
3
- "version": "0.2.0",
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",
7
7
  "bin": {
8
8
  "tana-mcp-codemode": "src/index.ts"
9
9
  },
10
+ "scripts": {
11
+ "generate": "bun run scripts/generate.ts",
12
+ "list-endpoints": "bun run scripts/list-endpoints.ts",
13
+ "start": "bun run --env-file=.env src/index.ts",
14
+ "dev": "bun run --env-file=.env --watch src/index.ts",
15
+ "debug": "bun run --env-file=.env src/debug-server.ts",
16
+ "test": "bun test",
17
+ "typecheck": "bunx tsc --noEmit",
18
+ "prepublishOnly": "bun run typecheck",
19
+ "release:patch": "npm version patch && npm publish",
20
+ "release:minor": "npm version minor && npm publish",
21
+ "release:major": "npm version major && npm publish"
22
+ },
10
23
  "files": [
11
24
  "src/index.ts",
12
25
  "src/prompts.ts",
@@ -41,14 +54,5 @@
41
54
  "openapi-typescript": "^7.10.1",
42
55
  "typescript": "^5.9.3"
43
56
  },
44
- "license": "MIT",
45
- "scripts": {
46
- "generate": "bun run scripts/generate.ts",
47
- "list-endpoints": "bun run scripts/list-endpoints.ts",
48
- "start": "bun run --env-file=.env src/index.ts",
49
- "dev": "bun run --env-file=.env --watch src/index.ts",
50
- "debug": "bun run --env-file=.env src/debug-server.ts",
51
- "test": "bun test",
52
- "typecheck": "bunx tsc --noEmit"
53
- }
54
- }
57
+ "license": "MIT"
58
+ }
package/src/api/tana.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  SearchResult,
17
17
  Children,
18
18
  EditNodeOptions,
19
+ MoveNodeOptions,
19
20
  Tag,
20
21
  CreateTagOptions,
21
22
  AddFieldOptions,
@@ -24,6 +25,115 @@ import type {
24
25
  } from "./types";
25
26
  import { format } from "./format";
26
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
+
27
137
  /**
28
138
  * TanaAPI Interface
29
139
  *
@@ -54,6 +164,10 @@ export interface TanaAPI {
54
164
  ): Promise<Children>;
55
165
  /** Edit a node's name/description */
56
166
  edit(options: EditNodeOptions): Promise<{ success: boolean }>;
167
+ /** Move node to a new parent */
168
+ move(options: MoveNodeOptions): Promise<{ success: boolean }>;
169
+ /** Open a node in the Tana UI */
170
+ open(nodeId: string, openType?: "current" | "panel" | "tab"): Promise<{ success: boolean }>;
57
171
  /** Move node to trash */
58
172
  trash(nodeId: string): Promise<{ success: boolean }>;
59
173
  /** Check a node's checkbox */
@@ -82,18 +196,24 @@ export interface TanaAPI {
82
196
  };
83
197
 
84
198
  fields: {
85
- /** Set a field to an option value */
199
+ /** Set a field to option value(s). Pass string[] for multi-value fields. */
86
200
  setOption(
87
201
  nodeId: string,
88
202
  attributeId: string,
89
- optionId: string
203
+ optionId: string | string[]
90
204
  ): Promise<{ success: boolean }>;
91
- /** Set a field to a string value */
205
+ /** Set a field to a string value, or null to clear it */
92
206
  setContent(
93
207
  nodeId: string,
94
208
  attributeId: string,
95
- content: string
209
+ content: string | null,
210
+ mode?: "replace" | "append"
96
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 }[]>;
97
217
  };
98
218
 
99
219
  calendar: {
@@ -135,30 +255,8 @@ export function createTanaAPI(
135
255
  query: SearchQuery,
136
256
  options?: SearchOptions
137
257
  ): Promise<SearchResult[]> {
138
- // Build deep object query params (style: deepObject, explode: true)
139
258
  const params: string[] = [];
140
-
141
- function addQueryParams(obj: Record<string, unknown>, prefix: string) {
142
- for (const [key, value] of Object.entries(obj)) {
143
- const paramKey = prefix ? `${prefix}[${key}]` : key;
144
- if (value === null || value === undefined) continue;
145
- if (typeof value === "object" && !Array.isArray(value)) {
146
- addQueryParams(value as Record<string, unknown>, paramKey);
147
- } else if (Array.isArray(value)) {
148
- value.forEach((item, i) => {
149
- if (typeof item === "object") {
150
- addQueryParams(item as Record<string, unknown>, `${paramKey}[${i}]`);
151
- } else {
152
- params.push(`${encodeURIComponent(`${paramKey}[${i}]`)}=${encodeURIComponent(String(item))}`);
153
- }
154
- });
155
- } else {
156
- params.push(`${encodeURIComponent(paramKey)}=${encodeURIComponent(String(value))}`);
157
- }
158
- }
159
- }
160
-
161
- addQueryParams(query as unknown as Record<string, unknown>, "query");
259
+ addQueryParams(params, query as unknown as Record<string, unknown>, "query");
162
260
  if (options?.limit) params.push(`limit=${options.limit}`);
163
261
  const effectiveWorkspaceIds = options?.workspaceIds
164
262
  ?? (defaultSearchWorkspaceIds?.length ? defaultSearchWorkspaceIds : undefined);
@@ -202,6 +300,31 @@ export function createTanaAPI(
202
300
  return { success: !!result.nodeId };
203
301
  },
204
302
 
303
+ async move(options: MoveNodeOptions): Promise<{ success: boolean }> {
304
+ const result = await client.post<{ nodeId: string; message: string }>(
305
+ `/nodes/${options.nodeId}/move`,
306
+ {
307
+ targetNodeId: options.targetNodeId,
308
+ keepSourceReference: options.keepSourceReference,
309
+ position: options.position,
310
+ referenceNodeId: options.referenceNodeId,
311
+ sourceParentId: options.sourceParentId,
312
+ }
313
+ );
314
+ return { success: !!result.nodeId };
315
+ },
316
+
317
+ async open(
318
+ nodeId: string,
319
+ openType: "current" | "panel" | "tab" = "current"
320
+ ): Promise<{ success: boolean }> {
321
+ const result = await client.post<{ nodeId: string; message: string }>(
322
+ `/nodes/${nodeId}/open`,
323
+ { openType }
324
+ );
325
+ return { success: !!result.nodeId };
326
+ },
327
+
205
328
  async trash(nodeId: string): Promise<{ success: boolean }> {
206
329
  const result = await client.post<{ nodeId: string; message: string }>(
207
330
  `/nodes/${nodeId}/trash`,
@@ -314,29 +437,130 @@ export function createTanaAPI(
314
437
  },
315
438
 
316
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
317
443
  async setOption(
318
444
  nodeId: string,
319
445
  attributeId: string,
320
- optionId: string
446
+ optionId: string | string[],
447
+ _mode?: "replace" | "append"
321
448
  ): Promise<{ success: boolean }> {
322
- 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 }>(
323
454
  `/nodes/${nodeId}/fields/${attributeId}/option`,
324
- { optionId }
455
+ { optionId: ids[0], mode: "replace" }
325
456
  );
326
- 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 };
327
482
  },
328
483
 
329
484
  async setContent(
330
485
  nodeId: string,
331
486
  attributeId: string,
332
- content: string
487
+ content: string | null,
488
+ mode?: "replace" | "append"
333
489
  ): Promise<{ success: boolean }> {
334
490
  const result = await client.post<{ nodeId: string; message: string }>(
335
491
  `/nodes/${nodeId}/fields/${attributeId}/content`,
336
- { content }
492
+ { content, mode }
337
493
  );
338
494
  return { success: !!result.nodeId };
339
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
+ },
340
564
  },
341
565
 
342
566
  calendar: {
@@ -355,9 +579,15 @@ export function createTanaAPI(
355
579
  },
356
580
 
357
581
  async import(parentNodeId: string, content: string): Promise<ImportResult> {
358
- return client.post<ImportResult>(`/nodes/${parentNodeId}/import`, {
359
- content,
360
- });
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
+ };
361
591
  },
362
592
 
363
593
  format(data: unknown): string {
package/src/api/types.ts CHANGED
@@ -161,6 +161,16 @@ export interface SetCheckboxOptions {
161
161
  };
162
162
  }
163
163
 
164
+ /** Options for moving a node to a new parent */
165
+ export interface MoveNodeOptions {
166
+ nodeId: string;
167
+ targetNodeId: string;
168
+ keepSourceReference?: boolean;
169
+ position?: "start" | "end" | "after" | "before";
170
+ referenceNodeId?: string;
171
+ sourceParentId?: string;
172
+ }
173
+
164
174
  /** Options for editing a node */
165
175
  export interface EditNodeOptions {
166
176
  nodeId: string;
package/src/index.ts CHANGED
File without changes
package/src/prompts.ts CHANGED
@@ -14,6 +14,8 @@ tana.nodes.search(query, options?) → SearchResult[]
14
14
  tana.nodes.read(nodeId, maxDepth?) → string (markdown)
15
15
  tana.nodes.getChildren(nodeId, { limit?, offset? }) → { children, total, hasMore }
16
16
  tana.nodes.edit({ nodeId, name?, description? }) → { success }
17
+ tana.nodes.move({ nodeId, targetNodeId, keepSourceReference?, position?, referenceNodeId?, sourceParentId? }) → { success }
18
+ tana.nodes.open(nodeId, openType?) → { success } // openType: "current" | "panel" | "tab" (default: "current")
17
19
  tana.nodes.trash(nodeId) → { success }
18
20
  tana.nodes.check(nodeId) / uncheck(nodeId) → { success }
19
21
 
@@ -26,8 +28,9 @@ tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email
26
28
  tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
27
29
 
28
30
  ### Fields
29
- tana.fields.setOption(nodeId, attributeId, optionId)
30
- tana.fields.setContent(nodeId, attributeId, content)
31
+ tana.fields.setOption(nodeId, attributeId, optionId) // optionId: string | string[] (array for multi-value)
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
31
34
 
32
35
  ### Calendar
33
36
  tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
@@ -58,6 +58,14 @@ function createTrackedTanaAPI(
58
58
  trackNodeId(options.nodeId);
59
59
  return track("nodes.edit", () => tana.nodes.edit(options));
60
60
  },
61
+ move: (options) => {
62
+ trackNodeId(options.nodeId);
63
+ return track("nodes.move", () => tana.nodes.move(options));
64
+ },
65
+ open: (nodeId, openType) => {
66
+ trackNodeId(nodeId);
67
+ return track("nodes.open", () => tana.nodes.open(nodeId, openType));
68
+ },
61
69
  trash: (nodeId) => {
62
70
  trackNodeId(nodeId);
63
71
  return track("nodes.trash", () => tana.nodes.trash(nodeId));
@@ -102,6 +110,8 @@ function createTrackedTanaAPI(
102
110
  trackNodeId(nodeId);
103
111
  return track("fields.setContent", () => tana.fields.setContent(nodeId, attributeId, content));
104
112
  },
113
+ getFieldOptions: (...args) =>
114
+ track("fields.getFieldOptions", () => tana.fields.getFieldOptions(...args)),
105
115
  },
106
116
 
107
117
  calendar: {