tana-mcp-codemode 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,33 +1,15 @@
1
1
  {
2
2
  "name": "tana-mcp-codemode",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Codemode MCP server for Tana — AI writes TypeScript that executes against Tana Local API",
5
5
  "type": "module",
6
- "main": "src/index.ts",
6
+ "main": "dist/entry-node.js",
7
7
  "bin": {
8
- "tana-mcp-codemode": "src/index.ts"
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"
8
+ "tana-mcp-codemode": "dist/entry-node.js"
22
9
  },
23
10
  "files": [
24
- "src/index.ts",
25
- "src/prompts.ts",
26
- "src/types.ts",
27
- "src/api",
28
- "src/sandbox",
29
- "src/storage",
30
- "src/generated",
11
+ "dist/",
12
+ "src/",
31
13
  "README.md"
32
14
  ],
33
15
  "keywords": [
@@ -43,16 +25,36 @@
43
25
  "url": "git+https://github.com/fabiogaliano/tana-mcp-codemode.git"
44
26
  },
45
27
  "engines": {
46
- "bun": ">=1.0.0"
28
+ "bun": ">=1.0.0",
29
+ "node": ">=20"
47
30
  },
48
31
  "dependencies": {
49
32
  "@modelcontextprotocol/sdk": "^1.25.3",
50
33
  "zod": "^3"
51
34
  },
35
+ "optionalDependencies": {
36
+ "better-sqlite3": "^11",
37
+ "esbuild": "^0.24"
38
+ },
52
39
  "devDependencies": {
53
40
  "@types/bun": "^1.3.8",
54
41
  "openapi-typescript": "^7.10.1",
42
+ "tsup": "^8",
55
43
  "typescript": "^5.9.3"
56
44
  },
57
- "license": "MIT"
58
- }
45
+ "license": "MIT",
46
+ "scripts": {
47
+ "generate": "bun run scripts/generate.ts",
48
+ "list-endpoints": "bun run scripts/list-endpoints.ts",
49
+ "start": "bun run --env-file=.env src/entry-bun.ts",
50
+ "dev": "bun run --env-file=.env --watch src/entry-bun.ts",
51
+ "debug": "bun run --env-file=.env src/debug-server.ts",
52
+ "build": "tsup",
53
+ "build:binary": "bun build --compile src/entry-bun.ts --outfile dist/tana-mcp-codemode",
54
+ "test": "bun test",
55
+ "typecheck": "bunx tsc --noEmit",
56
+ "release:patch": "npm version patch && npm publish",
57
+ "release:minor": "npm version minor && npm publish",
58
+ "release:major": "npm version major && npm publish"
59
+ }
60
+ }
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 {
@@ -0,0 +1,39 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { CompatDatabase, CompatOps, CompatStatement } from "./types";
3
+
4
+ function wrapStatement(stmt: ReturnType<Database["prepare"]>): CompatStatement {
5
+ return {
6
+ run(...params: unknown[]) {
7
+ const result = stmt.run(...(params as Parameters<typeof stmt.run>));
8
+ return { changes: result.changes };
9
+ },
10
+ all(...params: unknown[]) {
11
+ return stmt.all(...(params as Parameters<typeof stmt.all>)) as unknown[];
12
+ },
13
+ };
14
+ }
15
+
16
+ function wrapDatabase(db: Database): CompatDatabase {
17
+ return {
18
+ exec(sql: string) {
19
+ db.run(sql);
20
+ },
21
+ prepare(sql: string) {
22
+ return wrapStatement(db.prepare(sql));
23
+ },
24
+ close() {
25
+ db.close();
26
+ },
27
+ };
28
+ }
29
+
30
+ export const bunCompat: CompatOps = {
31
+ createDatabase(path: string): CompatDatabase {
32
+ return wrapDatabase(new Database(path, { create: true }));
33
+ },
34
+
35
+ transpileTS(code: string): string {
36
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
37
+ return transpiler.transformSync(code);
38
+ },
39
+ };
@@ -0,0 +1,29 @@
1
+ import type { CompatDatabase, CompatOps } from "./types";
2
+
3
+ export type { CompatDatabase, CompatStatement, CompatOps } from "./types";
4
+
5
+ let ops: CompatOps | null = null;
6
+
7
+ export function initCompat(compatOps: CompatOps): void {
8
+ if (ops) {
9
+ console.warn("initCompat called multiple times — overwriting previous compat ops");
10
+ }
11
+ ops = compatOps;
12
+ }
13
+
14
+ function getOps(): CompatOps {
15
+ if (!ops) {
16
+ throw new Error(
17
+ "compat not initialized — call initCompat() before using createDatabase or transpileTS"
18
+ );
19
+ }
20
+ return ops;
21
+ }
22
+
23
+ export function createDatabase(path: string): CompatDatabase {
24
+ return getOps().createDatabase(path);
25
+ }
26
+
27
+ export function transpileTS(code: string): string {
28
+ return getOps().transpileTS(code);
29
+ }
@@ -0,0 +1,62 @@
1
+ import { createRequire } from "node:module";
2
+ import type { CompatDatabase, CompatOps, CompatStatement } from "./types";
3
+
4
+ const require = createRequire(import.meta.url);
5
+
6
+ function getBetterSqlite3(): any {
7
+ try {
8
+ return require("better-sqlite3");
9
+ } catch {
10
+ throw new Error(
11
+ "better-sqlite3 is required for Node.js. Install it: npm install better-sqlite3"
12
+ );
13
+ }
14
+ }
15
+
16
+ function getEsbuild(): any {
17
+ try {
18
+ return require("esbuild");
19
+ } catch {
20
+ throw new Error(
21
+ "esbuild is required for Node.js. Install it: npm install esbuild"
22
+ );
23
+ }
24
+ }
25
+
26
+ function wrapStatement(stmt: any): CompatStatement {
27
+ return {
28
+ run(...params: unknown[]) {
29
+ const result = stmt.run(...params);
30
+ return { changes: result.changes };
31
+ },
32
+ all(...params: unknown[]) {
33
+ return stmt.all(...params) as unknown[];
34
+ },
35
+ };
36
+ }
37
+
38
+ function wrapDatabase(db: any): CompatDatabase {
39
+ return {
40
+ exec(sql: string) {
41
+ db.exec(sql);
42
+ },
43
+ prepare(sql: string) {
44
+ return wrapStatement(db.prepare(sql));
45
+ },
46
+ close() {
47
+ db.close();
48
+ },
49
+ };
50
+ }
51
+
52
+ export const nodeCompat: CompatOps = {
53
+ createDatabase(path: string): CompatDatabase {
54
+ const BetterSqlite3 = getBetterSqlite3();
55
+ return wrapDatabase(new BetterSqlite3(path));
56
+ },
57
+
58
+ transpileTS(code: string): string {
59
+ const esbuild = getEsbuild();
60
+ return esbuild.transformSync(code, { loader: "ts" }).code;
61
+ },
62
+ };
@@ -0,0 +1,15 @@
1
+ export interface CompatStatement {
2
+ run(...params: unknown[]): { changes: number };
3
+ all(...params: unknown[]): unknown[];
4
+ }
5
+
6
+ export interface CompatDatabase {
7
+ exec(sql: string): void;
8
+ prepare(sql: string): CompatStatement;
9
+ close(): void;
10
+ }
11
+
12
+ export interface CompatOps {
13
+ createDatabase(path: string): CompatDatabase;
14
+ transpileTS(code: string): string;
15
+ }