tana-mcp-codemode 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Debug Server - WebSocket UI for Testing
4
+ *
5
+ * Provides a web-based interface for testing Tana API scripts.
6
+ * Run with: bun run src/debug-server.ts
7
+ * Open: http://localhost:3333
8
+ *
9
+ * In dev mode, proxies to Vite for hot-reload and dynamic glob resolution.
10
+ */
11
+
12
+ import { initCompat } from "./compat";
13
+ import { bunCompat } from "./compat/bun";
14
+ import { createClient } from "./api/client";
15
+ import { createTanaAPI } from "./api/tana";
16
+ import { executeSandbox } from "./sandbox/executor";
17
+ import { getWorkflowEvents, getRecentWorkflows } from "./sandbox/workflow";
18
+ import { initDb } from "./storage/history";
19
+ import { spawn, type Subprocess } from "bun";
20
+
21
+ const PORT = parseInt(process.env.DEBUG_PORT || "3333", 10);
22
+ const VITE_PORT = 5188; // Use unique port to avoid conflicts
23
+ let viteProcess: Subprocess | null = null;
24
+
25
+ interface ExecuteRequest {
26
+ type: "execute";
27
+ code: string;
28
+ sessionId?: string;
29
+ input?: string;
30
+ }
31
+
32
+ interface GetWorkflowRequest {
33
+ type: "getWorkflow";
34
+ sessionId: string;
35
+ }
36
+
37
+ interface ListWorkflowsRequest {
38
+ type: "listWorkflows";
39
+ limit?: number;
40
+ }
41
+
42
+ type WebSocketMessage = ExecuteRequest | GetWorkflowRequest | ListWorkflowsRequest;
43
+
44
+ async function startVite(): Promise<void> {
45
+ console.log("Starting Vite dev server...");
46
+
47
+ viteProcess = spawn({
48
+ cmd: ["bunx", "vite", "--port", String(VITE_PORT), "--strictPort"],
49
+ cwd: "./ui",
50
+ stdout: "inherit",
51
+ stderr: "inherit",
52
+ });
53
+
54
+ // Wait for Vite to be ready
55
+ const maxAttempts = 30;
56
+ for (let i = 0; i < maxAttempts; i++) {
57
+ try {
58
+ const res = await fetch(`http://localhost:${VITE_PORT}`, { method: "HEAD" });
59
+ if (res.ok) {
60
+ console.log("Vite dev server ready");
61
+ return;
62
+ }
63
+ } catch {
64
+ // Not ready yet
65
+ }
66
+ await Bun.sleep(200);
67
+ }
68
+ console.warn("Vite may not be fully ready, continuing anyway...");
69
+ }
70
+
71
+ async function proxyToVite(req: Request): Promise<Response> {
72
+ const url = new URL(req.url);
73
+ const viteUrl = `http://localhost:${VITE_PORT}${url.pathname}${url.search}`;
74
+
75
+ try {
76
+ const proxyReq = new Request(viteUrl, {
77
+ method: req.method,
78
+ headers: req.headers,
79
+ body: req.body,
80
+ });
81
+ return await fetch(proxyReq);
82
+ } catch (e) {
83
+ return new Response(`Vite proxy error: ${e}`, { status: 502 });
84
+ }
85
+ }
86
+
87
+ async function main() {
88
+ initCompat(bunCompat);
89
+ initDb();
90
+
91
+ // Start Vite dev server for dynamic glob resolution
92
+ await startVite();
93
+
94
+ let tana: ReturnType<typeof createTanaAPI> | null = null;
95
+ let clientError: string | null = null;
96
+
97
+ try {
98
+ const client = createClient();
99
+ tana = createTanaAPI(client);
100
+ } catch (e) {
101
+ clientError = e instanceof Error ? e.message : String(e);
102
+ console.error("Warning: Could not create Tana client:", clientError);
103
+ }
104
+
105
+ const server = Bun.serve({
106
+ port: PORT,
107
+ async fetch(req, server) {
108
+ const url = new URL(req.url);
109
+
110
+ if (url.pathname === "/ws") {
111
+ const upgraded = server.upgrade(req);
112
+ if (!upgraded) {
113
+ return new Response("WebSocket upgrade failed", { status: 400 });
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ if (url.pathname === "/health") {
119
+ return Response.json({ status: "ok", tanaConnected: !!tana, clientError });
120
+ }
121
+
122
+ // Proxy everything else to Vite dev server
123
+ return proxyToVite(req);
124
+ },
125
+
126
+ websocket: {
127
+ async message(ws, message) {
128
+ try {
129
+ const data = JSON.parse(String(message)) as WebSocketMessage;
130
+
131
+ switch (data.type) {
132
+ case "execute": {
133
+ if (!tana) {
134
+ ws.send(JSON.stringify({
135
+ type: "error",
136
+ error: clientError || "Tana client not initialized",
137
+ }));
138
+ return;
139
+ }
140
+
141
+ const sessionId = data.sessionId || crypto.randomUUID();
142
+ ws.send(JSON.stringify({ type: "started", sessionId }));
143
+
144
+ const result = await executeSandbox(
145
+ data.code,
146
+ tana,
147
+ sessionId
148
+ );
149
+
150
+ const events = getWorkflowEvents(sessionId);
151
+
152
+ ws.send(JSON.stringify({
153
+ type: "result",
154
+ sessionId,
155
+ ...result,
156
+ workflowEvents: events,
157
+ }));
158
+ break;
159
+ }
160
+
161
+ case "getWorkflow": {
162
+ const events = getWorkflowEvents(data.sessionId);
163
+ ws.send(JSON.stringify({
164
+ type: "workflow",
165
+ sessionId: data.sessionId,
166
+ events,
167
+ }));
168
+ break;
169
+ }
170
+
171
+ case "listWorkflows": {
172
+ const workflows = getRecentWorkflows(data.limit ?? 20);
173
+ ws.send(JSON.stringify({
174
+ type: "workflows",
175
+ workflows,
176
+ }));
177
+ break;
178
+ }
179
+
180
+ default:
181
+ ws.send(JSON.stringify({
182
+ type: "error",
183
+ error: "Unknown message type",
184
+ }));
185
+ }
186
+ } catch (e) {
187
+ ws.send(JSON.stringify({
188
+ type: "error",
189
+ error: e instanceof Error ? e.message : String(e),
190
+ }));
191
+ }
192
+ },
193
+
194
+ open(ws) {
195
+ console.log("Client connected");
196
+ ws.send(JSON.stringify({
197
+ type: "connected",
198
+ tanaConnected: !!tana,
199
+ clientError,
200
+ }));
201
+ },
202
+
203
+ close() {
204
+ console.log("Client disconnected");
205
+ },
206
+ },
207
+ });
208
+
209
+ console.log(`Debug server running at http://localhost:${PORT}`);
210
+ console.log(`WebSocket endpoint: ws://localhost:${PORT}/ws`);
211
+ console.log(`Vite dev server: http://localhost:${VITE_PORT} (proxied)`);
212
+ if (clientError) {
213
+ console.log(`Warning: ${clientError}`);
214
+ }
215
+
216
+ // Cleanup on shutdown
217
+ const cleanup = () => {
218
+ if (viteProcess) {
219
+ console.log("Stopping Vite dev server...");
220
+ viteProcess.kill();
221
+ }
222
+ process.exit(0);
223
+ };
224
+
225
+ process.on("SIGINT", cleanup);
226
+ process.on("SIGTERM", cleanup);
227
+ }
228
+
229
+ main().catch((error) => {
230
+ console.error("Fatal error:", error);
231
+ process.exit(1);
232
+ });
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ import { initCompat } from "./compat";
3
+ import { bunCompat } from "./compat/bun";
4
+ import { main } from "./main";
5
+
6
+ initCompat(bunCompat);
7
+
8
+ main().catch((error) => {
9
+ console.error("Fatal error:", error);
10
+ process.exit(1);
11
+ });
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { initCompat } from "./compat/index.js";
3
+ import { nodeCompat } from "./compat/node.js";
4
+ import { main } from "./main.js";
5
+
6
+ initCompat(nodeCompat);
7
+
8
+ main().catch((error) => {
9
+ console.error("Fatal error:", error);
10
+ process.exit(1);
11
+ });
package/src/index.ts CHANGED
@@ -1,167 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- /**
3
- * Tana MCP Server - Entry Point
4
- *
5
- * Codemode MCP server for Tana knowledge management.
6
- * AI writes TypeScript code that executes against the Tana Local API.
7
- */
2
+ // Backward-compat shim — use src/entry-bun.ts directly
3
+ import { initCompat } from "./compat";
4
+ import { bunCompat } from "./compat/bun";
5
+ import { main } from "./main";
8
6
 
9
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- import { z } from "zod";
12
-
13
- import { createClient } from "./api/client";
14
- import { createTanaAPI } from "./api/tana";
15
- import type { TanaClient } from "./api/client";
16
- import type { Workspace } from "./api/types";
17
- import { executeSandbox } from "./sandbox/executor";
18
- import { cleanupOldRuns, initDb } from "./storage/history";
19
- import { TOOL_DESCRIPTION } from "./prompts";
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
-
106
- async function main() {
107
- initDb();
108
- const cleaned = cleanupOldRuns(30);
109
- if (cleaned > 0) {
110
- console.error(`Cleaned up ${cleaned} old script runs`);
111
- }
112
-
113
- const client = createClient();
114
- const workspace = await resolveWorkspace(client);
115
- const searchWorkspaceIds = await resolveSearchWorkspaces(client);
116
- const tana = createTanaAPI(client, workspace, searchWorkspaceIds);
117
-
118
- const server = new McpServer({
119
- name: "tana-mcp-codemode",
120
- version: "0.1.0",
121
- });
122
-
123
- server.registerTool(
124
- "execute",
125
- {
126
- description: TOOL_DESCRIPTION,
127
- inputSchema: {
128
- code: z.string().describe("TypeScript code to execute"),
129
- sessionId: z
130
- .string()
131
- .optional()
132
- .describe("Optional session ID for grouping script runs"),
133
- },
134
- },
135
- async ({ code, sessionId }) => {
136
- const result = await executeSandbox(code, tana, sessionId);
137
-
138
- let responseText = "";
139
- if (result.output) {
140
- responseText += result.output;
141
- }
142
- if (result.error) {
143
- responseText += responseText ? "\n\n" : "";
144
- responseText += `Error: ${result.error}`;
145
- }
146
- responseText += `\n\n[Executed in ${result.durationMs}ms]`;
147
-
148
- return {
149
- content: [
150
- {
151
- type: "text" as const,
152
- text: responseText,
153
- },
154
- ],
155
- isError: !result.success,
156
- };
157
- }
158
- );
159
-
160
- const transport = new StdioServerTransport();
161
- await server.connect(transport);
162
-
163
- console.error("Tana MCP server started");
164
- }
7
+ initCompat(bunCompat);
165
8
 
166
9
  main().catch((error) => {
167
10
  console.error("Fatal error:", error);
package/src/main.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+
5
+ import { createClient } from "./api/client";
6
+ import { createTanaAPI } from "./api/tana";
7
+ import type { TanaClient } from "./api/client";
8
+ import type { Workspace } from "./api/types";
9
+ import { executeSandbox } from "./sandbox/executor";
10
+ import { cleanupOldRuns, initDb } from "./storage/history";
11
+ import { TOOL_DESCRIPTION } from "./prompts";
12
+
13
+ async function resolveWorkspace(client: TanaClient): Promise<Workspace | null> {
14
+ const envValue = process.env.MAIN_TANA_WORKSPACE?.trim();
15
+ if (!envValue) return null;
16
+
17
+ let workspaces: Workspace[];
18
+ try {
19
+ workspaces = await client.get<Workspace[]>("/workspaces");
20
+ } catch (err) {
21
+ console.error(
22
+ `[workspace] Failed to fetch workspaces: ${err instanceof Error ? err.message : err}`
23
+ );
24
+ return null;
25
+ }
26
+
27
+ const byId = workspaces.find((w) => w.id === envValue);
28
+ if (byId) {
29
+ console.error(`[workspace] Resolved "${envValue}" → ${byId.name} (${byId.id})`);
30
+ return byId;
31
+ }
32
+
33
+ const lowerEnv = envValue.toLowerCase();
34
+ const byName = workspaces.find((w) => w.name.toLowerCase() === lowerEnv);
35
+ if (byName) {
36
+ console.error(`[workspace] Resolved "${envValue}" → ${byName.name} (${byName.id})`);
37
+ return byName;
38
+ }
39
+
40
+ const available = workspaces.map((w) => `${w.name} (${w.id})`).join(", ");
41
+ console.error(
42
+ `[workspace] No match for "${envValue}". Available: ${available || "none"}`
43
+ );
44
+ return null;
45
+ }
46
+
47
+ async function resolveSearchWorkspaces(client: TanaClient): Promise<string[]> {
48
+ const envValue = process.env.TANA_SEARCH_WORKSPACES?.trim();
49
+ if (!envValue) return [];
50
+
51
+ const values = envValue.split(",").map((v) => v.trim()).filter(Boolean);
52
+ if (values.length === 0) return [];
53
+
54
+ let workspaces: Workspace[];
55
+ try {
56
+ workspaces = await client.get<Workspace[]>("/workspaces");
57
+ } catch (err) {
58
+ console.error(
59
+ `[search-workspaces] Failed to fetch workspaces: ${err instanceof Error ? err.message : err}`
60
+ );
61
+ return [];
62
+ }
63
+
64
+ const resolvedIds: string[] = [];
65
+ for (const value of values) {
66
+ const byId = workspaces.find((w) => w.id === value);
67
+ if (byId) {
68
+ resolvedIds.push(byId.id);
69
+ console.error(`[search-workspaces] Resolved "${value}" → ${byId.name} (${byId.id})`);
70
+ continue;
71
+ }
72
+ const lowerValue = value.toLowerCase();
73
+ const byName = workspaces.find((w) => w.name.toLowerCase() === lowerValue);
74
+ if (byName) {
75
+ resolvedIds.push(byName.id);
76
+ console.error(`[search-workspaces] Resolved "${value}" → ${byName.name} (${byName.id})`);
77
+ continue;
78
+ }
79
+ const available = workspaces.map((w) => `${w.name} (${w.id})`).join(", ");
80
+ console.error(
81
+ `[search-workspaces] No match for "${value}". Available: ${available || "none"}`
82
+ );
83
+ }
84
+
85
+ return resolvedIds;
86
+ }
87
+
88
+ export async function main() {
89
+ initDb();
90
+ const cleaned = cleanupOldRuns(30);
91
+ if (cleaned > 0) {
92
+ console.error(`Cleaned up ${cleaned} old script runs`);
93
+ }
94
+
95
+ const client = createClient();
96
+ const workspace = await resolveWorkspace(client);
97
+ const searchWorkspaceIds = await resolveSearchWorkspaces(client);
98
+ const tana = createTanaAPI(client, workspace, searchWorkspaceIds);
99
+
100
+ const server = new McpServer({
101
+ name: "tana-mcp-codemode",
102
+ version: "0.1.0",
103
+ });
104
+
105
+ server.registerTool(
106
+ "execute",
107
+ {
108
+ description: TOOL_DESCRIPTION,
109
+ inputSchema: {
110
+ code: z.string().describe("TypeScript code to execute"),
111
+ sessionId: z
112
+ .string()
113
+ .optional()
114
+ .describe("Optional session ID for grouping script runs"),
115
+ },
116
+ },
117
+ async ({ code, sessionId }) => {
118
+ const result = await executeSandbox(code, tana, sessionId);
119
+
120
+ let responseText = "";
121
+ if (result.output) {
122
+ responseText += result.output;
123
+ }
124
+ if (result.error) {
125
+ responseText += responseText ? "\n\n" : "";
126
+ responseText += `Error: ${result.error}`;
127
+ }
128
+ responseText += `\n\n[Executed in ${result.durationMs}ms]`;
129
+
130
+ return {
131
+ content: [
132
+ {
133
+ type: "text" as const,
134
+ text: responseText,
135
+ },
136
+ ],
137
+ isError: !result.success,
138
+ };
139
+ }
140
+ );
141
+
142
+ const transport = new StdioServerTransport();
143
+ await server.connect(transport);
144
+
145
+ console.error("Tana MCP server started");
146
+ }
package/src/prompts.ts CHANGED
@@ -28,8 +28,9 @@ tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email
28
28
  tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
29
29
 
30
30
  ### Fields
31
- tana.fields.setOption(nodeId, attributeId, optionId, mode?) // mode: "replace" | "append" (default: "replace")
31
+ tana.fields.setOption(nodeId, attributeId, optionId) // optionId: string | string[] (array for multi-value)
32
32
  tana.fields.setContent(nodeId, attributeId, content, mode?) // content: string | null (null clears field), mode: "replace" | "append"
33
+ tana.fields.getFieldOptions(fieldId, options?) → { id, name }[] // discover available options for a field
33
34
 
34
35
  ### Calendar
35
36
  tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
@@ -13,6 +13,7 @@ import type { SandboxResult } from "../types";
13
13
  import type { TanaAPI } from "../api/tana";
14
14
  import { saveScriptRun } from "../storage/history";
15
15
  import { createWorkflowHelper } from "./workflow";
16
+ import { transpileTS } from "../compat";
16
17
 
17
18
  const EXECUTION_TIMEOUT = 10_000; // 10 seconds
18
19
 
@@ -110,6 +111,8 @@ function createTrackedTanaAPI(
110
111
  trackNodeId(nodeId);
111
112
  return track("fields.setContent", () => tana.fields.setContent(nodeId, attributeId, content));
112
113
  },
114
+ getFieldOptions: (...args) =>
115
+ track("fields.getFieldOptions", () => tana.fields.getFieldOptions(...args)),
113
116
  },
114
117
 
115
118
  calendar: {
@@ -190,8 +193,7 @@ export async function executeSandbox(
190
193
  "exports",
191
194
  ];
192
195
 
193
- const transpiler = new Bun.Transpiler({ loader: "ts" });
194
- const jsCode = transpiler.transformSync(code);
196
+ const jsCode = transpileTS(code);
195
197
 
196
198
  const fn = new AsyncFunction(
197
199
  "tana",
@@ -11,7 +11,7 @@
11
11
  * workflow.complete("Done!");
12
12
  */
13
13
 
14
- import { Database } from "bun:sqlite";
14
+ import type { CompatDatabase } from "../compat";
15
15
  import { initDb } from "../storage/history";
16
16
 
17
17
  export type WorkflowEventType =
@@ -45,10 +45,10 @@ export interface WorkflowHelper {
45
45
 
46
46
  let workflowTableInitialized = false;
47
47
 
48
- function ensureWorkflowTable(db: Database): void {
48
+ function ensureWorkflowTable(db: CompatDatabase): void {
49
49
  if (workflowTableInitialized) return;
50
50
 
51
- db.run(`
51
+ db.exec(`
52
52
  CREATE TABLE IF NOT EXISTS workflow_events (
53
53
  id INTEGER PRIMARY KEY AUTOINCREMENT,
54
54
  session_id TEXT NOT NULL,
@@ -59,7 +59,7 @@ function ensureWorkflowTable(db: Database): void {
59
59
  )
60
60
  `);
61
61
 
62
- db.run(`
62
+ db.exec(`
63
63
  CREATE INDEX IF NOT EXISTS idx_workflow_session
64
64
  ON workflow_events(session_id, timestamp)
65
65
  `);