tana-mcp-codemode 0.1.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 ADDED
@@ -0,0 +1,344 @@
1
+ # Tana MCP Server
2
+
3
+ A codemode MCP server for [Tana](https://tana.inc) knowledge management. AI writes TypeScript code that executes against the Tana Local API.
4
+
5
+ ## Features
6
+
7
+ - **Codemode Pattern** — AI writes executable TypeScript, not structured API calls
8
+ - **Full Tana API** — Workspaces, nodes, tags, fields, calendar, import
9
+ - **Top-level Await** — `await tana.workspaces.list()` works directly
10
+ - **Timeout Protection** — 10s max execution prevents infinite loops
11
+ - **Script History** — SQLite persistence for debugging and replay
12
+ - **Retry Logic** — Exponential backoff for transient failures
13
+ - **Debug UI** — WebSocket-based dashboard for testing scripts
14
+
15
+ ## Requirements
16
+
17
+ - [Bun](https://bun.sh) runtime
18
+ - [Tana Desktop](https://tana.inc) with Local API enabled
19
+ - API token from Tana (Settings → API → Generate Token)
20
+
21
+ ## Installation
22
+
23
+ ### Option 1: bun (recommended)
24
+
25
+ ```bash
26
+ # Requires Bun runtime
27
+ bun add -g tana-mcp-codemode
28
+ ```
29
+
30
+ ### Option 2: From source
31
+
32
+ ```bash
33
+ git clone https://github.com/fabiogaliano/tana-mcp-codemode
34
+ cd tana-mcp-codemode
35
+ bun install
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ | Variable | Default | Description |
41
+ | ------------------- | ----------------------- | --------------------------------------- |
42
+ | `TANA_API_TOKEN` | (required) | Bearer token from Tana Desktop |
43
+ | `TANA_API_URL` | `http://127.0.0.1:8262` | Tana Local API URL |
44
+ | `TANA_TIMEOUT` | `10000` | Request timeout in ms |
45
+ | `TANA_HISTORY_PATH` | (platform default) | Custom path for SQLite history database |
46
+
47
+ ## MCP Integration
48
+
49
+ Add to your Claude Desktop config (`claude_desktop_config.json`):
50
+
51
+ ### If installed globally via bun:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "tana": {
57
+ "command": "tana-mcp-codemode",
58
+ "env": {
59
+ "TANA_API_TOKEN": "your_token_here"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### If installed from source:
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "tana": {
72
+ "command": "bun",
73
+ "args": ["run", "/path/to/tana-mcp-codemode/src/index.ts"],
74
+ "env": {
75
+ "TANA_API_TOKEN": "your_token_here"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## API Reference
83
+
84
+ The server exposes a single `execute` tool. AI writes TypeScript code with access to the `tana` object:
85
+
86
+ ### Workspaces
87
+
88
+ ```typescript
89
+ const workspaces = await tana.workspaces.list();
90
+ // → Workspace[] (id, name, homeNodeId)
91
+ ```
92
+
93
+ ### Nodes
94
+
95
+ ```typescript
96
+ await tana.nodes.search(query, options?) // → SearchResult[]
97
+ await tana.nodes.read(nodeId, maxDepth?) // → string (markdown)
98
+ await tana.nodes.getChildren(nodeId, opts?) // → { children, total, hasMore }
99
+ await tana.nodes.edit({ nodeId, name?, description? })
100
+ await tana.nodes.trash(nodeId)
101
+ await tana.nodes.check(nodeId) // mark done
102
+ await tana.nodes.uncheck(nodeId) // mark undone
103
+ ```
104
+
105
+ ### Tags (Supertags)
106
+
107
+ ```typescript
108
+ await tana.tags.list(workspaceId, limit?)
109
+ await tana.tags.getSchema(tagId, includeEditInstructions?)
110
+ await tana.tags.modify(nodeId, action, tagIds) // action: "add" | "remove"
111
+ await tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
112
+ await tana.tags.addField({ tagId, name, dataType, ... })
113
+ await tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
114
+ ```
115
+
116
+ ### Fields
117
+
118
+ ```typescript
119
+ await tana.fields.setOption(nodeId, attributeId, optionId) // dropdown fields
120
+ await tana.fields.setContent(nodeId, attributeId, content) // text/date/url fields
121
+ ```
122
+
123
+ ### Calendar
124
+
125
+ ```typescript
126
+ await tana.calendar.getOrCreate(workspaceId, granularity, date?)
127
+ // granularity: "day" | "week" | "month" | "year"
128
+ ```
129
+
130
+ ### Import (Tana Paste)
131
+
132
+ ```typescript
133
+ await tana.import(parentNodeId, tanaPasteContent)
134
+ // → { success, nodeIds?, error? }
135
+ ```
136
+
137
+ ### Utility
138
+
139
+ ```typescript
140
+ await tana.health() // → { status: "ok" }
141
+ ```
142
+
143
+ ## Script Helpers
144
+
145
+ ### stdin() — Input Data
146
+
147
+ Pass data to scripts via the `input` parameter:
148
+
149
+ ```typescript
150
+ // Parse JSON input
151
+ const data = stdin().json<{ ids: string[] }>();
152
+
153
+ // Split into lines
154
+ const lines = stdin().lines();
155
+
156
+ // Raw text
157
+ const text = stdin().text();
158
+
159
+ // Check if input exists
160
+ if (stdin().hasInput()) {
161
+ // process input
162
+ }
163
+ ```
164
+
165
+ ### workflow — Progress Tracking
166
+
167
+ Track multi-step operations:
168
+
169
+ ```typescript
170
+ workflow.start("Processing nodes");
171
+ workflow.step("Fetching workspaces");
172
+ workflow.progress(5, 100, "Processing");
173
+ workflow.complete("Done!");
174
+ // Or: workflow.abort("Error message");
175
+ ```
176
+
177
+ ## Examples
178
+
179
+ ### Search for nodes
180
+
181
+ ```typescript
182
+ const results = await tana.nodes.search({
183
+ textContains: "meeting notes"
184
+ });
185
+ console.log("Found:", results.length, "nodes");
186
+ ```
187
+
188
+ ### Complex query
189
+
190
+ ```typescript
191
+ const tasks = await tana.nodes.search({
192
+ and: [
193
+ { hasType: "taskTagId" },
194
+ { is: "todo" },
195
+ { created: { last: 7 } }
196
+ ]
197
+ });
198
+ console.log({ tasks });
199
+ ```
200
+
201
+ ### Import content
202
+
203
+ ```typescript
204
+ await tana.import(parentNodeId, `
205
+ - Project Alpha #[[^projectTagId]]
206
+ - [[^statusFieldId]]:: Active
207
+ - [[^dueDateFieldId]]:: [[date:2024-03-15]]
208
+ `);
209
+ ```
210
+
211
+ ### Process input data
212
+
213
+ ```typescript
214
+ const { nodeIds } = stdin().json<{ nodeIds: string[] }>();
215
+ for (const id of nodeIds) {
216
+ const content = await tana.nodes.read(id);
217
+ console.log(content);
218
+ }
219
+ ```
220
+
221
+ ## Debug UI
222
+
223
+ A WebSocket-based dashboard for testing scripts:
224
+
225
+ ```bash
226
+ # Start debug server
227
+ bun run src/debug-server.ts
228
+
229
+ # Open http://localhost:3333
230
+ ```
231
+
232
+ Features:
233
+ - Real-time script execution
234
+ - Workflow event timeline
235
+ - Input data testing
236
+ - Error display with suggestions
237
+
238
+ ### Building the React UI (optional)
239
+
240
+ ```bash
241
+ cd ui
242
+ bun install
243
+ bun run build
244
+ cd ..
245
+ bun run src/debug-server.ts
246
+ ```
247
+
248
+ ## Architecture
249
+
250
+ ```
251
+ src/
252
+ ├── index.ts # MCP server entry
253
+ ├── prompts.ts # Tool description
254
+ ├── types.ts # TypeScript interfaces
255
+ ├── debug-server.ts # WebSocket debug UI
256
+ ├── api/
257
+ │ ├── client.ts # HTTP client (auth, retry, timeouts)
258
+ │ ├── tana.ts # API wrapper → `tana` object
259
+ │ └── types.ts # API type definitions
260
+ ├── sandbox/
261
+ │ ├── executor.ts # Code execution engine
262
+ │ ├── stdin.ts # Input data helper
263
+ │ └── workflow.ts # Progress tracking
264
+ ├── storage/
265
+ │ └── history.ts # SQLite script history
266
+ └── generated/
267
+ └── api.d.ts # Generated OpenAPI types
268
+
269
+ ui/ # React debug dashboard
270
+ ```
271
+
272
+ ### How It Works
273
+
274
+ 1. AI sends TypeScript code to the `execute` tool
275
+ 2. `sandbox.ts` creates an AsyncFunction for top-level await
276
+ 3. Code runs with injected `tana`, `console`, `stdin`, `workflow` objects
277
+ 4. 10s timeout via Promise.race prevents hangs
278
+ 5. `console.log()` output is captured and returned
279
+ 6. Script run is saved to SQLite history
280
+
281
+ ## Script History
282
+
283
+ Runs are persisted to SQLite:
284
+
285
+ | Platform | Location |
286
+ | -------- | --------------------------------------------------- |
287
+ | macOS | `~/Library/Application Support/tana-mcp/history.db` |
288
+ | Windows | `%APPDATA%/tana-mcp/history.db` |
289
+ | Linux | `~/.local/share/tana-mcp/history.db` |
290
+
291
+ Old runs (>30 days) are automatically cleaned up on startup.
292
+
293
+ ### Custom History Location
294
+
295
+ Set `TANA_HISTORY_PATH` to use a custom database path:
296
+
297
+ ```json
298
+ {
299
+ "mcpServers": {
300
+ "tana": {
301
+ "command": "tana-mcp-codemode",
302
+ "env": {
303
+ "TANA_API_TOKEN": "your_token_here",
304
+ "TANA_HISTORY_PATH": "/path/to/custom/tana-history.db"
305
+ }
306
+ }
307
+ }
308
+ }
309
+ ```
310
+
311
+ ### What Gets Saved
312
+
313
+ Each script run records:
314
+
315
+ | Field | Description |
316
+ |-------|-------------|
317
+ | `script` | The TypeScript code that was executed |
318
+ | `input` | Data passed via `stdin()` helper |
319
+ | `output` | Captured `console.log()` output |
320
+ | `error` | Error message if execution failed |
321
+ | `api_calls` | Which Tana API methods were called |
322
+ | `node_ids_affected` | Node IDs that were read/modified |
323
+ | `workspace_id` | Workspace used (if detected) |
324
+ | `duration_ms` | Execution time |
325
+
326
+ ## Development
327
+
328
+ ```bash
329
+ # Dev mode with watch
330
+ bun run dev
331
+
332
+ # Type check
333
+ bun run typecheck
334
+
335
+ # Regenerate API types from OpenAPI spec
336
+ bun run generate
337
+
338
+ # Run debug server
339
+ bun run debug
340
+ ```
341
+
342
+ ## License
343
+
344
+ MIT
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "tana-mcp-codemode",
3
+ "version": "0.1.0",
4
+ "description": "Codemode MCP server for Tana — AI writes TypeScript that executes against Tana Local API",
5
+ "type": "module",
6
+ "main": "src/index.ts",
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
+ "typecheck": "bunx tsc --noEmit",
17
+ "prepublishOnly": "bun run typecheck"
18
+ },
19
+ "files": [
20
+ "src/index.ts",
21
+ "src/prompts.ts",
22
+ "src/types.ts",
23
+ "src/api",
24
+ "src/sandbox",
25
+ "src/storage",
26
+ "src/generated",
27
+ "README.md"
28
+ ],
29
+ "keywords": [
30
+ "tana",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "ai",
34
+ "knowledge-management",
35
+ "typescript"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/fabiogaliano/tana-mcp-codemode"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.0.0"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.25.3",
46
+ "zod": "^3"
47
+ },
48
+ "devDependencies": {
49
+ "@types/bun": "^1.3.8",
50
+ "openapi-typescript": "^7.10.1",
51
+ "typescript": "^5.9.3"
52
+ },
53
+ "license": "MIT"
54
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Tana Local API HTTP Client
3
+ *
4
+ * Handles all HTTP communication with the Tana Desktop app's local API.
5
+ * Uses Bearer token authentication and AbortController for timeout handling.
6
+ */
7
+
8
+ export class TanaAPIError extends Error {
9
+ constructor(
10
+ message: string,
11
+ public statusCode?: number,
12
+ public response?: unknown,
13
+ public suggestion?: string
14
+ ) {
15
+ super(message);
16
+ this.name = "TanaAPIError";
17
+ }
18
+ }
19
+
20
+ const RETRY_CONFIG = {
21
+ maxRetries: 3,
22
+ baseDelayMs: 1000,
23
+ retryableCodes: new Set([408, 429, 500, 502, 503, 504]),
24
+ };
25
+
26
+ function isRetryableError(error: unknown): boolean {
27
+ if (error instanceof TanaAPIError && error.statusCode) {
28
+ return RETRY_CONFIG.retryableCodes.has(error.statusCode);
29
+ }
30
+ if (error instanceof Error) {
31
+ const msg = error.message.toLowerCase();
32
+ return (
33
+ msg.includes("econnrefused") ||
34
+ msg.includes("econnreset") ||
35
+ msg.includes("etimedout") ||
36
+ msg.includes("timeout") ||
37
+ msg.includes("network")
38
+ );
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function getFriendlyError(error: unknown): { message: string; suggestion: string } {
44
+ if (error instanceof TanaAPIError) {
45
+ switch (error.statusCode) {
46
+ case 401:
47
+ return {
48
+ message: "Authentication failed",
49
+ suggestion: "Check TANA_API_TOKEN - it may have expired. Generate a new token in Tana: Settings > API > Generate Token",
50
+ };
51
+ case 403:
52
+ return {
53
+ message: "Access forbidden",
54
+ suggestion: "Your API token may lack permissions for this operation. Try generating a new token.",
55
+ };
56
+ case 404:
57
+ return {
58
+ message: "Resource not found",
59
+ suggestion: "The node or workspace may have been deleted, or the ID is incorrect.",
60
+ };
61
+ case 429:
62
+ return {
63
+ message: "Rate limited",
64
+ suggestion: "Too many requests. Wait a moment and try again.",
65
+ };
66
+ case 500:
67
+ case 502:
68
+ case 503:
69
+ return {
70
+ message: "Tana server error",
71
+ suggestion: "Tana is experiencing issues. Try again in a few seconds.",
72
+ };
73
+ }
74
+ }
75
+
76
+ if (error instanceof Error) {
77
+ const msg = error.message.toLowerCase();
78
+ if (msg.includes("econnrefused")) {
79
+ return {
80
+ message: "Cannot connect to Tana",
81
+ suggestion: "Make sure Tana Desktop is running with Local API enabled. Check the port in Tana settings.",
82
+ };
83
+ }
84
+ if (msg.includes("timeout") || msg.includes("etimedout")) {
85
+ return {
86
+ message: "Request timed out",
87
+ suggestion: "Tana took too long to respond. Try again or increase TANA_TIMEOUT.",
88
+ };
89
+ }
90
+ }
91
+
92
+ return {
93
+ message: error instanceof Error ? error.message : "Unknown error",
94
+ suggestion: "Check if Tana Desktop is running and your API token is valid.",
95
+ };
96
+ }
97
+
98
+ function delay(ms: number): Promise<void> {
99
+ return new Promise((resolve) => setTimeout(resolve, ms));
100
+ }
101
+
102
+ export interface TanaClientConfig {
103
+ baseUrl: string;
104
+ token: string;
105
+ timeout: number;
106
+ }
107
+
108
+ export class TanaClient {
109
+ private baseUrl: string;
110
+ private token: string;
111
+ private timeout: number;
112
+
113
+ constructor(config: TanaClientConfig) {
114
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
115
+ this.token = config.token;
116
+ this.timeout = config.timeout;
117
+ }
118
+
119
+ private async request<T>(
120
+ method: string,
121
+ path: string,
122
+ body?: unknown
123
+ ): Promise<T> {
124
+ let lastError: unknown;
125
+
126
+ for (let attempt = 1; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
127
+ const controller = new AbortController();
128
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
129
+
130
+ try {
131
+ const url = `${this.baseUrl}${path}`;
132
+ const headers: Record<string, string> = {
133
+ Authorization: `Bearer ${this.token}`,
134
+ "Content-Type": "application/json",
135
+ };
136
+
137
+ const response = await fetch(url, {
138
+ method,
139
+ headers,
140
+ body: body ? JSON.stringify(body) : undefined,
141
+ signal: controller.signal,
142
+ });
143
+
144
+ if (!response.ok) {
145
+ let errorBody: unknown;
146
+ try {
147
+ errorBody = await response.json();
148
+ } catch {
149
+ errorBody = await response.text();
150
+ }
151
+ const error = new TanaAPIError(
152
+ `Tana API error: ${response.status} ${response.statusText}`,
153
+ response.status,
154
+ errorBody
155
+ );
156
+
157
+ if (isRetryableError(error) && attempt < RETRY_CONFIG.maxRetries) {
158
+ lastError = error;
159
+ const delayMs = RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt - 1);
160
+ console.error(`Retry ${attempt}/${RETRY_CONFIG.maxRetries} after ${delayMs}ms...`);
161
+ await delay(delayMs);
162
+ continue;
163
+ }
164
+ throw error;
165
+ }
166
+
167
+ const contentType = response.headers.get("content-type");
168
+ if (contentType?.includes("application/json")) {
169
+ return (await response.json()) as T;
170
+ }
171
+ return (await response.text()) as unknown as T;
172
+ } catch (error) {
173
+ clearTimeout(timeoutId);
174
+
175
+ if (error instanceof TanaAPIError && !isRetryableError(error)) {
176
+ const friendly = getFriendlyError(error);
177
+ throw new TanaAPIError(friendly.message, error.statusCode, error.response, friendly.suggestion);
178
+ }
179
+
180
+ if (error instanceof Error && error.name === "AbortError") {
181
+ const friendly = getFriendlyError(new Error("timeout"));
182
+ throw new TanaAPIError(friendly.message, undefined, undefined, friendly.suggestion);
183
+ }
184
+
185
+ if (isRetryableError(error) && attempt < RETRY_CONFIG.maxRetries) {
186
+ lastError = error;
187
+ const delayMs = RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt - 1);
188
+ console.error(`Retry ${attempt}/${RETRY_CONFIG.maxRetries} after ${delayMs}ms...`);
189
+ await delay(delayMs);
190
+ continue;
191
+ }
192
+
193
+ const friendly = getFriendlyError(error);
194
+ throw new TanaAPIError(friendly.message, undefined, undefined, friendly.suggestion);
195
+ } finally {
196
+ clearTimeout(timeoutId);
197
+ }
198
+ }
199
+
200
+ const friendly = getFriendlyError(lastError);
201
+ throw new TanaAPIError(
202
+ `Failed after ${RETRY_CONFIG.maxRetries} attempts: ${friendly.message}`,
203
+ undefined,
204
+ undefined,
205
+ friendly.suggestion
206
+ );
207
+ }
208
+
209
+ async get<T>(path: string): Promise<T> {
210
+ return this.request<T>("GET", path);
211
+ }
212
+
213
+ async post<T>(path: string, body?: unknown): Promise<T> {
214
+ return this.request<T>("POST", path, body);
215
+ }
216
+
217
+ async put<T>(path: string, body?: unknown): Promise<T> {
218
+ return this.request<T>("PUT", path, body);
219
+ }
220
+
221
+ async patch<T>(path: string, body?: unknown): Promise<T> {
222
+ return this.request<T>("PATCH", path, body);
223
+ }
224
+
225
+ async delete<T>(path: string, body?: unknown): Promise<T> {
226
+ return this.request<T>("DELETE", path, body);
227
+ }
228
+ }
229
+
230
+ export function createClient(): TanaClient {
231
+ const baseUrl = process.env.TANA_API_URL || "http://127.0.0.1:8262";
232
+ const token = process.env.TANA_API_TOKEN;
233
+ const timeout = parseInt(process.env.TANA_TIMEOUT || "10000", 10);
234
+
235
+ if (!token) {
236
+ throw new Error(
237
+ "TANA_API_TOKEN environment variable is required. " +
238
+ "Get your token from Tana Desktop: Settings > API > Generate Token"
239
+ );
240
+ }
241
+
242
+ return new TanaClient({ baseUrl, token, timeout });
243
+ }