framedeck-mcp 2.0.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.
Files changed (3) hide show
  1. package/README.md +108 -0
  2. package/index.js +1118 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # framedeck-mcp
2
+
3
+ MCP server for [Framedeck](https://framedeck.app) — connect any AI assistant to your content production pipeline.
4
+
5
+ Works with **Claude**, **ChatGPT**, **Gemini**, **Copilot**, and any MCP-compatible client.
6
+
7
+ ## Quick Start
8
+
9
+ 1. Get your API key at [framedeck.app](https://framedeck.app) > Settings > API Keys
10
+ 2. Add to your AI assistant config (see below)
11
+
12
+ ## Setup
13
+
14
+ ### Claude Code
15
+
16
+ ```bash
17
+ claude mcp add framedeck -e FRAMEDECK_API_KEY=your_key_here -- npx -y framedeck-mcp
18
+ ```
19
+
20
+ ### Claude Desktop
21
+
22
+ Add to your `claude_desktop_config.json`:
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "framedeck": {
28
+ "command": "npx",
29
+ "args": ["-y", "framedeck-mcp"],
30
+ "env": {
31
+ "FRAMEDECK_API_KEY": "your_key_here"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ### Other MCP Clients
39
+
40
+ Set the environment variable `FRAMEDECK_API_KEY` and run:
41
+
42
+ ```bash
43
+ npx framedeck-mcp
44
+ ```
45
+
46
+ ## What You Can Do
47
+
48
+ Talk to your AI assistant naturally:
49
+
50
+ - *"I had 3 video ideas: cooking tutorial, camera review, day in the life"* — creates ideas in your Idea Pool
51
+ - *"The cooking tutorial is a go!"* — graduates it to a full production with stages
52
+ - *"Add tasks: write intro, record B-roll, find music"* — creates frames in your production
53
+ - *"Move the intro to Editing"* — updates your board in real-time
54
+ - *"How's my channel looking?"* — get a full overview with overdue items
55
+
56
+ ## Tools (33)
57
+
58
+ | Tool | Description |
59
+ |------|-------------|
60
+ | `set_active_board` | Set the active production context |
61
+ | `get_active_board` | Show current active production |
62
+ | `list_boards` | List all productions/projects |
63
+ | `create_board` | Create a new production or project |
64
+ | `list_columns` | List stages in a board |
65
+ | `create_column` | Create a new stage |
66
+ | `delete_column` | Delete a stage |
67
+ | `list_cards` | List cards/frames with filters |
68
+ | `create_card` | Create a card/frame |
69
+ | `create_multiple_cards` | Batch create (up to 50) |
70
+ | `update_card` | Update title, description, priority, due date |
71
+ | `move_card` | Move to a different stage |
72
+ | `delete_card` | Permanently delete |
73
+ | `archive_card` | Soft delete (restorable) |
74
+ | `duplicate_card` | Copy with checklist and labels |
75
+ | `set_card_color` | Color-code a card |
76
+ | `add_comment` | Add a comment |
77
+ | `add_checklist_item` | Add a subtask |
78
+ | `toggle_checklist_item` | Check/uncheck a subtask |
79
+ | `add_labels_to_card` | Add labels to a card |
80
+ | `remove_label_from_card` | Remove a label |
81
+ | `get_card_details` | Full card info |
82
+ | `search_cards` | Search by keyword |
83
+ | `assign_card` | Assign a team member |
84
+ | `get_my_cards` | Cards grouped by status |
85
+ | `get_board_overview` | Board summary with overdue items |
86
+ | `list_labels` | List all labels |
87
+ | `create_label` | Create a label |
88
+ | `link_commit` | Link a git commit to a card |
89
+ | `log_work` | Log time spent |
90
+ | `get_sprint_summary` | Activity summary |
91
+ | `graduate_to_production` | Promote idea to full production |
92
+
93
+ ## Dual Mode
94
+
95
+ Framedeck supports two board modes:
96
+
97
+ - **Creator mode**: Productions, Stages, Frames — for content pipelines
98
+ - **Classic mode**: Projects, Stages, Cards — for standard project management
99
+
100
+ The MCP server automatically uses the correct terminology based on each board's mode.
101
+
102
+ ## Idea Pool
103
+
104
+ When you add ideas without specifying a board, they automatically land in your **Idea Pool**. When an idea is ready, use `graduate_to_production` to create a dedicated production with full stages (Idea > Scripting > Filming > Editing > Published).
105
+
106
+ ## License
107
+
108
+ MIT
package/index.js ADDED
@@ -0,0 +1,1118 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+
6
+ // ── Config ──────────────────────────────────────────────
7
+ const API_KEY = process.env.FRAMEDECK_API_KEY || process.env.COMMAND_CENTER_API_KEY;
8
+ const PROXY_URL = process.env.FRAMEDECK_PROXY_URL || process.env.COMMAND_CENTER_PROXY_URL || 'https://iecdacsegqginojacpca.supabase.co/functions/v1/mcp-proxy';
9
+
10
+ // Legacy: direct Supabase connection for local dev
11
+ const SUPABASE_URL = process.env.SUPABASE_URL;
12
+ const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
13
+ const LEGACY_USER_ID = process.env.MCP_USER_ID;
14
+
15
+ const USE_PROXY = !!API_KEY && !SUPABASE_SERVICE_ROLE_KEY;
16
+
17
+ let supabase, USER_ID, USER_PLAN = 'free';
18
+
19
+ if (USE_PROXY) {
20
+ if (!API_KEY) { console.error('Missing: FRAMEDECK_API_KEY. Get your key at https://framedeck.app → Settings → API Keys'); process.exit(1); }
21
+ } else {
22
+ // Legacy direct mode
23
+ if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
24
+ console.error('Missing: SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY, or set FRAMEDECK_API_KEY for proxy mode');
25
+ process.exit(1);
26
+ }
27
+ const { createClient } = await import('@supabase/supabase-js');
28
+ const { createHash } = await import('node:crypto');
29
+ supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
30
+
31
+ if (API_KEY) {
32
+ const keyHash = createHash('sha256').update(API_KEY).digest('hex');
33
+ const { data, error } = await supabase.rpc('validate_api_key', { key_hash: keyHash });
34
+ if (error || !data?.length) { console.error('Invalid API key'); process.exit(1); }
35
+ USER_ID = data[0].user_id;
36
+ USER_PLAN = data[0].plan;
37
+ } else if (LEGACY_USER_ID) {
38
+ USER_ID = LEGACY_USER_ID;
39
+ } else {
40
+ console.error('Missing: FRAMEDECK_API_KEY or MCP_USER_ID');
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ // ── Proxy call helper ───────────────────────────────────
46
+ async function proxyCall(action, params = {}) {
47
+ const res = await fetch(PROXY_URL, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ 'Authorization': `Bearer ${API_KEY}`,
52
+ },
53
+ body: JSON.stringify({ action, params }),
54
+ });
55
+ return await res.json();
56
+ }
57
+
58
+ // ── Direct mode helpers (legacy) ────────────────────────
59
+ async function resolveColumn(nameOrId, boardId = null) {
60
+ let query = supabase.from('columns').select('id, name').eq('id', nameOrId).eq('user_id', USER_ID);
61
+ if (boardId) query = query.eq('board_id', boardId);
62
+ let { data } = await query.maybeSingle();
63
+ if (data) return data;
64
+ // Use limit(1) instead of maybeSingle() to handle duplicate column names gracefully
65
+ let nameQuery = supabase.from('columns').select('id, name').eq('user_id', USER_ID).ilike('name', nameOrId).order('position').limit(1);
66
+ if (boardId) nameQuery = nameQuery.eq('board_id', boardId);
67
+ const { data: byName } = await nameQuery;
68
+ return byName?.[0] || null;
69
+ }
70
+ async function resolveBoard(nameOrId) {
71
+ let { data } = await supabase.from('boards').select('id, name').eq('id', nameOrId).eq('user_id', USER_ID).maybeSingle();
72
+ if (data) return data;
73
+ const { data: byName } = await supabase.from('boards').select('id, name').eq('user_id', USER_ID).ilike('name', nameOrId).order('position').limit(1);
74
+ return byName?.[0] || null;
75
+ }
76
+ async function resolveLabel(nameOrId) {
77
+ let { data } = await supabase.from('tags').select('id, name').eq('id', nameOrId).eq('user_id', USER_ID).maybeSingle();
78
+ if (data) return data;
79
+ const { data: byName } = await supabase.from('tags').select('id, name').eq('user_id', USER_ID).ilike('name', nameOrId).order('position').limit(1);
80
+ return byName?.[0] || null;
81
+ }
82
+ async function resolveCard(nameOrId) {
83
+ // Get user's board IDs to scope card resolution (service role bypasses RLS)
84
+ const { data: userBoards } = await supabase.from('boards').select('id').eq('user_id', USER_ID);
85
+ const boardIds = userBoards?.map(b => b.id) || [];
86
+ if (!boardIds.length) return null;
87
+
88
+ let { data } = await supabase.from('cards').select('id, title').eq('id', nameOrId).in('board_id', boardIds).maybeSingle();
89
+ if (data) return data;
90
+ const { data: byName } = await supabase.from('cards').select('id, title').in('board_id', boardIds).ilike('title', `%${nameOrId}%`).limit(1).maybeSingle();
91
+ return byName;
92
+ }
93
+ async function getMaxPosition(table, filter = {}) {
94
+ let query = supabase.from(table).select('position').order('position', { ascending: false }).limit(1);
95
+ for (const [k, v] of Object.entries(filter)) query = query.eq(k, v);
96
+ const { data } = await query;
97
+ return data?.[0]?.position ?? -1;
98
+ }
99
+
100
+ // ── Broadcast helper — notify browser clients of MCP mutations ──
101
+ // Keep a persistent channel so we don't have to reconnect every time
102
+ let mcpBroadcastChannel = null;
103
+ let mcpChannelReady = false;
104
+
105
+ function initBroadcastChannel() {
106
+ if (!supabase || mcpBroadcastChannel) return;
107
+ mcpBroadcastChannel = supabase.channel('mcp-changes');
108
+ mcpBroadcastChannel.subscribe((status) => {
109
+ mcpChannelReady = status === 'SUBSCRIBED';
110
+ });
111
+ }
112
+
113
+ async function broadcastChange(table) {
114
+ if (!mcpBroadcastChannel || !mcpChannelReady) return;
115
+ try {
116
+ await mcpBroadcastChannel.send({ type: 'broadcast', event: 'mutation', payload: { table } });
117
+ } catch (_) { /* best-effort */ }
118
+ }
119
+
120
+ // Initialize the channel at startup (only in direct/legacy mode)
121
+ if (supabase) initBroadcastChannel();
122
+
123
+ // ── Terminology helper ─────────────────────────────────
124
+ // Returns mode-aware labels based on board mode (creator vs classic)
125
+ function terms(mode) {
126
+ if (mode === 'creator') return { board: 'production', Board: 'Production', card: 'frame', Card: 'Frame', cards: 'frames', Cards: 'Frames', stage: 'stage', Stage: 'Stage' };
127
+ return { board: 'project', Board: 'Project', card: 'card', Card: 'Card', cards: 'cards', Cards: 'Cards', stage: 'stage', Stage: 'Stage' };
128
+ }
129
+
130
+ // Look up a board's mode and return its terms
131
+ async function boardTerms(boardId) {
132
+ if (!boardId) return terms('classic');
133
+ const { data } = await supabase.from('boards').select('mode').eq('id', boardId).eq('user_id', USER_ID).single();
134
+ return terms(data?.mode);
135
+ }
136
+
137
+ // ── Active board context ───────────────────────────────
138
+ // Stores the "current" board so users don't have to specify it every time.
139
+ // Set via set_active_board, used as fallback when board param is omitted.
140
+ let activeBoardId = null;
141
+ let activeBoardName = null;
142
+ let activeBoardMode = 'classic';
143
+
144
+ function activeTerms() { return terms(activeBoardMode); }
145
+
146
+ async function requireBoard(boardParam) {
147
+ if (boardParam) {
148
+ const brd = await resolveBoard(boardParam);
149
+ return brd;
150
+ }
151
+ if (activeBoardId) {
152
+ return { id: activeBoardId, name: activeBoardName };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ // ── Idea Pool auto-creation ────────────────────────────
158
+ // When no board specified and no active board, find or create "Idea Pool"
159
+ const IDEA_POOL_NAME = 'Idea Pool';
160
+ const IDEA_POOL_ICON = '💡';
161
+ const IDEA_POOL_COLUMN = 'Ideas';
162
+
163
+ async function ensureIdeaPool() {
164
+ // Check if Idea Pool already exists
165
+ const { data: existing } = await supabase.from('boards')
166
+ .select('id, name').eq('user_id', USER_ID).ilike('name', IDEA_POOL_NAME).limit(1);
167
+ if (existing?.length) return existing[0];
168
+
169
+ // Create it — handle race condition (concurrent calls may both pass the check above)
170
+ const maxPos = await getMaxPosition('boards', { user_id: USER_ID });
171
+ const { data: pool, error } = await supabase.from('boards').insert({
172
+ name: IDEA_POOL_NAME, icon: IDEA_POOL_ICON, color: '#f59e0b',
173
+ position: maxPos + 1, user_id: USER_ID, mode: 'creator',
174
+ }).select().single();
175
+ if (error) {
176
+ // Race condition: another call created it first — find and return it
177
+ const { data: raceResult } = await supabase.from('boards')
178
+ .select('id, name').eq('user_id', USER_ID).ilike('name', IDEA_POOL_NAME).limit(1);
179
+ return raceResult?.[0] || null;
180
+ }
181
+
182
+ // Add the owner as member
183
+ const { data: user } = await supabase.auth.admin.getUserById(USER_ID);
184
+ await supabase.from('project_members').upsert({
185
+ board_id: pool.id, user_id: USER_ID,
186
+ user_email: user?.user?.email || '', user_avatar: user?.user?.user_metadata?.avatar_url || '',
187
+ role: 'owner',
188
+ }, { onConflict: 'board_id,user_id' });
189
+
190
+ // Create the two stages: Ideas + In Production
191
+ await supabase.from('columns').insert({
192
+ name: 'Ideas', color: '#6b7280', position: 0, board_id: pool.id, user_id: USER_ID,
193
+ });
194
+ await supabase.from('columns').insert({
195
+ name: 'In Production', color: '#10b981', position: 1, board_id: pool.id, user_id: USER_ID,
196
+ });
197
+
198
+ await broadcastChange('boards');
199
+ await broadcastChange('columns');
200
+ return pool;
201
+ }
202
+
203
+ // ── Generic tool handler ────────────────────────────────
204
+ function handleTool(action, directHandler) {
205
+ return async (params) => {
206
+ if (USE_PROXY) {
207
+ const result = await proxyCall(action, params);
208
+ const text = result.ok ? (result.data || 'Done.') : `Error: ${result.error}`;
209
+ return { content: [{ type: 'text', text }] };
210
+ }
211
+ return directHandler(params);
212
+ };
213
+ }
214
+
215
+ // ── MCP Server ──────────────────────────────────────────
216
+ const server = new McpServer({ name: 'framedeck', version: '2.0.0' });
217
+
218
+ // ── list_boards ─────────────────────────────────────────
219
+ server.tool('list_boards', 'List all projects/productions in Framedeck', {},
220
+ handleTool('list_boards', async () => {
221
+ const { data, error } = await supabase.from('boards').select('id, name, icon, color, position, mode').eq('user_id', USER_ID).order('position');
222
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
223
+ const text = data.map(b => `${b.icon || '📋'} ${b.name} [${b.mode || 'classic'}] (${b.id})`).join('\n') || 'No boards found.';
224
+ return { content: [{ type: 'text', text }] };
225
+ })
226
+ );
227
+
228
+ // ── create_board ────────────────────────────────────────
229
+ server.tool('create_board', 'Create a new project/board in Framedeck', {
230
+ name: z.string().describe('Board name'),
231
+ icon: z.string().optional().describe('Emoji icon for the board (default: 📋)'),
232
+ color: z.string().regex(/^#[0-9a-fA-F]{3,8}$/).optional().describe('Hex color for the board (default: #3b82f6)'),
233
+ mode: z.enum(['creator', 'classic']).optional().describe('Board mode: creator (Productions/Stages/Frames) or classic (Projects/Stages/Cards). Default: classic'),
234
+ }, handleTool('create_board', async ({ name, icon, color, mode }) => {
235
+ if (USER_PLAN === 'free') {
236
+ const { count } = await supabase.from('boards').select('*', { count: 'exact', head: true }).eq('user_id', USER_ID);
237
+ if (count >= 2) return { content: [{ type: 'text', text: 'Free plan limit reached (2 boards). Upgrade to create more.' }] };
238
+ }
239
+ const maxPos = await getMaxPosition('boards', { user_id: USER_ID });
240
+ const { data, error } = await supabase.from('boards').insert({
241
+ name, icon: icon || '📋', color: color || '#3b82f6', position: maxPos + 1, user_id: USER_ID, mode: mode || 'classic',
242
+ }).select().single();
243
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
244
+ const { data: user } = await supabase.auth.admin.getUserById(USER_ID);
245
+ await supabase.from('project_members').upsert({
246
+ board_id: data.id, user_id: USER_ID,
247
+ user_email: user?.user?.email || '', user_avatar: user?.user?.user_metadata?.avatar_url || '',
248
+ role: 'owner',
249
+ }, { onConflict: 'board_id,user_id' });
250
+ return { content: [{ type: 'text', text: `Created board: ${data.icon} ${data.name} (${data.id})` }] };
251
+ }));
252
+
253
+ // ── set_active_board ───────────────────────────────────
254
+ server.tool('set_active_board', 'Set the active board/production context. Once set, other tools will use this board by default when no board is specified.', {
255
+ board: z.string().describe('Board/production name or ID to set as active'),
256
+ }, handleTool('set_active_board', async ({ board }) => {
257
+ const brd = await resolveBoard(board);
258
+ if (!brd) return { content: [{ type: 'text', text: `Board "${board}" not found.` }] };
259
+ activeBoardId = brd.id;
260
+ activeBoardName = brd.name;
261
+ // Fetch full board info for richer response
262
+ const { data: fullBoard } = await supabase.from('boards').select('icon, mode').eq('id', brd.id).single();
263
+ activeBoardMode = fullBoard?.mode || 'classic';
264
+ const t = activeTerms();
265
+ const { data: cols } = await supabase.from('columns').select('name').eq('board_id', brd.id).order('position');
266
+ const stages = (cols || []).map(c => c.name).join(' → ');
267
+ return { content: [{ type: 'text', text: `Active ${t.board} set to: ${fullBoard?.icon || '📋'} ${brd.name}\nStages: ${stages || 'none'}` }] };
268
+ }));
269
+
270
+ // ── get_active_board ───────────────────────────────────
271
+ server.tool('get_active_board', 'Show which board/production is currently set as the active context.', {},
272
+ handleTool('get_active_board', async () => {
273
+ if (!activeBoardId) return { content: [{ type: 'text', text: 'No active board set. Use set_active_board to set one.' }] };
274
+ const { data: brd } = await supabase.from('boards').select('name, icon, mode').eq('id', activeBoardId).single();
275
+ if (!brd) { activeBoardId = null; activeBoardName = null; activeBoardMode = 'classic'; return { content: [{ type: 'text', text: 'Active board no longer exists.' }] }; }
276
+ const t = terms(brd.mode);
277
+ const { data: cols } = await supabase.from('columns').select('name').eq('board_id', activeBoardId).order('position');
278
+ const { data: cards } = await supabase.from('cards').select('id').eq('board_id', activeBoardId).eq('archived', false);
279
+ const stages = (cols || []).map(c => c.name).join(' → ');
280
+ return { content: [{ type: 'text', text: `Active ${t.board}: ${brd.icon || '📋'} ${brd.name}\nStages: ${stages}\n${t.Cards}: ${(cards || []).length}` }] };
281
+ })
282
+ );
283
+
284
+ // ── list_columns ────────────────────────────────────────
285
+ server.tool('list_columns', 'List stages in a board. Uses active board if set and no board specified.', {
286
+ board: z.string().optional().describe('Board/production name or ID (uses active board if omitted and set)'),
287
+ }, handleTool('list_columns', async ({ board }) => {
288
+ let query = supabase.from('columns').select('id, name, color, position, board_id').eq('user_id', USER_ID).order('position');
289
+ if (board) {
290
+ const brd = await resolveBoard(board);
291
+ if (!brd) return { content: [{ type: 'text', text: `Board "${board}" not found.` }] };
292
+ query = query.eq('board_id', brd.id);
293
+ } else if (activeBoardId) {
294
+ query = query.eq('board_id', activeBoardId);
295
+ }
296
+ const { data, error } = await query;
297
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
298
+ return { content: [{ type: 'text', text: data.map(c => `${c.name} (${c.id})`).join('\n') || 'No columns found.' }] };
299
+ })
300
+ );
301
+
302
+ // ── list_labels ─────────────────────────────────────────
303
+ server.tool('list_labels', 'List all labels/tags', {},
304
+ handleTool('list_labels', async () => {
305
+ const { data, error } = await supabase.from('tags').select('id, name, color, position').eq('user_id', USER_ID).order('position');
306
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
307
+ return { content: [{ type: 'text', text: data.map(t => `${t.name} (${t.id})`).join('\n') || 'No labels found.' }] };
308
+ })
309
+ );
310
+
311
+ // ── create_label ────────────────────────────────────────
312
+ server.tool('create_label', 'Create a new label/tag', {
313
+ name: z.string().describe('Label name'),
314
+ color: z.string().regex(/^#[0-9a-fA-F]{3,8}$/).optional().describe('Hex color for the label (default: #6b7280)'),
315
+ }, handleTool('create_label', async ({ name, color }) => {
316
+ const maxPos = await getMaxPosition('tags', { user_id: USER_ID });
317
+ const { data, error } = await supabase.from('tags').insert({
318
+ name, color: color || '#6b7280', position: maxPos + 1, user_id: USER_ID,
319
+ }).select().single();
320
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
321
+ return { content: [{ type: 'text', text: `Created label: ${data.name} (${data.id})` }] };
322
+ }));
323
+
324
+ // ── list_cards ──────────────────────────────────────────
325
+ server.tool('list_cards', 'List cards/frames with optional filters. Uses active board if set and no board specified.', {
326
+ board: z.string().optional().describe('Board/production name or ID (uses active board if omitted and set)'),
327
+ column: z.string().optional().describe('Stage name or ID to filter by'),
328
+ priority: z.string().optional().describe('Priority to filter by: urgent, high, medium, low'),
329
+ limit: z.number().optional().describe('Max number of cards to return (default 50)'),
330
+ }, handleTool('list_cards', async ({ board, column, priority, limit }) => {
331
+ let query = supabase.from('cards').select('id, title, description, column_name, board_id, priority, due_date, position').order('position');
332
+ if (board) {
333
+ const b = await resolveBoard(board);
334
+ if (!b) return { content: [{ type: 'text', text: `Board "${board}" not found.` }] };
335
+ query = query.eq('board_id', b.id);
336
+ } else if (activeBoardId) {
337
+ query = query.eq('board_id', activeBoardId);
338
+ } else {
339
+ const { data: userBoards } = await supabase.from('boards').select('id').eq('user_id', USER_ID);
340
+ if (userBoards?.length) query = query.in('board_id', userBoards.map(b => b.id));
341
+ }
342
+ if (column) {
343
+ const c = await resolveColumn(column);
344
+ if (!c) return { content: [{ type: 'text', text: `Column "${column}" not found.` }] };
345
+ query = query.eq('column_name', c.id);
346
+ }
347
+ if (priority) query = query.eq('priority', priority);
348
+ query = query.limit(limit || 50);
349
+ const { data, error } = await query;
350
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
351
+ const { data: cols } = await supabase.from('columns').select('id, name').eq('user_id', USER_ID);
352
+ const { data: boards } = await supabase.from('boards').select('id, name, icon').eq('user_id', USER_ID);
353
+ const colMap = Object.fromEntries((cols || []).map(c => [c.id, c.name]));
354
+ const boardMap = Object.fromEntries((boards || []).map(b => [b.id, `${b.icon || ''} ${b.name}`]));
355
+ const text = data.map(card => {
356
+ const parts = [`• ${card.title}`];
357
+ if (card.priority) parts.push(`[${card.priority.toUpperCase()}]`);
358
+ parts.push(`| ${colMap[card.column_name] || 'Unknown'}`);
359
+ parts.push(`| ${boardMap[card.board_id] || 'Unknown'}`);
360
+ if (card.due_date) parts.push(`| Due: ${card.due_date}`);
361
+ return parts.join(' ');
362
+ }).join('\n') || 'No cards found.';
363
+ const scopedBoardId = board ? (await resolveBoard(board))?.id : activeBoardId;
364
+ const t = await boardTerms(scopedBoardId);
365
+ const boardContext = board ? `in "${board}"` : activeBoardId ? `in "${activeBoardName}"` : `across all ${t.board}s`;
366
+ return { content: [{ type: 'text', text: `📋 ${data.length} ${t.card}(s) ${boardContext}:\n${text}` }] };
367
+ }));
368
+
369
+ // ── create_card ─────────────────────────────────────────
370
+ server.tool('create_card', 'Create a new card/frame. Uses active board if set, or Idea Pool as fallback for quick idea capture.', {
371
+ title: z.string().describe('Card title'),
372
+ description: z.string().optional().describe('Card description'),
373
+ column: z.string().optional().describe('Stage name or ID (e.g. "Idea", "Scripting", "To Do"). Defaults to first stage.'),
374
+ board: z.string().optional().describe('Board/production name or ID (uses active board or Idea Pool if omitted)'),
375
+ priority: z.string().optional().describe('Priority: urgent, high, medium, low'),
376
+ labels: z.array(z.string()).optional().describe('Label names or IDs to attach'),
377
+ due_date: z.string().optional().describe('Due date (YYYY-MM-DD)'),
378
+ }, handleTool('create_card', async ({ title, description, column, board, priority, labels, due_date }) => {
379
+ let brd = await requireBoard(board);
380
+ let usedIdeaPool = false;
381
+ if (!brd) {
382
+ // No board specified, no active board → use Idea Pool
383
+ brd = await ensureIdeaPool();
384
+ usedIdeaPool = true;
385
+ if (!brd) return { content: [{ type: 'text', text: 'Could not find or create Idea Pool.' }] };
386
+ }
387
+ let col;
388
+ if (column) {
389
+ col = await resolveColumn(column, brd.id);
390
+ if (!col) return { content: [{ type: 'text', text: `Stage "${column}" not found in ${brd.name}.` }] };
391
+ } else {
392
+ // Default to first column in the board
393
+ const { data: firstCol } = await supabase.from('columns').select('id, name').eq('board_id', brd.id).order('position').limit(1);
394
+ col = firstCol?.[0];
395
+ if (!col) return { content: [{ type: 'text', text: `No stages found in ${brd.name}. Create a stage first.` }] };
396
+ }
397
+ const maxPos = await getMaxPosition('cards', { column_name: col.id });
398
+ const { data: card, error } = await supabase.from('cards').insert({
399
+ title, description: description || '', column_name: col.id, board_id: brd.id,
400
+ priority: priority || '', due_date: due_date || '', position: maxPos + 1, color: '', cover_image: '',
401
+ }).select().single();
402
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
403
+ if (labels?.length && card) {
404
+ for (const labelName of labels) {
405
+ const label = await resolveLabel(labelName);
406
+ if (label) await supabase.from('card_tags').insert({ card_id: card.id, tag_id: label.id });
407
+ }
408
+ }
409
+ await supabase.from('activity_log').insert({
410
+ user_id: USER_ID, user_email: 'Claude Code', action: 'created',
411
+ entity_type: 'card', entity_name: title, details: `in ${col.name} (${brd.name})`,
412
+ });
413
+ await broadcastChange('cards');
414
+ const t = await boardTerms(brd.id);
415
+ const poolNote = usedIdeaPool ? ' 💡 Added to Idea Pool.' : '';
416
+ return { content: [{ type: 'text', text: `Created ${t.card} "${title}" in ${col.name} (${brd.name})${priority ? ` [${priority.toUpperCase()}]` : ''}.${poolNote} ID: ${card.id}` }] };
417
+ }));
418
+
419
+ // ── update_card ─────────────────────────────────────────
420
+ server.tool('update_card', 'Update an existing card/frame', {
421
+ id: z.string().describe('Card ID or title (partial match supported)'),
422
+ title: z.string().optional().describe('New title'),
423
+ description: z.string().optional().describe('New description'),
424
+ column: z.string().optional().describe('Move to stage (name or ID)'),
425
+ priority: z.string().optional().describe('New priority: urgent, high, medium, low, or empty to clear'),
426
+ due_date: z.string().optional().describe('New due date (YYYY-MM-DD) or empty to clear'),
427
+ }, handleTool('update_card', async ({ id, title, description, column, priority, due_date }) => {
428
+ const card = await resolveCard(id);
429
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
430
+ const updates = {};
431
+ if (title !== undefined) updates.title = title;
432
+ if (description !== undefined) updates.description = description;
433
+ if (priority !== undefined) updates.priority = priority;
434
+ if (due_date !== undefined) updates.due_date = due_date;
435
+ if (column) {
436
+ const col = await resolveColumn(column);
437
+ if (!col) return { content: [{ type: 'text', text: `Column "${column}" not found.` }] };
438
+ updates.column_name = col.id;
439
+ }
440
+ const { data, error } = await supabase.from('cards').update(updates).eq('id', card.id).select('title').single();
441
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
442
+ await broadcastChange('cards');
443
+ return { content: [{ type: 'text', text: `Updated card "${data.title}".` }] };
444
+ }));
445
+
446
+ // ── move_card ───────────────────────────────────────────
447
+ server.tool('move_card', 'Move a card/frame to a different stage', {
448
+ id: z.string().describe('Card ID or title (partial match supported)'),
449
+ column: z.string().describe('Target stage name or ID'),
450
+ }, handleTool('move_card', async ({ id, column }) => {
451
+ const card = await resolveCard(id);
452
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
453
+ const { data: fullCard } = await supabase.from('cards').select('board_id').eq('id', card.id).single();
454
+ const col = await resolveColumn(column, fullCard?.board_id);
455
+ if (!col) return { content: [{ type: 'text', text: `Column "${column}" not found.` }] };
456
+ const maxPos = await getMaxPosition('cards', { column_name: col.id });
457
+ const { error } = await supabase.from('cards').update({ column_name: col.id, position: maxPos + 1 }).eq('id', card.id);
458
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
459
+ await supabase.from('activity_log').insert({
460
+ user_id: USER_ID, user_email: 'Claude Code', action: 'moved',
461
+ entity_type: 'card', entity_name: card.title, details: `to ${col.name}`,
462
+ });
463
+ await broadcastChange('cards');
464
+ return { content: [{ type: 'text', text: `Moved "${card.title}" to ${col.name}.` }] };
465
+ }));
466
+
467
+ // ── delete_card ─────────────────────────────────────────
468
+ server.tool('delete_card', 'Permanently delete a card/frame', {
469
+ id: z.string().describe('Card ID or title (partial match supported)'),
470
+ }, handleTool('delete_card', async ({ id }) => {
471
+ const card = await resolveCard(id);
472
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
473
+ const { data: cardInfo } = await supabase.from('cards').select('board_id').eq('id', card.id).single();
474
+ const t = await boardTerms(cardInfo?.board_id);
475
+ const { error } = await supabase.from('cards').delete().eq('id', card.id);
476
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
477
+ await broadcastChange('cards');
478
+ return { content: [{ type: 'text', text: `Deleted ${t.card} "${card.title}".` }] };
479
+ }));
480
+
481
+ // ── add_comment ─────────────────────────────────────────
482
+ server.tool('add_comment', 'Add a comment to a card', {
483
+ card_id: z.string().describe('Card ID or title (partial match supported)'),
484
+ text: z.string().describe('Comment text'),
485
+ }, handleTool('add_comment', async ({ card_id, text }) => {
486
+ const card = await resolveCard(card_id);
487
+ if (!card) return { content: [{ type: 'text', text: 'Card not found or access denied.' }] };
488
+ const { error } = await supabase.from('comments').insert({
489
+ card_id: card.id, text, user_id: USER_ID, user_email: 'Claude Code', user_avatar: '',
490
+ });
491
+ if (error) return { content: [{ type: 'text', text: 'Failed to add comment.' }] };
492
+ await broadcastChange('cards');
493
+ return { content: [{ type: 'text', text: 'Comment added to card.' }] };
494
+ }));
495
+
496
+ // ── add_checklist_item ──────────────────────────────────
497
+ server.tool('add_checklist_item', 'Add a subtask/checklist item to a card', {
498
+ card_id: z.string().describe('Card ID or title (partial match supported)'),
499
+ text: z.string().describe('Subtask text'),
500
+ }, handleTool('add_checklist_item', async ({ card_id, text }) => {
501
+ const card = await resolveCard(card_id);
502
+ if (!card) return { content: [{ type: 'text', text: 'Card not found or access denied.' }] };
503
+ const maxPos = await getMaxPosition('checklist_items', { card_id: card.id });
504
+ const { error } = await supabase.from('checklist_items').insert({
505
+ card_id: card.id, text, checked: false, position: maxPos + 1,
506
+ });
507
+ if (error) return { content: [{ type: 'text', text: 'Failed to add checklist item.' }] };
508
+ await broadcastChange('cards');
509
+ return { content: [{ type: 'text', text: `Checklist item "${text}" added.` }] };
510
+ }));
511
+
512
+ // ── get_card_details ────────────────────────────────────
513
+ server.tool('get_card_details', 'Get full details of a card including comments, checklists, attachments, and labels', {
514
+ id: z.string().describe('Card ID or title (partial match supported)'),
515
+ }, handleTool('get_card_details', async ({ id }) => {
516
+ const card = await resolveCard(id);
517
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
518
+ const { data: fullCard } = await supabase.from('cards').select('*').eq('id', card.id).single();
519
+ const { data: comments } = await supabase.from('comments').select('text, user_email, created_at').eq('card_id', card.id).order('created_at');
520
+ const { data: checklist } = await supabase.from('checklist_items').select('text, checked, position').eq('card_id', card.id).order('position');
521
+ const { data: attachments } = await supabase.from('attachments').select('file_name, file_url, file_type, created_at').eq('card_id', card.id).order('created_at');
522
+ const { data: cardTags } = await supabase.from('card_tags').select('tag_id').eq('card_id', card.id);
523
+ let labels = [];
524
+ if (cardTags?.length) {
525
+ const tagIds = cardTags.map(ct => ct.tag_id);
526
+ const { data: tags } = await supabase.from('tags').select('name').in('id', tagIds);
527
+ labels = (tags || []).map(t => t.name);
528
+ }
529
+ let output = `# ${fullCard.title}\n`;
530
+ if (fullCard.description) output += `\n${fullCard.description}\n`;
531
+ if (fullCard.priority) output += `\nPriority: ${fullCard.priority.toUpperCase()}`;
532
+ if (fullCard.due_date) output += `\nDue: ${fullCard.due_date}`;
533
+ if (labels.length) output += `\nLabels: ${labels.join(', ')}`;
534
+ if (fullCard.assignee_email) output += `\nAssigned to: ${fullCard.assignee_email}`;
535
+ output += `\nID: ${fullCard.id}`;
536
+ if (checklist?.length) {
537
+ const done = checklist.filter(i => i.checked).length;
538
+ output += `\n\nChecklist (${done}/${checklist.length}):\n`;
539
+ output += checklist.map(i => ` ${i.checked ? '[x]' : '[ ]'} ${i.text}`).join('\n');
540
+ }
541
+ if (comments?.length) {
542
+ output += `\n\nComments (${comments.length}):\n`;
543
+ output += comments.map(c => ` ${c.user_email} (${new Date(c.created_at).toLocaleDateString()}): ${c.text}`).join('\n');
544
+ }
545
+ if (attachments?.length) {
546
+ output += `\n\nAttachments (${attachments.length}):\n`;
547
+ output += attachments.map(a => ` ${a.file_name} (${a.file_type})`).join('\n');
548
+ }
549
+ return { content: [{ type: 'text', text: output }] };
550
+ }));
551
+
552
+ // ── search_cards ────────────────────────────────────────
553
+ server.tool('search_cards', 'Search for cards by keyword across all boards', {
554
+ query: z.string().describe('Search keyword to match against card titles and descriptions'),
555
+ limit: z.number().optional().describe('Max results (default 20)'),
556
+ }, handleTool('search_cards', async ({ query, limit }) => {
557
+ const { data: userBoards } = await supabase.from('boards').select('id, name, icon').eq('user_id', USER_ID);
558
+ if (!userBoards?.length) return { content: [{ type: 'text', text: 'No boards found.' }] };
559
+ const boardIds = userBoards.map(b => b.id);
560
+ const boardMap = Object.fromEntries(userBoards.map(b => [b.id, b]));
561
+ const { data: cards, error } = await supabase.from('cards')
562
+ .select('id, title, description, column_name, board_id, priority, due_date')
563
+ .in('board_id', boardIds)
564
+ .or(`title.ilike.%${query.replace(/[%_,()]/g, '')}%,description.ilike.%${query.replace(/[%_,()]/g, '')}%`)
565
+ .limit(limit || 20);
566
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
567
+ if (!cards?.length) return { content: [{ type: 'text', text: `No cards matching "${query}".` }] };
568
+ const { data: cols } = await supabase.from('columns').select('id, name').eq('user_id', USER_ID);
569
+ const colMap = Object.fromEntries((cols || []).map(c => [c.id, c.name]));
570
+ const text = cards.map(card => {
571
+ const brd = boardMap[card.board_id];
572
+ const parts = [`• ${card.title}`];
573
+ if (card.priority) parts.push(`[${card.priority.toUpperCase()}]`);
574
+ parts.push(`| ${colMap[card.column_name] || 'Unknown'}`);
575
+ if (brd) parts.push(`| ${brd.icon || ''} ${brd.name}`);
576
+ return parts.join(' ');
577
+ }).join('\n');
578
+ return { content: [{ type: 'text', text: `🔍 ${cards.length} result(s) for "${query}" across all boards:\n${text}` }] };
579
+ }));
580
+
581
+ // ── assign_card ─────────────────────────────────────────
582
+ server.tool('assign_card', 'Assign or unassign a user to a card', {
583
+ id: z.string().describe('Card ID or title (partial match supported)'),
584
+ email: z.string().email().optional().describe('Email of the user to assign'),
585
+ unassign: z.boolean().optional().describe('Set to true to unassign the current assignee'),
586
+ }, handleTool('assign_card', async ({ id, email, unassign }) => {
587
+ const card = await resolveCard(id);
588
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
589
+ if (unassign) {
590
+ const { error } = await supabase.from('cards').update({ assignee_id: null, assignee_email: null, assignee_avatar: null }).eq('id', card.id);
591
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
592
+ await broadcastChange('cards');
593
+ return { content: [{ type: 'text', text: `Unassigned "${card.title}".` }] };
594
+ }
595
+ if (!email) return { content: [{ type: 'text', text: 'Email is required to assign a card.' }] };
596
+ const { data: fullCard } = await supabase.from('cards').select('board_id').eq('id', card.id).single();
597
+ const { data: member } = await supabase.from('project_members')
598
+ .select('user_id, user_email, user_avatar')
599
+ .eq('board_id', fullCard.board_id).ilike('user_email', email).maybeSingle();
600
+ const { error } = await supabase.from('cards').update({
601
+ assignee_id: member?.user_id || null,
602
+ assignee_email: member?.user_email || email,
603
+ assignee_avatar: member?.user_avatar || '',
604
+ }).eq('id', card.id);
605
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
606
+ await supabase.from('activity_log').insert({
607
+ user_id: USER_ID, user_email: 'Claude Code', action: 'assigned',
608
+ entity_type: 'card', entity_name: card.title, details: `to ${member?.user_email || email}`,
609
+ });
610
+ await broadcastChange('cards');
611
+ return { content: [{ type: 'text', text: `Assigned "${card.title}" to ${member?.user_email || email}.` }] };
612
+ }));
613
+
614
+ // ── get_my_cards ────────────────────────────────────────
615
+ server.tool('get_my_cards', 'Get cards assigned to the current user, grouped by status.', {
616
+ board: z.string().optional().describe('Board/production name or ID (uses active board if omitted and set)'),
617
+ include_done: z.boolean().optional().describe('Include completed cards (default: false)'),
618
+ }, handleTool('get_my_cards', async ({ board, include_done }) => {
619
+ const { data: userBoards } = await supabase.from('boards').select('id, name, icon').eq('user_id', USER_ID);
620
+ if (!userBoards?.length) return { content: [{ type: 'text', text: 'No boards found.' }] };
621
+ const boardMap = Object.fromEntries(userBoards.map(b => [b.id, b]));
622
+ let boardIds = userBoards.map(b => b.id);
623
+ if (board) {
624
+ const b = await resolveBoard(board);
625
+ if (!b) return { content: [{ type: 'text', text: `Board "${board}" not found.` }] };
626
+ boardIds = [b.id];
627
+ } else if (activeBoardId) {
628
+ boardIds = [activeBoardId];
629
+ }
630
+ const { data: cols } = await supabase.from('columns').select('id, name, position').eq('user_id', USER_ID).order('position');
631
+ const colMap = Object.fromEntries((cols || []).map(c => [c.id, c]));
632
+ const { data: cards, error } = await supabase.from('cards')
633
+ .select('id, title, description, column_name, board_id, priority, due_date, position')
634
+ .in('board_id', boardIds).order('position');
635
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
636
+ const groups = {};
637
+ for (const card of cards) {
638
+ const colName = colMap[card.column_name]?.name || 'Unknown';
639
+ const colPos = colMap[card.column_name]?.position ?? 999;
640
+ if (!include_done && (colName.toLowerCase() === 'done' || colName.toLowerCase() === 'archive')) continue;
641
+ if (!groups[colName]) groups[colName] = { position: colPos, cards: [] };
642
+ groups[colName].cards.push(card);
643
+ }
644
+ const sortedGroups = Object.entries(groups).sort((a, b) => a[1].position - b[1].position);
645
+ if (sortedGroups.length === 0) return { content: [{ type: 'text', text: 'No active cards found.' }] };
646
+ const cardIds = cards.map(c => c.id);
647
+ const { data: checklistItems } = await supabase.from('checklist_items').select('card_id, checked').in('card_id', cardIds);
648
+ const checklistMap = {};
649
+ for (const item of (checklistItems || [])) {
650
+ if (!checklistMap[item.card_id]) checklistMap[item.card_id] = { total: 0, done: 0 };
651
+ checklistMap[item.card_id].total++;
652
+ if (item.checked) checklistMap[item.card_id].done++;
653
+ }
654
+ let output = '';
655
+ let totalCards = 0;
656
+ for (const [colName, group] of sortedGroups) {
657
+ output += `\n── ${colName} (${group.cards.length}) ──\n`;
658
+ for (const card of group.cards) {
659
+ totalCards++;
660
+ const brd = boardMap[card.board_id];
661
+ const parts = [` • ${card.title}`];
662
+ if (card.priority) parts.push(`[${card.priority.toUpperCase()}]`);
663
+ if (brd) parts.push(`| ${brd.icon || ''} ${brd.name}`);
664
+ if (card.due_date) {
665
+ const due = new Date(card.due_date);
666
+ const now = new Date();
667
+ const diffDays = Math.ceil((due - now) / (1000 * 60 * 60 * 24));
668
+ if (diffDays < 0) parts.push(`| ⚠️ OVERDUE by ${Math.abs(diffDays)}d`);
669
+ else if (diffDays === 0) parts.push(`| 📅 Due TODAY`);
670
+ else if (diffDays <= 3) parts.push(`| 📅 Due in ${diffDays}d`);
671
+ else parts.push(`| 📅 ${card.due_date}`);
672
+ }
673
+ const cl = checklistMap[card.id];
674
+ if (cl) parts.push(`| ✅ ${cl.done}/${cl.total}`);
675
+ output += parts.join(' ') + '\n';
676
+ }
677
+ }
678
+ const t = boardIds.length === 1 ? await boardTerms(boardIds[0]) : terms('classic');
679
+ const boardContext = board ? board : (boardIds.length === 1 ? boardMap[boardIds[0]]?.name : `${boardIds.length} ${t.board}s`);
680
+ return { content: [{ type: 'text', text: `📋 ${totalCards} active ${t.card}(s) in ${boardContext} across ${sortedGroups.length} stage(s)\n${output}` }] };
681
+ }));
682
+
683
+ // ── link_commit ─────────────────────────────────────────
684
+ server.tool('link_commit', 'Link a git commit to a card. Automatically adds commit info as a comment.', {
685
+ card_id: z.string().describe('Card ID or title (partial match supported)'),
686
+ sha: z.string().describe('Git commit SHA (short or full)'),
687
+ message: z.string().describe('Commit message'),
688
+ branch: z.string().optional().describe('Branch name'),
689
+ repo: z.string().optional().describe('Repository name or URL'),
690
+ }, handleTool('link_commit', async ({ card_id, sha, message, branch, repo }) => {
691
+ const card = await resolveCard(card_id);
692
+ if (!card) return { content: [{ type: 'text', text: `Card "${card_id}" not found.` }] };
693
+ const shortSha = sha.substring(0, 7);
694
+ let commentText = `🔗 Commit \`${shortSha}\`: ${message}`;
695
+ if (branch) commentText += `\nBranch: ${branch}`;
696
+ if (repo) commentText += `\nRepo: ${repo}`;
697
+ const { error } = await supabase.from('comments').insert({
698
+ card_id: card.id, text: commentText, user_id: USER_ID, user_email: 'Claude Code', user_avatar: '',
699
+ });
700
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
701
+ await supabase.from('activity_log').insert({
702
+ user_id: USER_ID, user_email: 'Claude Code', action: 'linked_commit',
703
+ entity_type: 'card', entity_name: card.title, details: `commit ${shortSha}`,
704
+ });
705
+ return { content: [{ type: 'text', text: `Linked commit ${shortSha} to "${card.title}".` }] };
706
+ }));
707
+
708
+ // ── log_work ────────────────────────────────────────────
709
+ server.tool('log_work', 'Logs time spent on a card', {
710
+ card_id: z.string().describe('Card ID'),
711
+ hours: z.number().describe('Number of hours worked'),
712
+ description: z.string().optional().describe('Description of work done'),
713
+ }, handleTool('log_work', async ({ card_id, hours, description }) => {
714
+ const card = await resolveCard(card_id);
715
+ if (!card) return { content: [{ type: 'text', text: `Card "${card_id}" not found.` }] };
716
+ const commentText = `⏱️ Logged ${hours}h${description ? `: ${description}` : ''}`;
717
+ const { error } = await supabase.from('comments').insert({
718
+ card_id: card.id, text: commentText, user_id: USER_ID, user_email: 'Claude Code', user_avatar: '',
719
+ });
720
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
721
+ await supabase.from('activity_log').insert({
722
+ user_id: USER_ID, user_email: 'Claude Code', action: 'logged_work',
723
+ entity_type: 'card', entity_name: card.title, details: `${hours}h${description ? ` - ${description}` : ''}`,
724
+ });
725
+ return { content: [{ type: 'text', text: `Logged ${hours}h on "${card.title}".` }] };
726
+ }));
727
+
728
+ // ── get_sprint_summary ──────────────────────────────────
729
+ server.tool('get_sprint_summary', 'Shows a summary of activity across all boards for the past N days', {
730
+ days: z.number().optional().describe('How many days to look back (default 7)'),
731
+ }, handleTool('get_sprint_summary', async ({ days }) => {
732
+ const lookback = days || 7;
733
+ const since = new Date();
734
+ since.setDate(since.getDate() - lookback);
735
+ const sinceISO = since.toISOString();
736
+
737
+ const { data: userBoards } = await supabase.from('boards').select('id').eq('user_id', USER_ID);
738
+ if (!userBoards?.length) return { content: [{ type: 'text', text: 'No boards found.' }] };
739
+
740
+ const { data: logs, error } = await supabase.from('activity_log')
741
+ .select('action, entity_type, entity_name, details, created_at')
742
+ .eq('user_id', USER_ID)
743
+ .gte('created_at', sinceISO)
744
+ .order('created_at', { ascending: false });
745
+
746
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
747
+ if (!logs?.length) return { content: [{ type: 'text', text: `No activity in the past ${lookback} day(s).` }] };
748
+
749
+ // Group by action
750
+ const groups = {};
751
+ for (const log of logs) {
752
+ if (!groups[log.action]) groups[log.action] = [];
753
+ groups[log.action].push(log);
754
+ }
755
+
756
+ // Find cards moved to Done
757
+ const completed = (groups['moved'] || []).filter(l => l.details?.toLowerCase().includes('to done'));
758
+
759
+ let output = `📊 Sprint summary (past ${lookback} day(s))\n`;
760
+ output += `Total activity entries: ${logs.length}\n\n`;
761
+
762
+ for (const [action, items] of Object.entries(groups)) {
763
+ output += `── ${action} (${items.length}) ──\n`;
764
+ for (const item of items.slice(0, 10)) {
765
+ output += ` • ${item.entity_name}${item.details ? ` — ${item.details}` : ''}\n`;
766
+ }
767
+ if (items.length > 10) output += ` ... and ${items.length - 10} more\n`;
768
+ output += '\n';
769
+ }
770
+
771
+ if (completed.length) {
772
+ output += `✅ Completed (moved to Done): ${completed.length}\n`;
773
+ for (const c of completed) {
774
+ output += ` • ${c.entity_name}\n`;
775
+ }
776
+ }
777
+
778
+ return { content: [{ type: 'text', text: output }] };
779
+ }));
780
+
781
+ // ── create_multiple_cards ───────────────────────────────
782
+ server.tool('create_multiple_cards', 'Create multiple cards/frames at once. Ideal for batch idea capture via voice. Uses active board if board is not specified.', {
783
+ board: z.string().optional().describe('Board/production name or ID (uses active board or Idea Pool if omitted)'),
784
+ column: z.string().optional().describe('Stage name or ID. Defaults to first stage.'),
785
+ cards: z.array(z.object({
786
+ title: z.string().describe('Card title'),
787
+ description: z.string().optional().describe('Card description'),
788
+ priority: z.string().optional().describe('Priority: urgent, high, medium, low'),
789
+ due_date: z.string().optional().describe('Due date (YYYY-MM-DD)'),
790
+ })).max(50).describe('Array of cards to create (max 50)'),
791
+ labels: z.array(z.string()).optional().describe('Label names to attach to ALL cards'),
792
+ }, handleTool('create_multiple_cards', async ({ board, column, cards, labels }) => {
793
+ let brd = await requireBoard(board);
794
+ let usedIdeaPool = false;
795
+ if (!brd) {
796
+ brd = await ensureIdeaPool();
797
+ usedIdeaPool = true;
798
+ if (!brd) return { content: [{ type: 'text', text: 'Could not find or create Idea Pool.' }] };
799
+ }
800
+ let col;
801
+ if (column) {
802
+ col = await resolveColumn(column, brd.id);
803
+ if (!col) return { content: [{ type: 'text', text: `Stage "${column}" not found in ${brd.name}.` }] };
804
+ } else {
805
+ const { data: firstCol } = await supabase.from('columns').select('id, name').eq('board_id', brd.id).order('position').limit(1);
806
+ col = firstCol?.[0];
807
+ if (!col) return { content: [{ type: 'text', text: `No stages found in ${brd.name}.` }] };
808
+ }
809
+ let maxPos = await getMaxPosition('cards', { column_name: col.id });
810
+ const resolvedLabels = [];
811
+ if (labels?.length) {
812
+ for (const l of labels) {
813
+ const label = await resolveLabel(l);
814
+ if (label) resolvedLabels.push(label);
815
+ }
816
+ }
817
+ const created = [];
818
+ for (const c of cards) {
819
+ maxPos++;
820
+ const { data: card, error } = await supabase.from('cards').insert({
821
+ title: c.title, description: c.description || '', column_name: col.id, board_id: brd.id,
822
+ priority: c.priority || '', due_date: c.due_date || '', position: maxPos, color: '', cover_image: '',
823
+ }).select().single();
824
+ if (error) { created.push(`✗ ${c.title}: ${error.message}`); continue; }
825
+ for (const label of resolvedLabels) {
826
+ await supabase.from('card_tags').insert({ card_id: card.id, tag_id: label.id });
827
+ }
828
+ created.push(`✓ ${c.title}`);
829
+ }
830
+ await supabase.from('activity_log').insert({
831
+ user_id: USER_ID, user_email: 'Claude Code', action: 'batch_created',
832
+ entity_type: 'card', entity_name: `${cards.length} cards`, details: `in ${col.name} (${brd.name})`,
833
+ });
834
+ await broadcastChange('cards');
835
+ const t = await boardTerms(brd.id);
836
+ const poolNote = usedIdeaPool ? ' 💡 Added to Idea Pool.' : '';
837
+ return { content: [{ type: 'text', text: `Created ${created.filter(c => c.startsWith('✓')).length}/${cards.length} ${t.cards} in ${col.name} (${brd.name}):${poolNote}\n${created.join('\n')}` }] };
838
+ }));
839
+
840
+ // ── toggle_checklist_item ──────────────────────────────
841
+ server.tool('toggle_checklist_item', 'Check or uncheck a subtask/checklist item', {
842
+ card_id: z.string().describe('Card ID or title (partial match supported)'),
843
+ item_text: z.string().describe('Text of the checklist item to toggle (partial match)'),
844
+ checked: z.boolean().optional().describe('Set to true to check, false to uncheck. Omit to toggle.'),
845
+ }, handleTool('toggle_checklist_item', async ({ card_id, item_text, checked }) => {
846
+ const card = await resolveCard(card_id);
847
+ if (!card) return { content: [{ type: 'text', text: `Card "${card_id}" not found.` }] };
848
+ const { data: items } = await supabase.from('checklist_items').select('id, text, checked').eq('card_id', card.id);
849
+ const item = (items || []).find(i => i.text.toLowerCase().includes(item_text.toLowerCase()));
850
+ if (!item) return { content: [{ type: 'text', text: `Checklist item matching "${item_text}" not found.` }] };
851
+ const newChecked = checked !== undefined ? checked : !item.checked;
852
+ const { error } = await supabase.from('checklist_items').update({ checked: newChecked }).eq('id', item.id);
853
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
854
+ await broadcastChange('cards');
855
+ return { content: [{ type: 'text', text: `${newChecked ? '☑' : '☐'} "${item.text}" on "${card.title}".` }] };
856
+ }));
857
+
858
+ // ── get_board_overview ─────────────────────────────────
859
+ server.tool('get_board_overview', 'Get a quick overview of a board: stages with card counts, overdue items. Uses active board if not specified.', {
860
+ board: z.string().optional().describe('Board/production name or ID (uses active board if omitted)'),
861
+ }, handleTool('get_board_overview', async ({ board }) => {
862
+ const brd = await requireBoard(board);
863
+ if (!brd) return { content: [{ type: 'text', text: board ? `Board "${board}" not found.` : 'No board specified and no active board set. Use set_active_board first.' }] };
864
+ const { data: cols } = await supabase.from('columns').select('id, name, position').eq('board_id', brd.id).order('position');
865
+ const { data: cards } = await supabase.from('cards').select('id, title, column_name, priority, due_date, updated_at').eq('board_id', brd.id).eq('archived', false);
866
+ const colMap = Object.fromEntries((cols || []).map(c => [c.id, c.name]));
867
+ const now = new Date();
868
+ let overdue = [];
869
+ let dueToday = [];
870
+ let dueSoon = [];
871
+ const stageCounts = {};
872
+ for (const col of (cols || [])) stageCounts[col.name] = 0;
873
+ for (const card of (cards || [])) {
874
+ const colName = colMap[card.column_name] || 'Unknown';
875
+ if (stageCounts[colName] !== undefined) stageCounts[colName]++;
876
+ if (card.due_date) {
877
+ const due = new Date(card.due_date);
878
+ const diffDays = Math.ceil((due - now) / (1000 * 60 * 60 * 24));
879
+ if (diffDays < 0) overdue.push({ ...card, days: Math.abs(diffDays) });
880
+ else if (diffDays === 0) dueToday.push(card);
881
+ else if (diffDays <= 3) dueSoon.push({ ...card, days: diffDays });
882
+ }
883
+ }
884
+ const t = terms(brd.mode);
885
+ let output = `${brd.icon || '📋'} ${brd.name} (${t.Board})\n`;
886
+ output += `Total: ${(cards || []).length} ${t.cards}\n\n`;
887
+ output += `Stages:\n`;
888
+ for (const col of (cols || [])) {
889
+ output += ` ${col.name}: ${stageCounts[col.name]} ${t.cards}\n`;
890
+ }
891
+ if (overdue.length) {
892
+ output += `\n⚠️ Overdue (${overdue.length}):\n`;
893
+ for (const c of overdue) output += ` • ${c.title} — ${c.days}d overdue\n`;
894
+ }
895
+ if (dueToday.length) {
896
+ output += `\n📅 Due today (${dueToday.length}):\n`;
897
+ for (const c of dueToday) output += ` • ${c.title}\n`;
898
+ }
899
+ if (dueSoon.length) {
900
+ output += `\n🔜 Due soon (${dueSoon.length}):\n`;
901
+ for (const c of dueSoon) output += ` • ${c.title} — in ${c.days}d\n`;
902
+ }
903
+ return { content: [{ type: 'text', text: output }] };
904
+ }));
905
+
906
+ // ── add_labels_to_card ─────────────────────────────────
907
+ server.tool('add_labels_to_card', 'Add one or more labels to an existing card', {
908
+ card_id: z.string().describe('Card ID or title (partial match supported)'),
909
+ labels: z.array(z.string()).describe('Label names or IDs to add'),
910
+ }, handleTool('add_labels_to_card', async ({ card_id, labels }) => {
911
+ const card = await resolveCard(card_id);
912
+ if (!card) return { content: [{ type: 'text', text: `Card "${card_id}" not found.` }] };
913
+ const added = [];
914
+ for (const labelName of labels) {
915
+ const label = await resolveLabel(labelName);
916
+ if (!label) { added.push(`✗ "${labelName}" not found`); continue; }
917
+ const { data: existing } = await supabase.from('card_tags').select('id').eq('card_id', card.id).eq('tag_id', label.id).maybeSingle();
918
+ if (existing) { added.push(`⊘ "${label.name}" already on card`); continue; }
919
+ const { error } = await supabase.from('card_tags').insert({ card_id: card.id, tag_id: label.id });
920
+ if (error) { added.push(`✗ "${label.name}": ${error.message}`); continue; }
921
+ added.push(`✓ ${label.name}`);
922
+ }
923
+ await broadcastChange('cards');
924
+ return { content: [{ type: 'text', text: `Labels on "${card.title}":\n${added.join('\n')}` }] };
925
+ }));
926
+
927
+ // ── remove_label_from_card ─────────────────────────────
928
+ server.tool('remove_label_from_card', 'Remove a label from a card', {
929
+ card_id: z.string().describe('Card ID or title (partial match supported)'),
930
+ label: z.string().describe('Label name or ID to remove'),
931
+ }, handleTool('remove_label_from_card', async ({ card_id, label }) => {
932
+ const card = await resolveCard(card_id);
933
+ if (!card) return { content: [{ type: 'text', text: `Card "${card_id}" not found.` }] };
934
+ const lbl = await resolveLabel(label);
935
+ if (!lbl) return { content: [{ type: 'text', text: `Label "${label}" not found.` }] };
936
+ const { error } = await supabase.from('card_tags').delete().eq('card_id', card.id).eq('tag_id', lbl.id);
937
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
938
+ await broadcastChange('cards');
939
+ return { content: [{ type: 'text', text: `Removed label "${lbl.name}" from "${card.title}".` }] };
940
+ }));
941
+
942
+ // ── archive_card ───────────────────────────────────────
943
+ server.tool('archive_card', 'Archive a card (soft delete — can be restored in the UI)', {
944
+ id: z.string().describe('Card ID or title (partial match supported)'),
945
+ }, handleTool('archive_card', async ({ id }) => {
946
+ const card = await resolveCard(id);
947
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
948
+ const { data: cardInfo } = await supabase.from('cards').select('board_id').eq('id', card.id).single();
949
+ const t = await boardTerms(cardInfo?.board_id);
950
+ const { error } = await supabase.from('cards').update({ archived: true }).eq('id', card.id);
951
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
952
+ await supabase.from('activity_log').insert({
953
+ user_id: USER_ID, user_email: 'Claude Code', action: 'archived',
954
+ entity_type: t.card, entity_name: card.title, details: '',
955
+ });
956
+ await broadcastChange('cards');
957
+ return { content: [{ type: 'text', text: `Archived ${t.card} "${card.title}".` }] };
958
+ }));
959
+
960
+ // ── duplicate_card ─────────────────────────────────────
961
+ server.tool('duplicate_card', 'Duplicate a card including its checklist items', {
962
+ id: z.string().describe('Card ID or title (partial match supported)'),
963
+ new_title: z.string().optional().describe('Title for the copy (default: "Copy of <original>")'),
964
+ }, handleTool('duplicate_card', async ({ id, new_title }) => {
965
+ const card = await resolveCard(id);
966
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
967
+ const { data: original } = await supabase.from('cards').select('*').eq('id', card.id).single();
968
+ if (!original) return { content: [{ type: 'text', text: 'Could not fetch card details.' }] };
969
+ const maxPos = await getMaxPosition('cards', { column_name: original.column_name });
970
+ const { data: copy, error } = await supabase.from('cards').insert({
971
+ title: new_title || `Copy of ${original.title}`,
972
+ description: original.description, column_name: original.column_name, board_id: original.board_id,
973
+ priority: original.priority, due_date: original.due_date, color: original.color,
974
+ cover_image: original.cover_image, position: maxPos + 1,
975
+ }).select().single();
976
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
977
+ // Copy checklist items
978
+ const { data: checklist } = await supabase.from('checklist_items').select('text, position').eq('card_id', card.id).order('position');
979
+ if (checklist?.length) {
980
+ for (const item of checklist) {
981
+ await supabase.from('checklist_items').insert({ card_id: copy.id, text: item.text, checked: false, position: item.position });
982
+ }
983
+ }
984
+ // Copy labels
985
+ const { data: cardTags } = await supabase.from('card_tags').select('tag_id').eq('card_id', card.id);
986
+ if (cardTags?.length) {
987
+ for (const ct of cardTags) {
988
+ await supabase.from('card_tags').insert({ card_id: copy.id, tag_id: ct.tag_id });
989
+ }
990
+ }
991
+ await broadcastChange('cards');
992
+ const t = await boardTerms(original.board_id);
993
+ return { content: [{ type: 'text', text: `Duplicated ${t.card} "${original.title}" → "${copy.title}" (${copy.id})` }] };
994
+ }));
995
+
996
+ // ── set_card_color ─────────────────────────────────────
997
+ server.tool('set_card_color', 'Set or clear the color of a card for visual categorization', {
998
+ id: z.string().describe('Card ID or title (partial match supported)'),
999
+ color: z.string().regex(/^(#[0-9a-fA-F]{3,8})?$/).describe('Hex color (e.g. "#ef4444") or empty string to clear'),
1000
+ }, handleTool('set_card_color', async ({ id, color }) => {
1001
+ const card = await resolveCard(id);
1002
+ if (!card) return { content: [{ type: 'text', text: `Card "${id}" not found.` }] };
1003
+ const { error } = await supabase.from('cards').update({ color: color || '' }).eq('id', card.id);
1004
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1005
+ await broadcastChange('cards');
1006
+ return { content: [{ type: 'text', text: color ? `Set color ${color} on "${card.title}".` : `Cleared color on "${card.title}".` }] };
1007
+ }));
1008
+
1009
+ // ── create_column ──────────────────────────────────────
1010
+ server.tool('create_column', 'Create a new stage in a board. Uses active board if not specified.', {
1011
+ name: z.string().describe('Stage name'),
1012
+ board: z.string().optional().describe('Board/production name or ID (uses active board if omitted)'),
1013
+ color: z.string().regex(/^#[0-9a-fA-F]{3,8}$/).optional().describe('Hex color (default: #6b7280)'),
1014
+ }, handleTool('create_column', async ({ name, board, color }) => {
1015
+ const brd = await requireBoard(board);
1016
+ if (!brd) return { content: [{ type: 'text', text: board ? `Board "${board}" not found.` : 'No board specified and no active board set. Use set_active_board first.' }] };
1017
+ const maxPos = await getMaxPosition('columns', { board_id: brd.id });
1018
+ const { data, error } = await supabase.from('columns').insert({
1019
+ name, color: color || '#6b7280', position: maxPos + 1, board_id: brd.id, user_id: USER_ID,
1020
+ }).select().single();
1021
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1022
+ await broadcastChange('columns');
1023
+ return { content: [{ type: 'text', text: `Created stage "${data.name}" in ${brd.name} (${data.id})` }] };
1024
+ }));
1025
+
1026
+ // ── delete_column ──────────────────────────────────────
1027
+ server.tool('delete_column', 'Delete a stage/column from a board. Cards in this stage will be moved to the first remaining stage.', {
1028
+ id: z.string().describe('Column/stage name or ID'),
1029
+ board: z.string().optional().describe('Board name or ID (helps resolve ambiguous stage names)'),
1030
+ }, handleTool('delete_column', async ({ id, board }) => {
1031
+ const boardId = board ? (await resolveBoard(board))?.id : null;
1032
+ const col = await resolveColumn(id, boardId);
1033
+ if (!col) return { content: [{ type: 'text', text: `Stage "${id}" not found.` }] };
1034
+ // Find the column's board_id
1035
+ const { data: colFull } = await supabase.from('columns').select('board_id').eq('id', col.id).single();
1036
+ // Find replacement column in same board
1037
+ const { data: siblings } = await supabase.from('columns').select('id, name').eq('board_id', colFull.board_id).neq('id', col.id).order('position').limit(1);
1038
+ if (siblings?.length) {
1039
+ // Move orphan cards to first sibling
1040
+ await supabase.from('cards').update({ column_name: siblings[0].id }).eq('column_name', col.id);
1041
+ }
1042
+ const { error } = await supabase.from('columns').delete().eq('id', col.id);
1043
+ if (error) return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1044
+ await broadcastChange('columns');
1045
+ const movedTo = siblings?.length ? ` Cards moved to "${siblings[0].name}".` : '';
1046
+ return { content: [{ type: 'text', text: `Deleted stage "${col.name}".${movedTo}` }] };
1047
+ }));
1048
+
1049
+ // ── graduate_to_production ──────────────────────────────
1050
+ server.tool('graduate_to_production', 'Promote an idea from the Idea Pool into its own dedicated production with full stages (Idea → Scripting → Filming → Editing → Published). Moves the idea card to "In Production" stage in the Idea Pool (auto-archived after the configured days).', {
1051
+ idea: z.string().describe('Card title or ID from the Idea Pool to graduate'),
1052
+ name: z.string().optional().describe('Production name (defaults to the idea title)'),
1053
+ icon: z.string().optional().describe('Emoji icon (default: 🎬)'),
1054
+ color: z.string().regex(/^#[0-9a-fA-F]{3,8}$/).optional().describe('Hex color (default: #3b82f6)'),
1055
+ stages: z.array(z.string()).optional().describe('Custom stage names (default: Idea, Scripting, Filming, Editing, Published)'),
1056
+ }, handleTool('graduate_to_production', async ({ idea, name, icon, color, stages }) => {
1057
+ // Find the idea card
1058
+ const card = await resolveCard(idea);
1059
+ if (!card) return { content: [{ type: 'text', text: `Idea "${idea}" not found.` }] };
1060
+ const { data: fullCard } = await supabase.from('cards').select('title, description, board_id').eq('id', card.id).single();
1061
+
1062
+ const productionName = name || fullCard.title;
1063
+ const stageNames = stages || ['Idea', 'Scripting', 'Filming', 'Editing', 'Published'];
1064
+ const stageColors = ['#6b7280', '#3b82f6', '#f59e0b', '#8b5cf6', '#10b981'];
1065
+
1066
+ // Create the production board
1067
+ const maxPos = await getMaxPosition('boards', { user_id: USER_ID });
1068
+ const { data: production, error } = await supabase.from('boards').insert({
1069
+ name: productionName, icon: icon || '🎬', color: color || '#3b82f6',
1070
+ position: maxPos + 1, user_id: USER_ID, mode: 'creator',
1071
+ }).select().single();
1072
+ if (error) return { content: [{ type: 'text', text: `Error creating production: ${error.message}` }] };
1073
+
1074
+ // Add owner
1075
+ const { data: user } = await supabase.auth.admin.getUserById(USER_ID);
1076
+ await supabase.from('project_members').upsert({
1077
+ board_id: production.id, user_id: USER_ID,
1078
+ user_email: user?.user?.email || '', user_avatar: user?.user?.user_metadata?.avatar_url || '',
1079
+ role: 'owner',
1080
+ }, { onConflict: 'board_id,user_id' });
1081
+
1082
+ // Create stages
1083
+ for (let i = 0; i < stageNames.length; i++) {
1084
+ await supabase.from('columns').insert({
1085
+ name: stageNames[i], color: stageColors[i % stageColors.length],
1086
+ position: i, board_id: production.id, user_id: USER_ID,
1087
+ });
1088
+ }
1089
+
1090
+ // Move the original idea card to "In Production" stage (instead of archiving)
1091
+ // Verify the card's board belongs to this user before touching columns
1092
+ if (fullCard.board_id) {
1093
+ const { data: ownerCheck } = await supabase.from('boards').select('id').eq('id', fullCard.board_id).eq('user_id', USER_ID).single();
1094
+ if (!ownerCheck) return { content: [{ type: 'text', text: 'Access denied: idea card belongs to another board.' }] };
1095
+ const { data: inProdCol } = await supabase.from('columns')
1096
+ .select('id').eq('board_id', fullCard.board_id).ilike('name', 'In Production').limit(1);
1097
+ if (inProdCol?.length) {
1098
+ const inProdMaxPos = await getMaxPosition('cards', { column_name: inProdCol[0].id });
1099
+ await supabase.from('cards').update({ column_name: inProdCol[0].id, position: inProdMaxPos + 1 }).eq('id', card.id);
1100
+ }
1101
+ }
1102
+
1103
+ // Log activity
1104
+ await supabase.from('activity_log').insert({
1105
+ user_id: USER_ID, user_email: 'Claude Code', action: 'graduated',
1106
+ entity_type: 'production', entity_name: productionName, details: `from Idea Pool`,
1107
+ });
1108
+
1109
+ await broadcastChange('boards');
1110
+ await broadcastChange('columns');
1111
+ await broadcastChange('cards');
1112
+
1113
+ return { content: [{ type: 'text', text: `🎬 Graduated "${fullCard.title}" to production!\n\nProduction: ${icon || '🎬'} ${productionName}\nStages: ${stageNames.join(' → ')}\n\nIdea moved to "In Production" in Idea Pool. Production ID: ${production.id}` }] };
1114
+ }));
1115
+
1116
+ // ── Start server ────────────────────────────────────────
1117
+ const transport = new StdioServerTransport();
1118
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "framedeck-mcp",
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "description": "MCP server for Framedeck — connect any AI assistant to your content production pipeline",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "framedeck-mcp": "./index.js"
9
+ },
10
+ "keywords": [
11
+ "mcp",
12
+ "framedeck",
13
+ "kanban",
14
+ "content-creator",
15
+ "video-production",
16
+ "claude",
17
+ "ai-assistant",
18
+ "model-context-protocol"
19
+ ],
20
+ "author": "Framedeck",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Lukaris/framedeck"
25
+ },
26
+ "homepage": "https://framedeck.app",
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.0.0",
32
+ "@supabase/supabase-js": "^2.49.0",
33
+ "zod": "^3.22.0"
34
+ }
35
+ }