tana-mcp-codemode 0.1.0 → 0.2.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/LICENSE.MD +9 -0
- package/README.md +123 -151
- package/package.json +13 -13
- package/src/api/client.ts +3 -3
- package/src/api/format.ts +125 -0
- package/src/api/tana.ts +84 -13
- package/src/api/types.ts +0 -20
- package/src/index.ts +92 -13
- package/src/prompts.ts +28 -14
- package/src/sandbox/executor.ts +13 -16
- package/src/sandbox/stdin.ts +0 -69
package/LICENSE.MD
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fábio galiano (fabiogaliano.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A codemode MCP server for [Tana](https://tana.inc) knowledge management. AI writ
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Codemode Pattern** — AI writes executable TypeScript, not structured API calls
|
|
8
|
-
- **
|
|
8
|
+
- **Tana Local API** — Workspaces, nodes, tags, fields, calendar, import
|
|
9
9
|
- **Top-level Await** — `await tana.workspaces.list()` works directly
|
|
10
10
|
- **Timeout Protection** — 10s max execution prevents infinite loops
|
|
11
11
|
- **Script History** — SQLite persistence for debugging and replay
|
|
@@ -37,18 +37,18 @@ bun install
|
|
|
37
37
|
|
|
38
38
|
## Configuration
|
|
39
39
|
|
|
40
|
-
| Variable
|
|
41
|
-
|
|
|
42
|
-
| `TANA_API_TOKEN`
|
|
43
|
-
| `TANA_API_URL`
|
|
44
|
-
| `TANA_TIMEOUT`
|
|
45
|
-
| `TANA_HISTORY_PATH`
|
|
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
|
+
| `MAIN_TANA_WORKSPACE` | (none) | Default workspace name or ID, resolved at startup |
|
|
47
|
+
| `TANA_SEARCH_WORKSPACES`| (none) | Comma-separated workspace names or IDs for default search scoping |
|
|
46
48
|
|
|
47
49
|
## MCP Integration
|
|
48
50
|
|
|
49
|
-
Add to your
|
|
50
|
-
|
|
51
|
-
### If installed globally via bun:
|
|
51
|
+
Add to your MCP client's configuration:
|
|
52
52
|
|
|
53
53
|
```json
|
|
54
54
|
{
|
|
@@ -56,14 +56,25 @@ Add to your Claude Desktop config (`claude_desktop_config.json`):
|
|
|
56
56
|
"tana": {
|
|
57
57
|
"command": "tana-mcp-codemode",
|
|
58
58
|
"env": {
|
|
59
|
-
"TANA_API_TOKEN": "your_token_here"
|
|
59
|
+
"TANA_API_TOKEN": "your_token_here",
|
|
60
|
+
// Optional
|
|
61
|
+
"MAIN_TANA_WORKSPACE": "My Workspace",
|
|
62
|
+
"TANA_SEARCH_WORKSPACES": "My Workspace",
|
|
63
|
+
// Optional: customize where the SQLite history database is stored
|
|
64
|
+
"TANA_HISTORY_PATH": "/path/to/history.db"
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
```
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
| Client | Config Location |
|
|
72
|
+
| ----------------- | ----------------------------------------------------------- |
|
|
73
|
+
| Claude Desktop | `claude_desktop_config.json` |
|
|
74
|
+
| Claude Code | `.mcp.json` (project) or `~/.claude/settings.json` (global) |
|
|
75
|
+
| Cursor / Windsurf | IDE MCP settings |
|
|
76
|
+
|
|
77
|
+
**If installed from source**, use `bun` as the command:
|
|
67
78
|
|
|
68
79
|
```json
|
|
69
80
|
{
|
|
@@ -72,108 +83,18 @@ Add to your Claude Desktop config (`claude_desktop_config.json`):
|
|
|
72
83
|
"command": "bun",
|
|
73
84
|
"args": ["run", "/path/to/tana-mcp-codemode/src/index.ts"],
|
|
74
85
|
"env": {
|
|
75
|
-
"TANA_API_TOKEN": "your_token_here"
|
|
86
|
+
"TANA_API_TOKEN": "your_token_here",
|
|
87
|
+
// Optional
|
|
88
|
+
"MAIN_TANA_WORKSPACE": "My Workspace",
|
|
89
|
+
"TANA_SEARCH_WORKSPACES": "My Workspace",
|
|
90
|
+
// Optional: customize where the SQLite history database is stored
|
|
91
|
+
"TANA_HISTORY_PATH": "/path/to/history.db"
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
}
|
|
79
95
|
}
|
|
80
96
|
```
|
|
81
97
|
|
|
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
98
|
## Examples
|
|
178
99
|
|
|
179
100
|
### Search for nodes
|
|
@@ -208,16 +129,6 @@ await tana.import(parentNodeId, `
|
|
|
208
129
|
`);
|
|
209
130
|
```
|
|
210
131
|
|
|
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
132
|
## Debug UI
|
|
222
133
|
|
|
223
134
|
A WebSocket-based dashboard for testing scripts:
|
|
@@ -229,11 +140,29 @@ bun run src/debug-server.ts
|
|
|
229
140
|
# Open http://localhost:3333
|
|
230
141
|
```
|
|
231
142
|
|
|
232
|
-
|
|
143
|
+
**Routes:**
|
|
144
|
+
- `http://localhost:3333/#debug` — Script execution console
|
|
145
|
+
- `http://localhost:3333/#benchmark` — Codemode vs tana-local performance comparison
|
|
146
|
+
|
|
147
|
+
**Features:**
|
|
233
148
|
- Real-time script execution
|
|
234
149
|
- Workflow event timeline
|
|
235
|
-
- Input data testing
|
|
236
150
|
- Error display with suggestions
|
|
151
|
+
- Benchmark results visualization (cost, speed, token usage)
|
|
152
|
+
|
|
153
|
+
### workflow Helper (Debug UI only)
|
|
154
|
+
|
|
155
|
+
Track multi-step operations with a timeline view:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
workflow.start("Processing nodes");
|
|
159
|
+
workflow.step("Fetching workspaces");
|
|
160
|
+
workflow.progress(5, 100, "Processing");
|
|
161
|
+
workflow.complete("Done!");
|
|
162
|
+
// Or: workflow.abort("Error message");
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
> **Note**: `workflow` is only available in the Debug UI. It's not exposed in the production MCP prompt.
|
|
237
166
|
|
|
238
167
|
### Building the React UI (optional)
|
|
239
168
|
|
|
@@ -259,7 +188,6 @@ src/
|
|
|
259
188
|
│ └── types.ts # API type definitions
|
|
260
189
|
├── sandbox/
|
|
261
190
|
│ ├── executor.ts # Code execution engine
|
|
262
|
-
│ ├── stdin.ts # Input data helper
|
|
263
191
|
│ └── workflow.ts # Progress tracking
|
|
264
192
|
├── storage/
|
|
265
193
|
│ └── history.ts # SQLite script history
|
|
@@ -272,8 +200,8 @@ ui/ # React debug dashboard
|
|
|
272
200
|
### How It Works
|
|
273
201
|
|
|
274
202
|
1. AI sends TypeScript code to the `execute` tool
|
|
275
|
-
2. `
|
|
276
|
-
3. Code runs with injected `tana
|
|
203
|
+
2. `executor.ts` creates an AsyncFunction for top-level await
|
|
204
|
+
3. Code runs with injected `tana` and `console` objects
|
|
277
205
|
4. 10s timeout via Promise.race prevents hangs
|
|
278
206
|
5. `console.log()` output is captured and returned
|
|
279
207
|
6. Script run is saved to SQLite history
|
|
@@ -290,38 +218,19 @@ Runs are persisted to SQLite:
|
|
|
290
218
|
|
|
291
219
|
Old runs (>30 days) are automatically cleaned up on startup.
|
|
292
220
|
|
|
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
221
|
### What Gets Saved
|
|
312
222
|
|
|
313
223
|
Each script run records:
|
|
314
224
|
|
|
315
|
-
| Field
|
|
316
|
-
|
|
317
|
-
| `script`
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
322
|
-
| `
|
|
323
|
-
| `
|
|
324
|
-
| `duration_ms` | Execution time |
|
|
225
|
+
| Field | Description |
|
|
226
|
+
| ------------------- | ------------------------------------- |
|
|
227
|
+
| `script` | The TypeScript code that was executed |
|
|
228
|
+
| `output` | Captured `console.log()` output |
|
|
229
|
+
| `error` | Error message if execution failed |
|
|
230
|
+
| `api_calls` | Which Tana API methods were called |
|
|
231
|
+
| `node_ids_affected` | Node IDs that were read/modified |
|
|
232
|
+
| `workspace_id` | Workspace used (if detected) |
|
|
233
|
+
| `duration_ms` | Execution time |
|
|
325
234
|
|
|
326
235
|
## Development
|
|
327
236
|
|
|
@@ -339,6 +248,69 @@ bun run generate
|
|
|
339
248
|
bun run debug
|
|
340
249
|
```
|
|
341
250
|
|
|
251
|
+
## API Reference
|
|
252
|
+
|
|
253
|
+
The server exposes a single `execute` tool. AI writes TypeScript code with access to the `tana` object:
|
|
254
|
+
|
|
255
|
+
### Workspaces
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const workspaces = await tana.workspaces.list();
|
|
259
|
+
// → Workspace[] (id, name, homeNodeId)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Nodes
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
await tana.nodes.search(query, options?) // → SearchResult[]
|
|
266
|
+
await tana.nodes.read(nodeId, maxDepth?) // → string (markdown)
|
|
267
|
+
await tana.nodes.getChildren(nodeId, opts?) // → { children, total, hasMore }
|
|
268
|
+
await tana.nodes.edit({ nodeId, name?, description? })
|
|
269
|
+
await tana.nodes.trash(nodeId)
|
|
270
|
+
await tana.nodes.check(nodeId) // mark done
|
|
271
|
+
await tana.nodes.uncheck(nodeId) // mark undone
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Tags (Supertags)
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
await tana.tags.listAll(workspaceId) // All workspace supertags (paginated)
|
|
278
|
+
await tana.tags.getSchema(tagId, opts?) // opts: { includeInheritedFields? }
|
|
279
|
+
await tana.tags.modify(nodeId, action, tagIds) // action: "add" | "remove"
|
|
280
|
+
await tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
|
|
281
|
+
await tana.tags.addField({ tagId, name, dataType, ... })
|
|
282
|
+
await tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Fields
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
await tana.fields.setOption(nodeId, attributeId, optionId) // dropdown fields
|
|
289
|
+
await tana.fields.setContent(nodeId, attributeId, content) // text/date/url fields
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Calendar
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
await tana.calendar.getOrCreate(workspaceId, granularity, date?)
|
|
296
|
+
// granularity: "day" | "week" | "month" | "year"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Import (Tana Paste)
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
await tana.import(parentNodeId, tanaPasteContent)
|
|
303
|
+
// → { success, nodeIds?, error? }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Utility
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
tana.workspace // Pre-resolved default workspace (from MAIN_TANA_WORKSPACE) or null
|
|
310
|
+
tana.format(data) // Compact display of any API response
|
|
311
|
+
await tana.health() // → { status: "ok" }
|
|
312
|
+
```
|
|
313
|
+
|
|
342
314
|
## License
|
|
343
315
|
|
|
344
316
|
MIT
|
package/package.json
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tana-mcp-codemode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
-
"typecheck": "bunx tsc --noEmit",
|
|
17
|
-
"prepublishOnly": "bun run typecheck"
|
|
18
|
-
},
|
|
19
10
|
"files": [
|
|
20
11
|
"src/index.ts",
|
|
21
12
|
"src/prompts.ts",
|
|
@@ -36,7 +27,7 @@
|
|
|
36
27
|
],
|
|
37
28
|
"repository": {
|
|
38
29
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/fabiogaliano/tana-mcp-codemode"
|
|
30
|
+
"url": "git+https://github.com/fabiogaliano/tana-mcp-codemode.git"
|
|
40
31
|
},
|
|
41
32
|
"engines": {
|
|
42
33
|
"bun": ">=1.0.0"
|
|
@@ -50,5 +41,14 @@
|
|
|
50
41
|
"openapi-typescript": "^7.10.1",
|
|
51
42
|
"typescript": "^5.9.3"
|
|
52
43
|
},
|
|
53
|
-
"license": "MIT"
|
|
54
|
-
|
|
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
|
+
}
|
package/src/api/client.ts
CHANGED
|
@@ -18,8 +18,8 @@ export class TanaAPIError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const RETRY_CONFIG = {
|
|
21
|
-
maxRetries:
|
|
22
|
-
baseDelayMs:
|
|
21
|
+
maxRetries: 2,
|
|
22
|
+
baseDelayMs: 500,
|
|
23
23
|
retryableCodes: new Set([408, 429, 500, 502, 503, 504]),
|
|
24
24
|
};
|
|
25
25
|
|
|
@@ -230,7 +230,7 @@ export class TanaClient {
|
|
|
230
230
|
export function createClient(): TanaClient {
|
|
231
231
|
const baseUrl = process.env.TANA_API_URL || "http://127.0.0.1:8262";
|
|
232
232
|
const token = process.env.TANA_API_TOKEN;
|
|
233
|
-
const timeout = parseInt(process.env.TANA_TIMEOUT || "
|
|
233
|
+
const timeout = parseInt(process.env.TANA_TIMEOUT || "3000", 10);
|
|
234
234
|
|
|
235
235
|
if (!token) {
|
|
236
236
|
throw new Error(
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact Formatter for Tana API Responses
|
|
3
|
+
*
|
|
4
|
+
* Pure function — detects response shape and returns a compact string representation.
|
|
5
|
+
* Designed to reduce token waste when AI logs API results via console.log(tana.format(data)).
|
|
6
|
+
*
|
|
7
|
+
* Shape detection uses discriminating fields from the OpenAPI spec:
|
|
8
|
+
* - SearchResult[]: has `breadcrumb`
|
|
9
|
+
* - Children: has `children` + `total` + `hasMore`
|
|
10
|
+
* - ChildNode[]: has `childCount`
|
|
11
|
+
* - Tag[]: has `id` + `name`, no `breadcrumb`/`childCount`/`homeNodeId`
|
|
12
|
+
* - Workspace[]: has `homeNodeId`
|
|
13
|
+
* - ImportResult: has `success` + (`nodeIds` or `error`)
|
|
14
|
+
* - string: passthrough (read/getSchema already return markdown)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function hasKey(obj: unknown, key: string): boolean {
|
|
18
|
+
return typeof obj === "object" && obj !== null && key in obj;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatTags(tags: { id: string; name: string }[]): string {
|
|
22
|
+
if (!tags || tags.length === 0) return "";
|
|
23
|
+
return " " + tags.map((t) => `#${t.name}`).join(" ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatSearchResults(data: unknown[]): string {
|
|
27
|
+
const lines = data.map((r: any) => {
|
|
28
|
+
const tags = formatTags(r.tags);
|
|
29
|
+
const breadcrumb = r.breadcrumb?.length ? ` — ${r.breadcrumb.join(" > ")}` : "";
|
|
30
|
+
return ` [${r.id}] ${r.name}${tags}${breadcrumb}`;
|
|
31
|
+
});
|
|
32
|
+
return `${data.length} results:\n${lines.join("\n")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatChildren(data: any): string {
|
|
36
|
+
const showing = data.children.length;
|
|
37
|
+
const header = `${data.total} children (showing ${showing}${data.hasMore ? ", has more" : ""}):`;
|
|
38
|
+
const lines = data.children.map((c: any) => {
|
|
39
|
+
const tags = formatTags(c.tags);
|
|
40
|
+
const kids = c.childCount > 0 ? ` (${c.childCount} children)` : "";
|
|
41
|
+
return ` [${c.id}] ${c.name}${tags}${kids}`;
|
|
42
|
+
});
|
|
43
|
+
return `${header}\n${lines.join("\n")}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatChildNodes(data: unknown[]): string {
|
|
47
|
+
const lines = data.map((c: any) => {
|
|
48
|
+
const tags = formatTags(c.tags);
|
|
49
|
+
const kids = c.childCount > 0 ? ` (${c.childCount} children)` : "";
|
|
50
|
+
return ` [${c.id}] ${c.name}${tags}${kids}`;
|
|
51
|
+
});
|
|
52
|
+
return `${data.length} nodes:\n${lines.join("\n")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatTags_(data: unknown[]): string {
|
|
56
|
+
const lines = data.map((t: any) => ` [${t.id}] ${t.name}`);
|
|
57
|
+
return `${data.length} tags:\n${lines.join("\n")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatWorkspaces(data: unknown[]): string {
|
|
61
|
+
const lines = data.map((w: any) => ` [${w.id}] ${w.name} (home: ${w.homeNodeId})`);
|
|
62
|
+
return `${data.length} workspaces:\n${lines.join("\n")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatImportResult(data: any): string {
|
|
66
|
+
if (data.success) {
|
|
67
|
+
const ids = data.nodeIds?.length ? ` (${data.nodeIds.join(", ")})` : "";
|
|
68
|
+
return `Import success: ${data.nodeIds?.length ?? 0} nodes${ids}`;
|
|
69
|
+
}
|
|
70
|
+
return `Import failed: ${data.error ?? "unknown error"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compact formatter for any Tana API response.
|
|
75
|
+
*
|
|
76
|
+
* Detects the shape of the data and returns a human-readable, token-efficient string.
|
|
77
|
+
* Falls back to JSON.stringify for unrecognized shapes.
|
|
78
|
+
*/
|
|
79
|
+
export function format(data: unknown): string {
|
|
80
|
+
if (typeof data === "string") return data;
|
|
81
|
+
if (data === null || data === undefined) return String(data);
|
|
82
|
+
|
|
83
|
+
// ImportResult: { success, nodeIds?, error? }
|
|
84
|
+
if (hasKey(data, "success") && (hasKey(data, "nodeIds") || hasKey(data, "error"))) {
|
|
85
|
+
return formatImportResult(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Children: { children, total, hasMore }
|
|
89
|
+
if (hasKey(data, "children") && hasKey(data, "total") && hasKey(data, "hasMore")) {
|
|
90
|
+
return formatChildren(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(data)) {
|
|
94
|
+
if (data.length === 0) return "0 results";
|
|
95
|
+
|
|
96
|
+
const first = data[0];
|
|
97
|
+
|
|
98
|
+
// SearchResult[]: has `breadcrumb`
|
|
99
|
+
if (hasKey(first, "breadcrumb")) {
|
|
100
|
+
return formatSearchResults(data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ChildNode[]: has `childCount`
|
|
104
|
+
if (hasKey(first, "childCount")) {
|
|
105
|
+
return formatChildNodes(data);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Workspace[]: has `homeNodeId`
|
|
109
|
+
if (hasKey(first, "homeNodeId")) {
|
|
110
|
+
return formatWorkspaces(data);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Tag[]: has `id` + `name`, no discriminators for other types
|
|
114
|
+
if (hasKey(first, "id") && hasKey(first, "name") && !hasKey(first, "breadcrumb") && !hasKey(first, "childCount") && !hasKey(first, "homeNodeId")) {
|
|
115
|
+
return formatTags_(data);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fallback: pretty JSON
|
|
120
|
+
try {
|
|
121
|
+
return JSON.stringify(data, null, 2);
|
|
122
|
+
} catch {
|
|
123
|
+
return String(data);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/api/tana.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
SetCheckboxOptions,
|
|
23
23
|
ImportResult,
|
|
24
24
|
} from "./types";
|
|
25
|
+
import { format } from "./format";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* TanaAPI Interface
|
|
@@ -30,6 +31,9 @@ import type {
|
|
|
30
31
|
* Provides high-level methods organized by domain (workspaces, nodes, tags, etc.)
|
|
31
32
|
*/
|
|
32
33
|
export interface TanaAPI {
|
|
34
|
+
/** Pre-resolved default workspace (from MAIN_TANA_WORKSPACE env var), or null */
|
|
35
|
+
workspace: Workspace | null;
|
|
36
|
+
|
|
33
37
|
/** Check API health */
|
|
34
38
|
health(): Promise<{ status: string; timestamp: string; nodeSpaceReady: boolean }>;
|
|
35
39
|
|
|
@@ -59,10 +63,10 @@ export interface TanaAPI {
|
|
|
59
63
|
};
|
|
60
64
|
|
|
61
65
|
tags: {
|
|
62
|
-
/** List tags in a workspace */
|
|
63
|
-
|
|
66
|
+
/** List all tags in a workspace */
|
|
67
|
+
listAll(workspaceId: string): Promise<Tag[]>;
|
|
64
68
|
/** Get tag schema */
|
|
65
|
-
getSchema(tagId: string, includeEditInstructions?: boolean): Promise<string>;
|
|
69
|
+
getSchema(tagId: string, includeEditInstructions?: boolean, includeInheritedFields?: boolean): Promise<string>;
|
|
66
70
|
/** Add/remove tags from a node */
|
|
67
71
|
modify(
|
|
68
72
|
nodeId: string,
|
|
@@ -103,10 +107,19 @@ export interface TanaAPI {
|
|
|
103
107
|
|
|
104
108
|
/** Import Tana Paste formatted content */
|
|
105
109
|
import(parentNodeId: string, content: string): Promise<ImportResult>;
|
|
110
|
+
|
|
111
|
+
/** Compact formatter for any API response */
|
|
112
|
+
format(data: unknown): string;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
export function createTanaAPI(
|
|
115
|
+
export function createTanaAPI(
|
|
116
|
+
client: TanaClient,
|
|
117
|
+
workspace?: Workspace | null,
|
|
118
|
+
defaultSearchWorkspaceIds?: string[]
|
|
119
|
+
): TanaAPI {
|
|
109
120
|
return {
|
|
121
|
+
workspace: workspace ?? null,
|
|
122
|
+
|
|
110
123
|
async health() {
|
|
111
124
|
return client.get<{ status: string; timestamp: string; nodeSpaceReady: boolean }>("/health");
|
|
112
125
|
},
|
|
@@ -147,8 +160,10 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
147
160
|
|
|
148
161
|
addQueryParams(query as unknown as Record<string, unknown>, "query");
|
|
149
162
|
if (options?.limit) params.push(`limit=${options.limit}`);
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
const effectiveWorkspaceIds = options?.workspaceIds
|
|
164
|
+
?? (defaultSearchWorkspaceIds?.length ? defaultSearchWorkspaceIds : undefined);
|
|
165
|
+
if (effectiveWorkspaceIds) {
|
|
166
|
+
effectiveWorkspaceIds.forEach((id, i) => {
|
|
152
167
|
params.push(`workspaceIds[${i}]=${encodeURIComponent(id)}`);
|
|
153
168
|
});
|
|
154
169
|
}
|
|
@@ -213,20 +228,36 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
213
228
|
},
|
|
214
229
|
|
|
215
230
|
tags: {
|
|
216
|
-
async
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
231
|
+
async listAll(workspaceId: string): Promise<Tag[]> {
|
|
232
|
+
const schemaNodeId = `${workspaceId}_SCHEMA`;
|
|
233
|
+
const tags: Tag[] = [];
|
|
234
|
+
let offset = 0;
|
|
235
|
+
|
|
236
|
+
while (true) {
|
|
237
|
+
const page = await client.get<Children>(
|
|
238
|
+
`/nodes/${schemaNodeId}/children?limit=200&offset=${offset}`
|
|
239
|
+
);
|
|
240
|
+
for (const child of page.children) {
|
|
241
|
+
if (child.docType === "tagDef") {
|
|
242
|
+
tags.push({ id: child.id, name: child.name });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!page.hasMore) break;
|
|
246
|
+
offset += 200;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return tags;
|
|
220
250
|
},
|
|
221
251
|
|
|
222
252
|
async getSchema(
|
|
223
253
|
tagId: string,
|
|
224
|
-
includeEditInstructions = false
|
|
254
|
+
includeEditInstructions = false,
|
|
255
|
+
includeInheritedFields = true
|
|
225
256
|
): Promise<string> {
|
|
226
257
|
const result = await client.get<{ markdown: string }>(
|
|
227
|
-
`/tags/${tagId}/schema?includeEditInstructions=${includeEditInstructions}`
|
|
258
|
+
`/tags/${tagId}/schema?includeEditInstructions=${includeEditInstructions}&includeInheritedFields=${includeInheritedFields}`
|
|
228
259
|
);
|
|
229
|
-
return result.markdown;
|
|
260
|
+
return truncateOptionLists(result.markdown);
|
|
230
261
|
},
|
|
231
262
|
|
|
232
263
|
async modify(
|
|
@@ -328,5 +359,45 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
328
359
|
content,
|
|
329
360
|
});
|
|
330
361
|
},
|
|
362
|
+
|
|
363
|
+
format(data: unknown): string {
|
|
364
|
+
return format(data);
|
|
365
|
+
},
|
|
331
366
|
};
|
|
332
367
|
}
|
|
368
|
+
|
|
369
|
+
const MAX_OPTIONS_SHOWN = 5;
|
|
370
|
+
const OPTION_LINE_RE = /^ - .+ \(id:/;
|
|
371
|
+
|
|
372
|
+
function truncateOptionLists(markdown: string): string {
|
|
373
|
+
const lines = markdown.split("\n");
|
|
374
|
+
const result: string[] = [];
|
|
375
|
+
let optionCount = 0;
|
|
376
|
+
let inOptions = false;
|
|
377
|
+
|
|
378
|
+
for (const line of lines) {
|
|
379
|
+
if (OPTION_LINE_RE.test(line)) {
|
|
380
|
+
if (!inOptions) {
|
|
381
|
+
inOptions = true;
|
|
382
|
+
optionCount = 0;
|
|
383
|
+
}
|
|
384
|
+
optionCount++;
|
|
385
|
+
if (optionCount <= MAX_OPTIONS_SHOWN) {
|
|
386
|
+
result.push(line);
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
if (inOptions && optionCount > MAX_OPTIONS_SHOWN) {
|
|
390
|
+
result.push(` - ... (${optionCount - MAX_OPTIONS_SHOWN} more, ${optionCount} total)`);
|
|
391
|
+
}
|
|
392
|
+
inOptions = false;
|
|
393
|
+
optionCount = 0;
|
|
394
|
+
result.push(line);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (inOptions && optionCount > MAX_OPTIONS_SHOWN) {
|
|
399
|
+
result.push(` - ... (${optionCount - MAX_OPTIONS_SHOWN} more, ${optionCount} total)`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return result.join("\n");
|
|
403
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { operations } from "../generated/api";
|
|
10
10
|
|
|
11
|
-
// ============================================================================
|
|
12
|
-
// Response Types (extracted from operation responses)
|
|
13
|
-
// ============================================================================
|
|
14
|
-
|
|
15
11
|
/** Health check response */
|
|
16
12
|
export type HealthResponse =
|
|
17
13
|
operations["health.ping"]["responses"]["200"]["content"]["application/json"];
|
|
@@ -87,10 +83,6 @@ export type SetFieldOptionResponse =
|
|
|
87
83
|
export type SetFieldContentResponse =
|
|
88
84
|
operations["nodes.setFieldContent"]["responses"]["200"]["content"]["application/json"];
|
|
89
85
|
|
|
90
|
-
// ============================================================================
|
|
91
|
-
// Request/Query Types (extracted from operation parameters)
|
|
92
|
-
// ============================================================================
|
|
93
|
-
|
|
94
86
|
/** Search query structure */
|
|
95
87
|
export type SearchQuery = NonNullable<
|
|
96
88
|
operations["nodes.search"]["parameters"]["query"]
|
|
@@ -106,10 +98,6 @@ export type FieldDataType = NonNullable<
|
|
|
106
98
|
operations["tags.addField"]["requestBody"]
|
|
107
99
|
>["content"]["application/json"]["dataType"];
|
|
108
100
|
|
|
109
|
-
// ============================================================================
|
|
110
|
-
// Request Body Types
|
|
111
|
-
// ============================================================================
|
|
112
|
-
|
|
113
101
|
/** Create tag request body */
|
|
114
102
|
export type CreateTagBody = NonNullable<
|
|
115
103
|
operations["tags.create"]["requestBody"]
|
|
@@ -135,10 +123,6 @@ export type NodeUpdateBody = NonNullable<
|
|
|
135
123
|
operations["nodes.update"]["requestBody"]
|
|
136
124
|
>["content"]["application/json"];
|
|
137
125
|
|
|
138
|
-
// ============================================================================
|
|
139
|
-
// Derived Helper Types (for ergonomic API usage)
|
|
140
|
-
// ============================================================================
|
|
141
|
-
|
|
142
126
|
/** Options for search */
|
|
143
127
|
export interface SearchOptions {
|
|
144
128
|
limit?: number;
|
|
@@ -199,9 +183,5 @@ export interface ImportResult {
|
|
|
199
183
|
error?: string;
|
|
200
184
|
}
|
|
201
185
|
|
|
202
|
-
// ============================================================================
|
|
203
|
-
// Re-export full generated types for escape hatch
|
|
204
|
-
// ============================================================================
|
|
205
|
-
|
|
206
186
|
export type { operations } from "../generated/api";
|
|
207
187
|
export type { paths } from "../generated/api";
|
package/src/index.ts
CHANGED
|
@@ -12,29 +12,114 @@ import { z } from "zod";
|
|
|
12
12
|
|
|
13
13
|
import { createClient } from "./api/client";
|
|
14
14
|
import { createTanaAPI } from "./api/tana";
|
|
15
|
+
import type { TanaClient } from "./api/client";
|
|
16
|
+
import type { Workspace } from "./api/types";
|
|
15
17
|
import { executeSandbox } from "./sandbox/executor";
|
|
16
18
|
import { cleanupOldRuns, initDb } from "./storage/history";
|
|
17
19
|
import { TOOL_DESCRIPTION } from "./prompts";
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the default workspace from MAIN_TANA_WORKSPACE env var.
|
|
23
|
+
* Matches by ID first, then case-insensitive name.
|
|
24
|
+
* Returns null if unset, unmatched, or API unreachable.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveWorkspace(client: TanaClient): Promise<Workspace | null> {
|
|
27
|
+
const envValue = process.env.MAIN_TANA_WORKSPACE?.trim();
|
|
28
|
+
if (!envValue) return null;
|
|
29
|
+
|
|
30
|
+
let workspaces: Workspace[];
|
|
31
|
+
try {
|
|
32
|
+
workspaces = await client.get<Workspace[]>("/workspaces");
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(
|
|
35
|
+
`[workspace] Failed to fetch workspaces: ${err instanceof Error ? err.message : err}`
|
|
36
|
+
);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const byId = workspaces.find((w) => w.id === envValue);
|
|
41
|
+
if (byId) {
|
|
42
|
+
console.error(`[workspace] Resolved "${envValue}" → ${byId.name} (${byId.id})`);
|
|
43
|
+
return byId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lowerEnv = envValue.toLowerCase();
|
|
47
|
+
const byName = workspaces.find((w) => w.name.toLowerCase() === lowerEnv);
|
|
48
|
+
if (byName) {
|
|
49
|
+
console.error(`[workspace] Resolved "${envValue}" → ${byName.name} (${byName.id})`);
|
|
50
|
+
return byName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const available = workspaces.map((w) => `${w.name} (${w.id})`).join(", ");
|
|
54
|
+
console.error(
|
|
55
|
+
`[workspace] No match for "${envValue}". Available: ${available || "none"}`
|
|
56
|
+
);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves TANA_SEARCH_WORKSPACES env var to an array of workspace IDs.
|
|
62
|
+
* Each value can be an ID or case-insensitive name.
|
|
63
|
+
* Unresolved values are logged and skipped.
|
|
64
|
+
*/
|
|
65
|
+
async function resolveSearchWorkspaces(client: TanaClient): Promise<string[]> {
|
|
66
|
+
const envValue = process.env.TANA_SEARCH_WORKSPACES?.trim();
|
|
67
|
+
if (!envValue) return [];
|
|
68
|
+
|
|
69
|
+
const values = envValue.split(",").map((v) => v.trim()).filter(Boolean);
|
|
70
|
+
if (values.length === 0) return [];
|
|
71
|
+
|
|
72
|
+
let workspaces: Workspace[];
|
|
73
|
+
try {
|
|
74
|
+
workspaces = await client.get<Workspace[]>("/workspaces");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(
|
|
77
|
+
`[search-workspaces] Failed to fetch workspaces: ${err instanceof Error ? err.message : err}`
|
|
78
|
+
);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resolvedIds: string[] = [];
|
|
83
|
+
for (const value of values) {
|
|
84
|
+
const byId = workspaces.find((w) => w.id === value);
|
|
85
|
+
if (byId) {
|
|
86
|
+
resolvedIds.push(byId.id);
|
|
87
|
+
console.error(`[search-workspaces] Resolved "${value}" → ${byId.name} (${byId.id})`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const lowerValue = value.toLowerCase();
|
|
91
|
+
const byName = workspaces.find((w) => w.name.toLowerCase() === lowerValue);
|
|
92
|
+
if (byName) {
|
|
93
|
+
resolvedIds.push(byName.id);
|
|
94
|
+
console.error(`[search-workspaces] Resolved "${value}" → ${byName.name} (${byName.id})`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const available = workspaces.map((w) => `${w.name} (${w.id})`).join(", ");
|
|
98
|
+
console.error(
|
|
99
|
+
`[search-workspaces] No match for "${value}". Available: ${available || "none"}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return resolvedIds;
|
|
104
|
+
}
|
|
105
|
+
|
|
19
106
|
async function main() {
|
|
20
|
-
// Initialize database and cleanup old runs
|
|
21
107
|
initDb();
|
|
22
108
|
const cleaned = cleanupOldRuns(30);
|
|
23
109
|
if (cleaned > 0) {
|
|
24
110
|
console.error(`Cleaned up ${cleaned} old script runs`);
|
|
25
111
|
}
|
|
26
112
|
|
|
27
|
-
// Create Tana client and API
|
|
28
113
|
const client = createClient();
|
|
29
|
-
const
|
|
114
|
+
const workspace = await resolveWorkspace(client);
|
|
115
|
+
const searchWorkspaceIds = await resolveSearchWorkspaces(client);
|
|
116
|
+
const tana = createTanaAPI(client, workspace, searchWorkspaceIds);
|
|
30
117
|
|
|
31
|
-
// Create MCP server using the modern McpServer API
|
|
32
118
|
const server = new McpServer({
|
|
33
119
|
name: "tana-mcp-codemode",
|
|
34
120
|
version: "0.1.0",
|
|
35
121
|
});
|
|
36
122
|
|
|
37
|
-
// Register the execute tool using registerTool (non-deprecated API)
|
|
38
123
|
server.registerTool(
|
|
39
124
|
"execute",
|
|
40
125
|
{
|
|
@@ -45,16 +130,11 @@ async function main() {
|
|
|
45
130
|
.string()
|
|
46
131
|
.optional()
|
|
47
132
|
.describe("Optional session ID for grouping script runs"),
|
|
48
|
-
input: z
|
|
49
|
-
.string()
|
|
50
|
-
.optional()
|
|
51
|
-
.describe("Data to pass to script via stdin() helper"),
|
|
52
133
|
},
|
|
53
134
|
},
|
|
54
|
-
async ({ code, sessionId
|
|
55
|
-
const result = await executeSandbox(code, tana, sessionId
|
|
135
|
+
async ({ code, sessionId }) => {
|
|
136
|
+
const result = await executeSandbox(code, tana, sessionId);
|
|
56
137
|
|
|
57
|
-
// Format response
|
|
58
138
|
let responseText = "";
|
|
59
139
|
if (result.output) {
|
|
60
140
|
responseText += result.output;
|
|
@@ -77,7 +157,6 @@ async function main() {
|
|
|
77
157
|
}
|
|
78
158
|
);
|
|
79
159
|
|
|
80
|
-
// Start server with stdio transport
|
|
81
160
|
const transport = new StdioServerTransport();
|
|
82
161
|
await server.connect(transport);
|
|
83
162
|
|
package/src/prompts.ts
CHANGED
|
@@ -6,7 +6,7 @@ export const TOOL_DESCRIPTION = `Execute TypeScript code to interact with Tana.
|
|
|
6
6
|
|
|
7
7
|
## APIs
|
|
8
8
|
|
|
9
|
-
tana.
|
|
9
|
+
tana.workspace → Workspace | null (pre-resolved default workspace)
|
|
10
10
|
tana.workspaces.list() → Workspace[] { id, name, homeNodeId }
|
|
11
11
|
|
|
12
12
|
### Nodes
|
|
@@ -18,8 +18,8 @@ tana.nodes.trash(nodeId) → { success }
|
|
|
18
18
|
tana.nodes.check(nodeId) / uncheck(nodeId) → { success }
|
|
19
19
|
|
|
20
20
|
### Tags
|
|
21
|
-
tana.tags.
|
|
22
|
-
tana.tags.getSchema(tagId
|
|
21
|
+
tana.tags.listAll(workspaceId) → Tag[] (workspace supertags only — for schema analysis)
|
|
22
|
+
tana.tags.getSchema(tagId) → string
|
|
23
23
|
tana.tags.modify(nodeId, "add"|"remove", tagIds[])
|
|
24
24
|
tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
|
|
25
25
|
tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email"|"checkbox"|"user"|"instance"|"options", ... })
|
|
@@ -35,13 +35,10 @@ tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
|
|
|
35
35
|
### Import
|
|
36
36
|
tana.import(parentNodeId, tanaPasteContent) → { success, nodeIds? }
|
|
37
37
|
|
|
38
|
-
###
|
|
39
|
-
|
|
40
|
-
library: \`\${workspaceId}_STASH\`
|
|
38
|
+
### Formatting
|
|
39
|
+
tana.format(data) → string (compact display of any API response)
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
stdin().json<T>() | .lines() | .text() | .hasInput()
|
|
44
|
-
workflow.start(msg) | .step(msg) | .progress(n, total, msg) | .complete(msg) | .abort(err)
|
|
41
|
+
Entry points: \`\${workspaceId}_CAPTURE_INBOX\` (inbox), \`\${workspaceId}_STASH\` (library)
|
|
45
42
|
|
|
46
43
|
## Edit (search-and-replace)
|
|
47
44
|
|
|
@@ -53,15 +50,35 @@ Empty old_string matches absent field.
|
|
|
53
50
|
{ textContains: string } | { textMatches: "/regex/i" }
|
|
54
51
|
{ hasType: tagId } | { field: { fieldId, stringValue?, numberValue?, nodeId?, state? } }
|
|
55
52
|
{ compare: { fieldId, operator: "gt"|"lt"|"eq", value, type } }
|
|
56
|
-
{ childOf: { nodeIds[], recursive?, includeRefs? } } | { ownedBy: { nodeId, recursive?, includeSelf? } }
|
|
57
53
|
{ linksTo: nodeIds[] }
|
|
58
|
-
{ is: "done"|"todo"|"template"|"entity"|"calendarNode"|"search"|"inLibrary" }
|
|
54
|
+
{ is: "done"|"todo"|"template"|"field"|"published"|"entity"|"calendarNode"|"onDayNode"|"chat"|"search"|"command"|"inLibrary" }
|
|
59
55
|
{ has: "tag"|"field"|"media"|"audio"|"video"|"image" }
|
|
60
56
|
{ created: { last: N } } | { edited: { last?, by?, since? } } | { done: { last: N } }
|
|
61
57
|
{ onDate: "YYYY-MM-DD" | { date, fieldId?, overlaps? } }
|
|
62
58
|
{ overdue: true } | { inLibrary: true }
|
|
63
59
|
{ and: [...] } | { or: [...] } | { not: {...} }
|
|
64
60
|
|
|
61
|
+
## Default Workspace
|
|
62
|
+
|
|
63
|
+
tana.workspace is pre-resolved if MAIN_TANA_WORKSPACE is set. Prefer over workspaces.list(): \`const ws = tana.workspace ?? (await tana.workspaces.list())[0];\`
|
|
64
|
+
|
|
65
|
+
## Output
|
|
66
|
+
|
|
67
|
+
console.log() output becomes LLM context. Keep it compact:
|
|
68
|
+
- Use tana.format(data) for any API response, or .map() for task-specific fields
|
|
69
|
+
- search returns metadata only (name, id, tags); use .read() for content and field values
|
|
70
|
+
- Never JSON.stringify API responses
|
|
71
|
+
|
|
72
|
+
## API Notes
|
|
73
|
+
|
|
74
|
+
- search: no offset/pagination — use narrower queries, not repeated calls
|
|
75
|
+
- getChildren: only endpoint with pagination (limit + offset)
|
|
76
|
+
- Timeout is 10s. On timeout, try a different approach, not the same call.
|
|
77
|
+
- childOf/ownedBy/inWorkspace operators broken. Scope by workspace: search(query, { workspaceIds: ["id"] })
|
|
78
|
+
- Tag names are not unique. Find a tag by name: search({ and: [{ hasType: "SYS_T01" }, { textContains: "name" }] })
|
|
79
|
+
- search results: { id, name, breadcrumb[], tags[{id,name}], tagIds[], workspaceId, docType, description, created, inTrash }
|
|
80
|
+
- getSchema output: line 1 is \`# Tag definition: name (id:xxx)\`. Line 2 is \`Extends #parent (id:xxx)\` when tag has inheritance, or \`Extends #parent (base type) (id:xxx)\` for Tana built-in types. Parse "Extends" to find relationships.
|
|
81
|
+
|
|
65
82
|
## Examples
|
|
66
83
|
|
|
67
84
|
Search: await tana.nodes.search({ and: [{ hasType: "tagId" }, { is: "todo" }] })
|
|
@@ -75,7 +92,4 @@ await tana.import(parentNodeId, \`
|
|
|
75
92
|
\`)
|
|
76
93
|
\`\`\`
|
|
77
94
|
|
|
78
|
-
## Output
|
|
79
|
-
|
|
80
|
-
console.log() to return data.
|
|
81
95
|
`;
|
package/src/sandbox/executor.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import type { SandboxResult } from "../types";
|
|
13
13
|
import type { TanaAPI } from "../api/tana";
|
|
14
14
|
import { saveScriptRun } from "../storage/history";
|
|
15
|
-
import { createStdinHelper } from "./stdin";
|
|
16
15
|
import { createWorkflowHelper } from "./workflow";
|
|
17
16
|
|
|
18
17
|
const EXECUTION_TIMEOUT = 10_000; // 10 seconds
|
|
@@ -32,6 +31,8 @@ function createTrackedTanaAPI(
|
|
|
32
31
|
const trackNodeId = (id: string) => tracker.nodeIds.add(id);
|
|
33
32
|
|
|
34
33
|
return {
|
|
34
|
+
workspace: tana.workspace,
|
|
35
|
+
|
|
35
36
|
health: () => track("health", () => tana.health()),
|
|
36
37
|
|
|
37
38
|
workspaces: {
|
|
@@ -72,9 +73,9 @@ function createTrackedTanaAPI(
|
|
|
72
73
|
},
|
|
73
74
|
|
|
74
75
|
tags: {
|
|
75
|
-
|
|
76
|
+
listAll: (workspaceId: string) => {
|
|
76
77
|
tracker.workspaceId = workspaceId;
|
|
77
|
-
return track("tags.
|
|
78
|
+
return track("tags.listAll", () => tana.tags.listAll(workspaceId));
|
|
78
79
|
},
|
|
79
80
|
getSchema: (tagId, includeEditInstructions) =>
|
|
80
81
|
track("tags.getSchema", () => tana.tags.getSchema(tagId, includeEditInstructions)),
|
|
@@ -116,14 +117,15 @@ function createTrackedTanaAPI(
|
|
|
116
117
|
trackNodeId(parentNodeId);
|
|
117
118
|
return track("import", () => tana.import(parentNodeId, content));
|
|
118
119
|
},
|
|
120
|
+
|
|
121
|
+
format: (data) => tana.format(data),
|
|
119
122
|
};
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
export async function executeSandbox(
|
|
123
126
|
code: string,
|
|
124
127
|
tana: TanaAPI,
|
|
125
|
-
sessionId?: string
|
|
126
|
-
input?: string
|
|
128
|
+
sessionId?: string
|
|
127
129
|
): Promise<SandboxResult> {
|
|
128
130
|
const startTime = performance.now();
|
|
129
131
|
const logs: string[] = [];
|
|
@@ -132,7 +134,7 @@ export async function executeSandbox(
|
|
|
132
134
|
const tracker = {
|
|
133
135
|
calls: [] as string[],
|
|
134
136
|
nodeIds: new Set<string>(),
|
|
135
|
-
workspaceId:
|
|
137
|
+
workspaceId: tana.workspace?.id ?? null,
|
|
136
138
|
};
|
|
137
139
|
|
|
138
140
|
// Custom console that captures output
|
|
@@ -151,8 +153,6 @@ export async function executeSandbox(
|
|
|
151
153
|
},
|
|
152
154
|
};
|
|
153
155
|
|
|
154
|
-
// Create helpers for script execution
|
|
155
|
-
const stdin = createStdinHelper(input);
|
|
156
156
|
const effectiveSessionId = sessionId ?? crypto.randomUUID();
|
|
157
157
|
const workflow = createWorkflowHelper(effectiveSessionId);
|
|
158
158
|
|
|
@@ -160,7 +160,6 @@ export async function executeSandbox(
|
|
|
160
160
|
const trackedTana = createTrackedTanaAPI(tana, tracker);
|
|
161
161
|
|
|
162
162
|
try {
|
|
163
|
-
// Create async function to support top-level await
|
|
164
163
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
165
164
|
const AsyncFunction = Object.getPrototypeOf(
|
|
166
165
|
async function () {}
|
|
@@ -183,26 +182,26 @@ export async function executeSandbox(
|
|
|
183
182
|
"exports",
|
|
184
183
|
];
|
|
185
184
|
|
|
185
|
+
const transpiler = new Bun.Transpiler({ loader: "ts" });
|
|
186
|
+
const jsCode = transpiler.transformSync(code);
|
|
187
|
+
|
|
186
188
|
const fn = new AsyncFunction(
|
|
187
189
|
"tana",
|
|
188
190
|
"console",
|
|
189
|
-
"stdin",
|
|
190
191
|
"workflow",
|
|
191
192
|
...shadowedGlobals,
|
|
192
|
-
|
|
193
|
+
jsCode
|
|
193
194
|
);
|
|
194
195
|
|
|
195
|
-
// Race between execution and timeout
|
|
196
196
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
197
197
|
setTimeout(() => {
|
|
198
198
|
reject(new Error(`Execution timed out after ${EXECUTION_TIMEOUT}ms`));
|
|
199
199
|
}, EXECUTION_TIMEOUT);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
-
// Pass undefined for all shadowed globals
|
|
203
202
|
const undefinedArgs = shadowedGlobals.map(() => undefined);
|
|
204
203
|
await Promise.race([
|
|
205
|
-
fn(trackedTana, sandboxConsole,
|
|
204
|
+
fn(trackedTana, sandboxConsole, workflow, ...undefinedArgs),
|
|
206
205
|
timeoutPromise,
|
|
207
206
|
]);
|
|
208
207
|
|
|
@@ -217,7 +216,6 @@ export async function executeSandbox(
|
|
|
217
216
|
error: null,
|
|
218
217
|
durationMs,
|
|
219
218
|
sessionId: effectiveSessionId,
|
|
220
|
-
input: input ?? null,
|
|
221
219
|
apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
|
|
222
220
|
nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
|
|
223
221
|
workspaceId: tracker.workspaceId,
|
|
@@ -242,7 +240,6 @@ export async function executeSandbox(
|
|
|
242
240
|
error: errorMessage,
|
|
243
241
|
durationMs,
|
|
244
242
|
sessionId: effectiveSessionId,
|
|
245
|
-
input: input ?? null,
|
|
246
243
|
apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
|
|
247
244
|
nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
|
|
248
245
|
workspaceId: tracker.workspaceId,
|
package/src/sandbox/stdin.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* stdin() Helper
|
|
3
|
-
*
|
|
4
|
-
* Provides convenient methods for handling input data passed to scripts.
|
|
5
|
-
* In MCP context, this handles data passed via the `input` parameter.
|
|
6
|
-
*
|
|
7
|
-
* Usage in scripts:
|
|
8
|
-
* const data = stdin().json(); // Parse as JSON
|
|
9
|
-
* const lines = stdin().lines(); // Split into lines
|
|
10
|
-
* const text = stdin().text(); // Raw trimmed text
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export interface StdinHelper {
|
|
14
|
-
/** Get raw input as trimmed string */
|
|
15
|
-
text(): string;
|
|
16
|
-
/** Split input into array of lines (empty lines filtered) */
|
|
17
|
-
lines(): string[];
|
|
18
|
-
/** Parse input as JSON (throws if invalid) */
|
|
19
|
-
json<T = unknown>(): T;
|
|
20
|
-
/** Parse input as JSON, return null if invalid */
|
|
21
|
-
jsonSafe<T = unknown>(): T | null;
|
|
22
|
-
/** Check if any input was provided */
|
|
23
|
-
hasInput(): boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createStdinHelper(input: string | undefined): () => StdinHelper {
|
|
27
|
-
const rawInput = input ?? "";
|
|
28
|
-
|
|
29
|
-
return () => ({
|
|
30
|
-
text(): string {
|
|
31
|
-
return rawInput.trim();
|
|
32
|
-
},
|
|
33
|
-
|
|
34
|
-
lines(): string[] {
|
|
35
|
-
return rawInput
|
|
36
|
-
.split("\n")
|
|
37
|
-
.map((line) => line.trim())
|
|
38
|
-
.filter((line) => line.length > 0);
|
|
39
|
-
},
|
|
40
|
-
|
|
41
|
-
json<T = unknown>(): T {
|
|
42
|
-
const text = rawInput.trim();
|
|
43
|
-
if (!text) {
|
|
44
|
-
throw new Error("stdin is empty - no JSON to parse");
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(text) as T;
|
|
48
|
-
} catch (e) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
`Failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
|
|
55
|
-
jsonSafe<T = unknown>(): T | null {
|
|
56
|
-
const text = rawInput.trim();
|
|
57
|
-
if (!text) return null;
|
|
58
|
-
try {
|
|
59
|
-
return JSON.parse(text) as T;
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
hasInput(): boolean {
|
|
66
|
-
return rawInput.trim().length > 0;
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
}
|