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 +344 -0
- package/package.json +54 -0
- package/src/api/client.ts +243 -0
- package/src/api/tana.ts +332 -0
- package/src/api/types.ts +207 -0
- package/src/generated/api.d.ts +1425 -0
- package/src/index.ts +90 -0
- package/src/prompts.ts +81 -0
- package/src/sandbox/executor.ts +270 -0
- package/src/sandbox/stdin.ts +69 -0
- package/src/sandbox/workflow.ts +197 -0
- package/src/storage/history.ts +202 -0
- package/src/types.ts +32 -0
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
|
+
}
|