@zeroclickdev/zeroboard-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @zeroclickdev/zeroboard-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for **ZeroBoard** — manage your boards, columns, and cards from any MCP‑compatible coding agent (Claude Code, Cursor, Zed, Windsurf).
4
+
5
+ > Status: **v1 core** (issue #9). Read/write tools + resources over stdio, authenticated with your ZeroBoard (Supabase) account. See [Roadmap](#roadmap) for what's next.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # Sign in once — opens your browser (Google or email); stores a refreshable
11
+ # session under ~/.zeroboard.
12
+ npx -y @zeroclickdev/zeroboard-mcp login
13
+
14
+ # Headless / no browser? Use password sign-in instead:
15
+ ZEROBOARD_EMAIL=you@example.com ZEROBOARD_PASSWORD=… npx -y @zeroclickdev/zeroboard-mcp login --password
16
+
17
+ # Check who you are
18
+ npx -y @zeroclickdev/zeroboard-mcp status
19
+ ```
20
+
21
+ Then add the server to your agent (below). The default command (no subcommand) runs the MCP server over stdio.
22
+
23
+ ## Client configuration
24
+
25
+ **Claude Code**
26
+ ```bash
27
+ claude mcp add zeroboard -- npx -y @zeroclickdev/zeroboard-mcp
28
+ ```
29
+
30
+ **Cursor** — `~/.cursor/mcp.json`
31
+ ```json
32
+ { "mcpServers": { "zeroboard": { "command": "npx", "args": ["-y", "@zeroclickdev/zeroboard-mcp"] } } }
33
+ ```
34
+
35
+ **Zed** — `settings.json`
36
+ ```json
37
+ { "context_servers": { "zeroboard": { "command": { "path": "npx", "args": ["-y", "@zeroclickdev/zeroboard-mcp"] } } } }
38
+ ```
39
+
40
+ **Windsurf** — `mcp_config.json`
41
+ ```json
42
+ { "mcpServers": { "zeroboard": { "command": "npx", "args": ["-y", "@zeroclickdev/zeroboard-mcp"] } } }
43
+ ```
44
+
45
+ Add `"--read-only"` to `args` (or set `ZEROBOARD_READONLY=1`) to expose only the read tools.
46
+
47
+ ## Tools
48
+
49
+ **Read:** `list_boards`, `get_board`, `list_columns`, `list_cards`, `get_card`, `search`
50
+
51
+ **Write:** `create_board`, `generate_board` (AI — create a board from a prompt), `update_board`, `delete_board`, `add_column`, `update_column`, `remove_column`, `reorder_columns`, `add_card`, `update_card`, `move_card`, `archive_card`, `restore_card`, `duplicate_card`, `delete_card`, `add_checklist_item`, `toggle_checklist_item`, `add_label`, `remove_label`, `set_target_date`, `set_cover_image`
52
+
53
+ Destructive tools (`delete_board`, `delete_card`, `remove_column`) carry a `destructiveHint`; reads carry `readOnlyHint`, so clients can warn or auto-approve appropriately.
54
+
55
+ ## Resources
56
+
57
+ - `zeroboard://me` — the signed‑in account
58
+ - `zeroboard://boards` — index of your boards
59
+ - `zeroboard://board/{boardId}` — a full board (columns + cards) as JSON
60
+
61
+ ## Auth & security
62
+
63
+ - Uses **Supabase Auth** with the public anon key only — **never** the service‑role key. Postgres **RLS** (`auth.uid() = user_id`) enforces that you only ever see/modify your own data.
64
+ - `login` opens the ZeroBoard web app's `/auth/cli` page, which signs you in with the app's normal Supabase auth (Google or email) and hands the session back to a short‑lived loopback listener on `127.0.0.1`, gated by a one‑time random `state`. No password is typed into the CLI. (`--password` / `ZEROBOARD_EMAIL`+`ZEROBOARD_PASSWORD` do a headless password grant instead.)
65
+ - The CLI validates the delivered session before accepting it, so a stale/expired session is rejected rather than stored.
66
+ - It stores `{ access_token, refresh_token }` in `~/.zeroboard/credentials.json` (mode `0600`). The long‑lived server auto‑refreshes the access token and rewrites the rotated refresh token. `logout` wipes it.
67
+ - Card titles/contents are returned verbatim to your agent. As with any data source, treat board text as **untrusted input** (possible prompt injection) — the server never executes it.
68
+
69
+ ## Concurrency
70
+
71
+ Board columns/cards live in a single `boards.data` JSONB blob (the same source of truth the web app uses). Writes are read‑modify‑write and **last‑write‑wins** — a simultaneous edit in the web UI (which syncs on a debounce) and via MCP can clobber each other. The server always reads the freshest row immediately before writing to keep the window small. Scoped tokens and conditional updates are planned for v2.
72
+
73
+ ## Configuration
74
+
75
+ | Env var | Purpose |
76
+ | --- | --- |
77
+ | `ZEROBOARD_SUPABASE_URL` / `ZEROBOARD_SUPABASE_ANON_KEY` | Override the target project (defaults baked in at publish; fall back to `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY`) |
78
+ | `ZEROBOARD_EMAIL` / `ZEROBOARD_PASSWORD` | Non‑interactive `login --password` credentials |
79
+ | `ZEROBOARD_WEB_URL` | ZeroBoard web app base URL for browser login (default `https://board.zeroclickdev.ai`) |
80
+ | `ZEROBOARD_NO_BROWSER=1` | Don't auto‑open a browser during `login` (just print the URL) |
81
+ | `ZEROBOARD_READONLY=1` | Read‑only mode |
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ npm install
87
+ npm run build
88
+ npm run smoke # exercises the data layer against real Supabase (needs E2E_EMAIL/PASSWORD + VITE_SUPABASE_* in ../.env.local)
89
+ ```
90
+
91
+ ## Roadmap
92
+
93
+ - ✅ Browser login via the hosted `/auth/cli` route (Google + email) — done; a future hardening is a full server-side PKCE code exchange (the current flow binds the loopback delivery with a one‑time `state` and validates the session before storing).
94
+ - ✅ `generate_board` tool backed by the existing `/api/ai/board-template` endpoint — done.
95
+ - A dedicated `/api/v1` layer for scoped/read‑only tokens, audit logging, and conditional updates.
96
+ - Realtime: a `list_changes(since)` poll tool and (where clients support it) resource‑update notifications.
97
+ - A Claude Code **plugin** that bundles this server plus slash commands and a transcript‑to‑cards skill.
98
+
99
+ ## License
100
+
101
+ MIT
package/dist/ai.js ADDED
@@ -0,0 +1,37 @@
1
+ import { WEB_BASE_URL } from './config.js';
2
+ /**
3
+ * Generate a board template from a natural-language prompt by calling the web
4
+ * app's existing /api/ai/board-template endpoint with the user's access token.
5
+ * That endpoint runs the AI gateway and enforces the daily free-tier limit, so
6
+ * we don't reimplement any of that here.
7
+ */
8
+ export async function generateBoardTemplate(prompt, accessToken) {
9
+ let res;
10
+ try {
11
+ res = await fetch(`${WEB_BASE_URL}/api/ai/board-template`, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
14
+ body: JSON.stringify({ prompt }),
15
+ signal: AbortSignal.timeout(45_000),
16
+ });
17
+ }
18
+ catch (err) {
19
+ throw new Error(`Could not reach ZeroBoard AI: ${err instanceof Error ? err.message : String(err)}`);
20
+ }
21
+ if (res.status === 401) {
22
+ throw new Error('Not authorized for AI. Run `zeroboard-mcp login` again.');
23
+ }
24
+ if (res.status === 429) {
25
+ const body = (await res.json().catch(() => ({})));
26
+ const resets = body.usage?.resetsAt ? ` Resets at ${body.usage.resetsAt}.` : '';
27
+ throw new Error(`Daily AI limit reached.${resets} Upgrade to ZeroBoard Pro for unlimited AI.`);
28
+ }
29
+ if (!res.ok) {
30
+ const body = (await res.json().catch(() => ({})));
31
+ throw new Error(`AI board generation failed (${res.status})${body.error ? `: ${body.error}` : ''}`);
32
+ }
33
+ const data = (await res.json());
34
+ if (!data.template)
35
+ throw new Error('AI returned no template.');
36
+ return data.template;
37
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,192 @@
1
+ import readline from 'node:readline';
2
+ import http from 'node:http';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import { createClient } from '@supabase/supabase-js';
6
+ import { makeClient, getAuthedClient, NotAuthenticatedError } from './supabase.js';
7
+ import { setSupabaseUrl, clearCredentials, hasCredentials } from './credentials.js';
8
+ import { SUPABASE_URL, SUPABASE_ANON_KEY, WEB_BASE_URL } from './config.js';
9
+ function flag(name) {
10
+ const idx = process.argv.indexOf(`--${name}`);
11
+ if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1].startsWith('--'))
12
+ return process.argv[idx + 1];
13
+ const eq = process.argv.find((a) => a.startsWith(`--${name}=`));
14
+ return eq ? eq.slice(name.length + 3) : undefined;
15
+ }
16
+ function question(query, mask = false) {
17
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
18
+ if (mask) {
19
+ rl._writeToOutput = (s) => {
20
+ process.stdout.write(s.includes('\n') || s.includes(query) ? s : '*');
21
+ };
22
+ }
23
+ return new Promise((resolve) => {
24
+ rl.question(query, (answer) => {
25
+ rl.close();
26
+ if (mask)
27
+ process.stdout.write('\n');
28
+ resolve(answer.trim());
29
+ });
30
+ });
31
+ }
32
+ function openBrowser(url) {
33
+ if (process.env.ZEROBOARD_NO_BROWSER)
34
+ return; // headless: just print the URL
35
+ const platform = process.platform;
36
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
37
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
38
+ try {
39
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
40
+ child.on('error', () => {
41
+ /* fall back to the printed URL */
42
+ });
43
+ child.unref();
44
+ }
45
+ catch {
46
+ /* user can open the URL manually */
47
+ }
48
+ }
49
+ /**
50
+ * Browser sign-in: start a loopback listener, open the ZeroBoard web app's
51
+ * /auth/cli page (which reuses the app's existing Supabase sign-in — Google or
52
+ * email), and receive the resulting tokens back on the loopback. A random
53
+ * `state` (only ever in the URL we opened) gates the callback against other
54
+ * local processes/tabs. No password ever touches this CLI.
55
+ */
56
+ async function browserLogin() {
57
+ const state = randomBytes(16).toString('hex');
58
+ let resolveDone;
59
+ let rejectDone;
60
+ const done = new Promise((res, rej) => {
61
+ resolveDone = res;
62
+ rejectDone = rej;
63
+ });
64
+ // Validate and store a delivered session. Returns true once a VALID session is
65
+ // accepted; a stale/invalid delivery is rejected and the loopback keeps
66
+ // listening (so a premature delivery doesn't abort the whole login).
67
+ const accept = async (data) => {
68
+ if (data.state !== state)
69
+ return { code: 403, body: { ok: false, error: 'state mismatch' } };
70
+ if (!data.access_token || !data.refresh_token)
71
+ return { code: 400, body: { ok: false, error: 'missing tokens' } };
72
+ const tokens = { access_token: data.access_token, refresh_token: data.refresh_token };
73
+ // Validate with a throwaway, non-persisting client so a bad session never
74
+ // pollutes the credential store.
75
+ const probe = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
76
+ auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
77
+ });
78
+ const set = await probe.auth.setSession(tokens);
79
+ if (set.error || !set.data.session)
80
+ return { code: 401, body: { ok: false, error: 'session invalid — sign in again' } };
81
+ const who = await probe.auth.getUser();
82
+ if (who.error || !who.data.user)
83
+ return { code: 401, body: { ok: false, error: 'session could not be validated' } };
84
+ // Persist via the file-backed client (canonical supabase-js storage format).
85
+ const store = makeClient();
86
+ const stored = await store.auth.setSession(tokens);
87
+ if (stored.error || !stored.data.session)
88
+ return { code: 500, body: { ok: false, error: 'failed to store session' } };
89
+ setSupabaseUrl(SUPABASE_URL);
90
+ resolveDone(who.data.user.email ?? 'your account');
91
+ return { code: 200, body: { ok: true } };
92
+ };
93
+ const server = http.createServer((req, res) => {
94
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
95
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
96
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
97
+ if (req.method === 'OPTIONS') {
98
+ res.writeHead(204);
99
+ res.end();
100
+ return;
101
+ }
102
+ if (req.method === 'POST' && (req.url ?? '').startsWith('/callback')) {
103
+ let body = '';
104
+ req.on('data', (chunk) => {
105
+ body += chunk;
106
+ if (body.length > 1_000_000)
107
+ req.destroy();
108
+ });
109
+ req.on('end', () => {
110
+ const reply = (code, obj) => {
111
+ res.writeHead(code, { 'Content-Type': 'application/json' });
112
+ res.end(JSON.stringify(obj));
113
+ };
114
+ let parsed = null;
115
+ try {
116
+ parsed = JSON.parse(body);
117
+ }
118
+ catch {
119
+ reply(400, { ok: false, error: 'bad request' });
120
+ return;
121
+ }
122
+ if (!parsed || typeof parsed !== 'object') {
123
+ reply(400, { ok: false, error: 'bad request' });
124
+ return;
125
+ }
126
+ accept(parsed).then(({ code, body: b }) => reply(code, b)).catch(() => reply(500, { ok: false }));
127
+ });
128
+ return;
129
+ }
130
+ res.writeHead(404);
131
+ res.end();
132
+ });
133
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
134
+ const addr = server.address();
135
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
136
+ const url = `${WEB_BASE_URL}/auth/cli?port=${port}&state=${state}`;
137
+ console.log('Opening your browser to sign in to ZeroBoard…');
138
+ console.log(`If it does not open automatically, visit:\n ${url}\n`);
139
+ openBrowser(url);
140
+ const timeout = setTimeout(() => rejectDone(new Error('Timed out waiting for sign-in (5 minutes).')), 300_000);
141
+ let email;
142
+ try {
143
+ email = await done;
144
+ }
145
+ finally {
146
+ clearTimeout(timeout);
147
+ server.close();
148
+ }
149
+ console.log(`✅ Signed in as ${email}.`);
150
+ }
151
+ /** Fallback: direct email/password sign-in (flags, env, or interactive prompt). */
152
+ async function passwordLogin() {
153
+ const email = flag('email') ?? process.env.ZEROBOARD_EMAIL ?? (await question('Email: '));
154
+ const password = flag('password') ?? process.env.ZEROBOARD_PASSWORD ?? (await question('Password: ', true));
155
+ if (!email || !password)
156
+ throw new Error('Email and password are required.');
157
+ const client = makeClient();
158
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
159
+ if (error || !data.session)
160
+ throw new Error(`Login failed: ${error?.message ?? 'no session returned'}`);
161
+ setSupabaseUrl(SUPABASE_URL);
162
+ console.log(`✅ Signed in as ${data.user?.email ?? email}.`);
163
+ }
164
+ export async function login() {
165
+ // Use password mode when explicitly asked or when credentials are supplied;
166
+ // otherwise do the browser flow (supports Google OAuth + email).
167
+ const usePassword = process.argv.includes('--password') ||
168
+ flag('email') !== undefined ||
169
+ (!!process.env.ZEROBOARD_EMAIL && !!process.env.ZEROBOARD_PASSWORD);
170
+ return usePassword ? passwordLogin() : browserLogin();
171
+ }
172
+ export function logout() {
173
+ clearCredentials();
174
+ console.log('✅ Signed out — local credentials cleared.');
175
+ }
176
+ export async function status() {
177
+ if (!hasCredentials()) {
178
+ console.log('Not signed in. Run `zeroboard-mcp login`.');
179
+ return;
180
+ }
181
+ try {
182
+ const { user } = await getAuthedClient();
183
+ console.log(`Signed in as ${user.email ?? user.id}.`);
184
+ }
185
+ catch (err) {
186
+ if (err instanceof NotAuthenticatedError) {
187
+ console.log(err.message);
188
+ return;
189
+ }
190
+ throw err;
191
+ }
192
+ }
@@ -0,0 +1,384 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ // ---------------------------------------------------------------------------
3
+ // Helpers — these intentionally mirror src/store/useBoardStore.ts so the MCP
4
+ // server and the web UI read/write the boards.data JSONB identically.
5
+ // ---------------------------------------------------------------------------
6
+ const nowIso = () => new Date().toISOString();
7
+ const newId = () => randomUUID();
8
+ function createDefaultColumns() {
9
+ return [
10
+ { id: newId(), title: 'To Do', cards: [], order: 0 },
11
+ { id: newId(), title: 'Blocked', cards: [], order: 1 },
12
+ { id: newId(), title: 'In Progress', cards: [], order: 2 },
13
+ { id: newId(), title: 'Resolved', cards: [], order: 3 },
14
+ { id: newId(), title: 'Closed', cards: [], order: 4 },
15
+ ];
16
+ }
17
+ function asRecord(data) {
18
+ if (!data || typeof data !== 'object' || Array.isArray(data))
19
+ return null;
20
+ return data;
21
+ }
22
+ export function decodeData(data) {
23
+ const rec = asRecord(data);
24
+ const columns = rec && Array.isArray(rec.columns) ? rec.columns : createDefaultColumns();
25
+ const background = rec && typeof rec.background === 'string' ? rec.background : undefined;
26
+ const hiddenColumnIds = rec && Array.isArray(rec.hiddenColumnIds)
27
+ ? rec.hiddenColumnIds.filter((v) => typeof v === 'string')
28
+ : [];
29
+ return { columns, background, hiddenColumnIds };
30
+ }
31
+ /** Re-encode columns into the JSONB shape, preserving background/hiddenColumnIds. */
32
+ function encodeData(columns, base) {
33
+ return {
34
+ columns,
35
+ ...(base.background ? { background: base.background } : {}),
36
+ ...(base.hiddenColumnIds && base.hiddenColumnIds.length > 0
37
+ ? { hiddenColumnIds: base.hiddenColumnIds }
38
+ : {}),
39
+ };
40
+ }
41
+ const SELECT_COLS = 'id,user_id,name,description,data,created_at,updated_at,is_public,embed_enabled';
42
+ function rowToFullBoard(row) {
43
+ const decoded = decodeData(row.data);
44
+ return {
45
+ id: row.id,
46
+ name: row.name,
47
+ description: row.description ?? undefined,
48
+ columns: decoded.columns,
49
+ background: decoded.background,
50
+ hiddenColumnIds: decoded.hiddenColumnIds ?? [],
51
+ isPublic: row.is_public,
52
+ embedEnabled: row.embed_enabled,
53
+ createdAt: row.created_at,
54
+ updatedAt: row.updated_at,
55
+ };
56
+ }
57
+ function rowToSummary(row) {
58
+ const { columns } = decodeData(row.data);
59
+ return {
60
+ id: row.id,
61
+ name: row.name,
62
+ description: row.description ?? undefined,
63
+ columnCount: columns.length,
64
+ cardCount: columns.reduce((n, c) => n + c.cards.length, 0),
65
+ updatedAt: row.updated_at,
66
+ createdAt: row.created_at,
67
+ };
68
+ }
69
+ class BoardError extends Error {
70
+ }
71
+ const notFound = (what, id) => {
72
+ throw new BoardError(`${what} ${id} not found`);
73
+ };
74
+ async function getRow(client, boardId) {
75
+ const { data, error } = await client.from('boards').select(SELECT_COLS).eq('id', boardId).single();
76
+ if (error || !data)
77
+ return notFound('Board', boardId);
78
+ return data;
79
+ }
80
+ /**
81
+ * Read-modify-write the columns of a board's JSONB. Reads the freshest row
82
+ * immediately before writing to narrow the last-write-wins window (the web app
83
+ * also overwrites the whole columns array on a debounce — concurrent edits are
84
+ * last-write-wins; see README "Concurrency").
85
+ */
86
+ async function mutateColumns(client, boardId, mutator) {
87
+ const row = await getRow(client, boardId);
88
+ const base = decodeData(row.data);
89
+ const nextColumns = mutator(structuredClone(base.columns));
90
+ const nextData = encodeData(nextColumns, base);
91
+ const { data, error } = await client
92
+ .from('boards')
93
+ .update({ data: nextData, updated_at: nowIso() })
94
+ .eq('id', boardId)
95
+ .select(SELECT_COLS)
96
+ .single();
97
+ if (error || !data)
98
+ throw new BoardError(error?.message ?? 'Update failed');
99
+ return rowToFullBoard(data);
100
+ }
101
+ function findColumn(columns, columnId) {
102
+ const col = columns.find((c) => c.id === columnId);
103
+ if (!col)
104
+ notFound('Column', columnId);
105
+ return col;
106
+ }
107
+ function locateCard(columns, cardId) {
108
+ for (const column of columns) {
109
+ const index = column.cards.findIndex((c) => c.id === cardId);
110
+ if (index !== -1)
111
+ return { column, card: column.cards[index], index };
112
+ }
113
+ return notFound('Card', cardId);
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Boards
117
+ // ---------------------------------------------------------------------------
118
+ export async function listBoards(client) {
119
+ const { data, error } = await client
120
+ .from('boards')
121
+ .select(SELECT_COLS)
122
+ .order('created_at', { ascending: true });
123
+ if (error)
124
+ throw new BoardError(error.message);
125
+ return data.map(rowToSummary);
126
+ }
127
+ export async function getBoard(client, boardId) {
128
+ return rowToFullBoard(await getRow(client, boardId));
129
+ }
130
+ export async function createBoard(client, userId, name, description, columns) {
131
+ const id = newId();
132
+ const now = nowIso();
133
+ const data = { columns: columns ?? createDefaultColumns() };
134
+ const { data: row, error } = await client
135
+ .from('boards')
136
+ .insert({
137
+ id,
138
+ user_id: userId,
139
+ name,
140
+ description: description ?? null,
141
+ data,
142
+ created_at: now,
143
+ updated_at: now,
144
+ })
145
+ .select(SELECT_COLS)
146
+ .single();
147
+ if (error || !row)
148
+ throw new BoardError(error?.message ?? 'Insert failed');
149
+ return rowToFullBoard(row);
150
+ }
151
+ /** Create a board from an AI-generated template (maps template cards to Cards). */
152
+ export function createBoardFromTemplate(client, userId, template) {
153
+ const now = nowIso();
154
+ const columns = template.columns.map((col, i) => ({
155
+ id: newId(),
156
+ title: col.title,
157
+ order: i,
158
+ cards: (col.sampleCards ?? []).map((c) => {
159
+ const content = c.content?.type === 'checklist'
160
+ ? {
161
+ type: 'checklist',
162
+ checklist: (c.content.checklist ?? []).map((it) => ({ id: newId(), text: it.text, completed: false })),
163
+ }
164
+ : { type: 'text', text: c.content?.text ?? '' };
165
+ return {
166
+ id: newId(),
167
+ title: c.title,
168
+ description: c.description,
169
+ content,
170
+ labels: c.labels ?? [],
171
+ isArchived: false,
172
+ createdAt: now,
173
+ updatedAt: now,
174
+ };
175
+ }),
176
+ }));
177
+ return createBoard(client, userId, template.name, template.description, columns);
178
+ }
179
+ export async function updateBoardMeta(client, boardId, patch) {
180
+ const update = { updated_at: nowIso() };
181
+ if (patch.name !== undefined)
182
+ update.name = patch.name;
183
+ if (patch.description !== undefined)
184
+ update.description = patch.description;
185
+ const { data, error } = await client
186
+ .from('boards')
187
+ .update(update)
188
+ .eq('id', boardId)
189
+ .select(SELECT_COLS)
190
+ .single();
191
+ if (error || !data)
192
+ throw new BoardError(error?.message ?? 'Update failed');
193
+ return rowToFullBoard(data);
194
+ }
195
+ export async function deleteBoard(client, boardId) {
196
+ const { error } = await client.from('boards').delete().eq('id', boardId);
197
+ if (error)
198
+ throw new BoardError(error.message);
199
+ return { id: boardId };
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Columns
203
+ // ---------------------------------------------------------------------------
204
+ export function addColumn(client, boardId, title) {
205
+ return mutateColumns(client, boardId, (columns) => {
206
+ const maxOrder = columns.reduce((m, c) => Math.max(m, c.order), -1);
207
+ return [...columns, { id: newId(), title, cards: [], order: maxOrder + 1 }];
208
+ });
209
+ }
210
+ export function updateColumn(client, boardId, columnId, title) {
211
+ return mutateColumns(client, boardId, (columns) => {
212
+ findColumn(columns, columnId).title = title;
213
+ return columns;
214
+ });
215
+ }
216
+ export function removeColumn(client, boardId, columnId) {
217
+ return mutateColumns(client, boardId, (columns) => {
218
+ findColumn(columns, columnId);
219
+ return columns.filter((c) => c.id !== columnId);
220
+ });
221
+ }
222
+ export function reorderColumns(client, boardId, orderedColumnIds) {
223
+ return mutateColumns(client, boardId, (columns) => {
224
+ const map = new Map(columns.map((c) => [c.id, c]));
225
+ if (orderedColumnIds.some((id) => !map.has(id)) || orderedColumnIds.length !== columns.length) {
226
+ throw new BoardError('orderedColumnIds must list every existing column id exactly once');
227
+ }
228
+ return orderedColumnIds.map((id, index) => ({ ...map.get(id), order: index }));
229
+ });
230
+ }
231
+ export function addCard(client, boardId, columnId, input) {
232
+ return mutateColumns(client, boardId, (columns) => {
233
+ const column = findColumn(columns, columnId);
234
+ const now = nowIso();
235
+ const card = {
236
+ id: newId(),
237
+ title: input.title,
238
+ description: input.description,
239
+ content: input.content ?? { type: 'text', text: '' },
240
+ targetDate: input.targetDate,
241
+ labels: input.labels ?? [],
242
+ coverImage: input.coverImage,
243
+ isArchived: false,
244
+ createdAt: now,
245
+ updatedAt: now,
246
+ };
247
+ column.cards.push(card);
248
+ return columns;
249
+ });
250
+ }
251
+ export function updateCard(client, boardId, cardId, patch) {
252
+ return mutateColumns(client, boardId, (columns) => {
253
+ const { card } = locateCard(columns, cardId);
254
+ Object.assign(card, patch, { updatedAt: nowIso() });
255
+ return columns;
256
+ });
257
+ }
258
+ export function moveCard(client, boardId, cardId, targetColumnId, targetIndex) {
259
+ return mutateColumns(client, boardId, (columns) => {
260
+ const { column: sourceColumn, card, index } = locateCard(columns, cardId);
261
+ const target = findColumn(columns, targetColumnId);
262
+ sourceColumn.cards.splice(index, 1);
263
+ const insertAt = targetIndex === undefined ? target.cards.length : Math.max(0, Math.min(targetIndex, target.cards.length));
264
+ target.cards.splice(insertAt, 0, card);
265
+ card.updatedAt = nowIso();
266
+ return columns;
267
+ });
268
+ }
269
+ export function removeCard(client, boardId, cardId) {
270
+ return mutateColumns(client, boardId, (columns) => {
271
+ const { column } = locateCard(columns, cardId);
272
+ column.cards = column.cards.filter((c) => c.id !== cardId);
273
+ return columns;
274
+ });
275
+ }
276
+ export function setCardArchived(client, boardId, cardId, archived) {
277
+ return mutateColumns(client, boardId, (columns) => {
278
+ const { card } = locateCard(columns, cardId);
279
+ card.isArchived = archived;
280
+ card.archivedAt = archived ? nowIso() : undefined;
281
+ card.updatedAt = nowIso();
282
+ return columns;
283
+ });
284
+ }
285
+ export function duplicateCard(client, boardId, cardId) {
286
+ return mutateColumns(client, boardId, (columns) => {
287
+ const { column, card } = locateCard(columns, cardId);
288
+ const now = nowIso();
289
+ column.cards.push({ ...structuredClone(card), id: newId(), title: `${card.title} (copy)`, createdAt: now, updatedAt: now });
290
+ return columns;
291
+ });
292
+ }
293
+ // ---------------------------------------------------------------------------
294
+ // Card detail mutations
295
+ // ---------------------------------------------------------------------------
296
+ export function addChecklistItem(client, boardId, cardId, text) {
297
+ return mutateColumns(client, boardId, (columns) => {
298
+ const { card } = locateCard(columns, cardId);
299
+ const checklist = card.content.type === 'checklist' && card.content.checklist ? card.content.checklist : [];
300
+ card.content = { ...card.content, type: 'checklist', checklist: [...checklist, { id: newId(), text, completed: false }] };
301
+ card.updatedAt = nowIso();
302
+ return columns;
303
+ });
304
+ }
305
+ export function toggleChecklistItem(client, boardId, cardId, itemId, completed) {
306
+ return mutateColumns(client, boardId, (columns) => {
307
+ const { card } = locateCard(columns, cardId);
308
+ const items = card.content.checklist;
309
+ if (!items)
310
+ throw new BoardError(`Card ${cardId} has no checklist`);
311
+ const item = items.find((i) => i.id === itemId);
312
+ if (!item)
313
+ notFound('Checklist item', itemId);
314
+ item.completed = completed ?? !item.completed;
315
+ card.updatedAt = nowIso();
316
+ return columns;
317
+ });
318
+ }
319
+ export function setLabel(client, boardId, cardId, label, present) {
320
+ return mutateColumns(client, boardId, (columns) => {
321
+ const { card } = locateCard(columns, cardId);
322
+ const labels = new Set(card.labels ?? []);
323
+ if (present)
324
+ labels.add(label);
325
+ else
326
+ labels.delete(label);
327
+ card.labels = [...labels];
328
+ card.updatedAt = nowIso();
329
+ return columns;
330
+ });
331
+ }
332
+ export function setTargetDate(client, boardId, cardId, targetDate) {
333
+ return mutateColumns(client, boardId, (columns) => {
334
+ const { card } = locateCard(columns, cardId);
335
+ card.targetDate = targetDate ?? undefined;
336
+ card.updatedAt = nowIso();
337
+ return columns;
338
+ });
339
+ }
340
+ export function setCoverImage(client, boardId, cardId, coverImage) {
341
+ return mutateColumns(client, boardId, (columns) => {
342
+ const { card } = locateCard(columns, cardId);
343
+ card.coverImage = coverImage ?? undefined;
344
+ card.updatedAt = nowIso();
345
+ return columns;
346
+ });
347
+ }
348
+ function cardText(card) {
349
+ const parts = [card.title, card.description ?? '', card.content.text ?? ''];
350
+ if (card.content.checklist)
351
+ parts.push(...card.content.checklist.map((i) => i.text));
352
+ return parts.join('\n');
353
+ }
354
+ export async function search(client, query, includeArchived = false) {
355
+ const q = query.trim().toLowerCase();
356
+ if (!q)
357
+ return [];
358
+ const { data, error } = await client.from('boards').select(SELECT_COLS);
359
+ if (error)
360
+ throw new BoardError(error.message);
361
+ const hits = [];
362
+ for (const row of data) {
363
+ const { columns } = decodeData(row.data);
364
+ for (const column of columns) {
365
+ for (const card of column.cards) {
366
+ if (card.isArchived && !includeArchived)
367
+ continue;
368
+ const text = cardText(card);
369
+ if (text.toLowerCase().includes(q)) {
370
+ hits.push({
371
+ boardId: row.id,
372
+ boardName: row.name,
373
+ columnId: column.id,
374
+ columnName: column.title,
375
+ cardId: card.id,
376
+ cardTitle: card.title,
377
+ snippet: card.description || card.content.text || undefined,
378
+ });
379
+ }
380
+ }
381
+ }
382
+ }
383
+ return hits;
384
+ }
package/dist/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ // Public ZeroBoard Supabase project defaults so the published package works with
4
+ // zero configuration. Both values are PUBLISHABLE: the anon key is a `role: anon`
5
+ // JWT (the same one shipped in the web app's browser bundle), and Postgres RLS
6
+ // (`auth.uid() = user_id`) enforces per-user access. The service-role key is
7
+ // never included. Override with ZEROBOARD_SUPABASE_URL / ZEROBOARD_SUPABASE_ANON_KEY.
8
+ const DEFAULT_SUPABASE_URL = 'https://lhupamtkfuntxwosogtz.supabase.co';
9
+ const DEFAULT_SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxodXBhbXRrZnVudHh3b3NvZ3R6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjE1OTIwNzEsImV4cCI6MjA3NzE2ODA3MX0.RRzUk5vf5cqvc-gjCua_LMT5UubaM6OJdMJ_LBls88U';
10
+ export const SUPABASE_URL = process.env.ZEROBOARD_SUPABASE_URL ?? process.env.VITE_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
11
+ export const SUPABASE_ANON_KEY = process.env.ZEROBOARD_SUPABASE_ANON_KEY ?? process.env.VITE_SUPABASE_ANON_KEY ?? DEFAULT_SUPABASE_ANON_KEY;
12
+ /** The ZeroBoard web app, used for the browser sign-in (`/auth/cli`) flow. */
13
+ export const WEB_BASE_URL = (process.env.ZEROBOARD_WEB_URL ?? 'https://board.zeroclickdev.ai').replace(/\/$/, '');
14
+ /** Read-only mode: only non-mutating tools are registered. */
15
+ export const READ_ONLY = process.env.ZEROBOARD_READONLY === '1' || process.argv.includes('--read-only');
16
+ export const CONFIG_DIR = join(homedir(), '.zeroboard');
17
+ export const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials.json');
18
+ /** supabase-js derives this from the URL; we reproduce it for the storage key. */
19
+ export function storageKeyFor(url) {
20
+ try {
21
+ const ref = new URL(url).hostname.split('.')[0];
22
+ return `sb-${ref}-auth-token`;
23
+ }
24
+ catch {
25
+ return 'sb-zeroboard-auth-token';
26
+ }
27
+ }
28
+ export function assertConfigured() {
29
+ if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
30
+ throw new Error('ZeroBoard Supabase URL/anon key not configured. Set ZEROBOARD_SUPABASE_URL and ' +
31
+ 'ZEROBOARD_SUPABASE_ANON_KEY (or VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY).');
32
+ }
33
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
2
+ import { CONFIG_DIR, CREDENTIALS_PATH } from './config.js';
3
+ function read() {
4
+ try {
5
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
6
+ }
7
+ catch {
8
+ return {};
9
+ }
10
+ }
11
+ function write(next) {
12
+ mkdirSync(CONFIG_DIR, { recursive: true });
13
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(next, null, 2), { mode: 0o600 });
14
+ }
15
+ export function setSupabaseUrl(url) {
16
+ write({ ...read(), url });
17
+ }
18
+ export function clearCredentials() {
19
+ if (existsSync(CREDENTIALS_PATH))
20
+ rmSync(CREDENTIALS_PATH);
21
+ }
22
+ export function hasCredentials() {
23
+ return !!read().token;
24
+ }
25
+ /**
26
+ * A supabase-js storage adapter backed by the credentials file. supabase-js
27
+ * calls setItem whenever it persists/rotates the session, so refresh tokens stay
28
+ * current on disk. We keep a single session, so the storage key is ignored.
29
+ */
30
+ export const fileStorage = {
31
+ getItem(_key) {
32
+ return read().token ?? null;
33
+ },
34
+ setItem(_key, value) {
35
+ write({ ...read(), token: value });
36
+ },
37
+ removeItem(_key) {
38
+ const current = read();
39
+ delete current.token;
40
+ write(current);
41
+ },
42
+ };
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { login, logout, status } from './auth.js';
3
+ import { runServer } from './server.js';
4
+ const HELP = `zeroboard-mcp — ZeroBoard MCP server
5
+
6
+ Usage:
7
+ zeroboard-mcp [serve] Start the MCP server over stdio (default)
8
+ zeroboard-mcp --read-only Start in read-only mode (no write tools)
9
+ zeroboard-mcp login Sign in via the browser (Google or email). Use \`--password\`
10
+ (or ZEROBOARD_EMAIL/PASSWORD) for headless password sign-in.
11
+ zeroboard-mcp logout Clear stored credentials
12
+ zeroboard-mcp status Show the signed-in account
13
+ `;
14
+ async function main() {
15
+ const cmd = process.argv[2];
16
+ switch (cmd) {
17
+ case 'login':
18
+ return login();
19
+ case 'logout':
20
+ return logout();
21
+ case 'status':
22
+ return status();
23
+ case 'help':
24
+ case '--help':
25
+ case '-h':
26
+ process.stdout.write(HELP);
27
+ return;
28
+ case undefined:
29
+ case 'serve':
30
+ default:
31
+ // Bare invocation or any leading flag (e.g. --read-only) starts the server.
32
+ if (cmd && cmd !== 'serve' && !cmd.startsWith('--')) {
33
+ console.error(`Unknown command: ${cmd}\n\n${HELP}`);
34
+ process.exit(1);
35
+ }
36
+ return runServer();
37
+ }
38
+ }
39
+ main().catch((err) => {
40
+ console.error(err instanceof Error ? err.message : String(err));
41
+ process.exit(1);
42
+ });
package/dist/server.js ADDED
@@ -0,0 +1,172 @@
1
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { READ_ONLY } from './config.js';
5
+ import { getAuthedClient, NotAuthenticatedError } from './supabase.js';
6
+ import * as db from './board-data.js';
7
+ import { generateBoardTemplate } from './ai.js';
8
+ import { CARD_LABELS } from './types.js';
9
+ const text = (data) => ({
10
+ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
11
+ });
12
+ const fail = (e) => ({
13
+ content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
14
+ isError: true,
15
+ });
16
+ const safe = async (fn) => {
17
+ try {
18
+ return text(await fn());
19
+ }
20
+ catch (e) {
21
+ return fail(e);
22
+ }
23
+ };
24
+ const labelEnum = z.enum(CARD_LABELS);
25
+ const textToContent = (t) => t === undefined ? undefined : { type: 'text', text: t };
26
+ function buildServer(client, user) {
27
+ const server = new McpServer({ name: 'zeroboard-mcp', version: '0.1.0' });
28
+ const RO = { readOnlyHint: true };
29
+ const DESTRUCTIVE = { destructiveHint: true };
30
+ // ----- Read tools -----------------------------------------------------
31
+ server.registerTool('list_boards', { title: 'List boards', description: 'List all of your ZeroBoard boards (id, name, column/card counts).', inputSchema: {}, annotations: RO }, async () => safe(() => db.listBoards(client)));
32
+ server.registerTool('get_board', { title: 'Get board', description: 'Get a full board including its columns and cards.', inputSchema: { boardId: z.string().describe('Board id') }, annotations: RO }, async ({ boardId }) => safe(() => db.getBoard(client, boardId)));
33
+ server.registerTool('list_columns', { title: 'List columns', description: 'List the columns of a board.', inputSchema: { boardId: z.string() }, annotations: RO }, async ({ boardId }) => safe(async () => {
34
+ const b = await db.getBoard(client, boardId);
35
+ return b.columns.map((c) => ({ id: c.id, title: c.title, order: c.order, cardCount: c.cards.length }));
36
+ }));
37
+ server.registerTool('list_cards', {
38
+ title: 'List cards',
39
+ description: 'List cards on a board, optionally filtered to a column. Archived cards excluded unless includeArchived.',
40
+ inputSchema: {
41
+ boardId: z.string(),
42
+ columnId: z.string().optional(),
43
+ includeArchived: z.boolean().optional(),
44
+ },
45
+ annotations: RO,
46
+ }, async ({ boardId, columnId, includeArchived }) => safe(async () => {
47
+ const b = await db.getBoard(client, boardId);
48
+ return b.columns
49
+ .filter((c) => !columnId || c.id === columnId)
50
+ .flatMap((c) => c.cards
51
+ .filter((card) => includeArchived || !card.isArchived)
52
+ .map((card) => ({ columnId: c.id, columnName: c.title, ...card })));
53
+ }));
54
+ server.registerTool('get_card', { title: 'Get card', description: 'Get a single card by id.', inputSchema: { boardId: z.string(), cardId: z.string() }, annotations: RO }, async ({ boardId, cardId }) => safe(async () => {
55
+ const b = await db.getBoard(client, boardId);
56
+ for (const c of b.columns) {
57
+ const card = c.cards.find((x) => x.id === cardId);
58
+ if (card)
59
+ return { columnId: c.id, columnName: c.title, ...card };
60
+ }
61
+ throw new Error(`Card ${cardId} not found`);
62
+ }));
63
+ server.registerTool('search', {
64
+ title: 'Search cards',
65
+ description: 'Search across all your boards for cards whose title/description/content match the query.',
66
+ inputSchema: { query: z.string(), includeArchived: z.boolean().optional() },
67
+ annotations: RO,
68
+ }, async ({ query, includeArchived }) => safe(() => db.search(client, query, includeArchived ?? false)));
69
+ if (READ_ONLY)
70
+ return server;
71
+ // ----- Write tools ----------------------------------------------------
72
+ server.registerTool('create_board', { title: 'Create board', description: 'Create a new board with the default columns.', inputSchema: { name: z.string(), description: z.string().optional() } }, async ({ name, description }) => safe(() => db.createBoard(client, user.id, name, description)));
73
+ server.registerTool('generate_board', {
74
+ title: 'Generate board (AI)',
75
+ description: 'Create a new board from a natural-language description using ZeroBoard AI (e.g. "a sprint board for a mobile app launch"). Subject to the daily AI limit on the free plan.',
76
+ inputSchema: { prompt: z.string().describe('What the board is for') },
77
+ }, async ({ prompt }) => safe(async () => {
78
+ const { data } = await client.auth.getSession();
79
+ const token = data.session?.access_token;
80
+ if (!token)
81
+ throw new Error('No active session — run `zeroboard-mcp login`.');
82
+ const template = await generateBoardTemplate(prompt, token);
83
+ return db.createBoardFromTemplate(client, user.id, template);
84
+ }));
85
+ server.registerTool('update_board', { title: 'Update board', description: 'Rename a board or change its description.', inputSchema: { boardId: z.string(), name: z.string().optional(), description: z.string().optional() } }, async ({ boardId, name, description }) => safe(() => db.updateBoardMeta(client, boardId, { name, description })));
86
+ server.registerTool('delete_board', { title: 'Delete board', description: 'Permanently delete a board and all its cards.', inputSchema: { boardId: z.string() }, annotations: DESTRUCTIVE }, async ({ boardId }) => safe(() => db.deleteBoard(client, boardId)));
87
+ server.registerTool('add_column', { title: 'Add column', description: 'Add a column to a board.', inputSchema: { boardId: z.string(), title: z.string() } }, async ({ boardId, title }) => safe(() => db.addColumn(client, boardId, title)));
88
+ server.registerTool('update_column', { title: 'Rename column', description: 'Rename a column.', inputSchema: { boardId: z.string(), columnId: z.string(), title: z.string() } }, async ({ boardId, columnId, title }) => safe(() => db.updateColumn(client, boardId, columnId, title)));
89
+ server.registerTool('remove_column', { title: 'Remove column', description: 'Remove a column and its cards.', inputSchema: { boardId: z.string(), columnId: z.string() }, annotations: DESTRUCTIVE }, async ({ boardId, columnId }) => safe(() => db.removeColumn(client, boardId, columnId)));
90
+ server.registerTool('reorder_columns', { title: 'Reorder columns', description: 'Reorder columns. Provide every existing column id exactly once, in the desired order.', inputSchema: { boardId: z.string(), orderedColumnIds: z.array(z.string()) } }, async ({ boardId, orderedColumnIds }) => safe(() => db.reorderColumns(client, boardId, orderedColumnIds)));
91
+ server.registerTool('add_card', {
92
+ title: 'Add card',
93
+ description: 'Add a card to a column.',
94
+ inputSchema: {
95
+ boardId: z.string(),
96
+ columnId: z.string(),
97
+ title: z.string(),
98
+ description: z.string().optional(),
99
+ text: z.string().optional().describe('Card body text'),
100
+ targetDate: z.string().optional().describe('ISO date'),
101
+ labels: z.array(labelEnum).optional(),
102
+ },
103
+ }, async ({ boardId, columnId, title, description, text: body, targetDate, labels }) => safe(() => db.addCard(client, boardId, columnId, {
104
+ title,
105
+ description,
106
+ content: textToContent(body),
107
+ targetDate,
108
+ labels: labels,
109
+ })));
110
+ server.registerTool('update_card', {
111
+ title: 'Update card',
112
+ description: 'Update fields of a card.',
113
+ inputSchema: {
114
+ boardId: z.string(),
115
+ cardId: z.string(),
116
+ title: z.string().optional(),
117
+ description: z.string().optional(),
118
+ text: z.string().optional(),
119
+ targetDate: z.string().optional(),
120
+ },
121
+ }, async ({ boardId, cardId, title, description, text: body, targetDate }) => safe(() => db.updateCard(client, boardId, cardId, {
122
+ ...(title !== undefined ? { title } : {}),
123
+ ...(description !== undefined ? { description } : {}),
124
+ ...(body !== undefined ? { content: textToContent(body) } : {}),
125
+ ...(targetDate !== undefined ? { targetDate } : {}),
126
+ })));
127
+ server.registerTool('move_card', { title: 'Move card', description: 'Move a card to another column (and optional index).', inputSchema: { boardId: z.string(), cardId: z.string(), targetColumnId: z.string(), targetIndex: z.number().int().nonnegative().optional() } }, async ({ boardId, cardId, targetColumnId, targetIndex }) => safe(() => db.moveCard(client, boardId, cardId, targetColumnId, targetIndex)));
128
+ server.registerTool('archive_card', { title: 'Archive card', description: 'Archive a card.', inputSchema: { boardId: z.string(), cardId: z.string() } }, async ({ boardId, cardId }) => safe(() => db.setCardArchived(client, boardId, cardId, true)));
129
+ server.registerTool('restore_card', { title: 'Restore card', description: 'Restore an archived card.', inputSchema: { boardId: z.string(), cardId: z.string() } }, async ({ boardId, cardId }) => safe(() => db.setCardArchived(client, boardId, cardId, false)));
130
+ server.registerTool('duplicate_card', { title: 'Duplicate card', description: 'Duplicate a card within its column.', inputSchema: { boardId: z.string(), cardId: z.string() } }, async ({ boardId, cardId }) => safe(() => db.duplicateCard(client, boardId, cardId)));
131
+ server.registerTool('delete_card', { title: 'Delete card', description: 'Permanently delete a card.', inputSchema: { boardId: z.string(), cardId: z.string() }, annotations: DESTRUCTIVE }, async ({ boardId, cardId }) => safe(() => db.removeCard(client, boardId, cardId)));
132
+ server.registerTool('add_checklist_item', { title: 'Add checklist item', description: 'Add a checklist item to a card (converts the card content to a checklist).', inputSchema: { boardId: z.string(), cardId: z.string(), text: z.string() } }, async ({ boardId, cardId, text: itemText }) => safe(() => db.addChecklistItem(client, boardId, cardId, itemText)));
133
+ server.registerTool('toggle_checklist_item', { title: 'Toggle checklist item', description: 'Toggle (or set) a checklist item completed state.', inputSchema: { boardId: z.string(), cardId: z.string(), itemId: z.string(), completed: z.boolean().optional() } }, async ({ boardId, cardId, itemId, completed }) => safe(() => db.toggleChecklistItem(client, boardId, cardId, itemId, completed)));
134
+ server.registerTool('add_label', { title: 'Add label', description: `Add a color label to a card (${CARD_LABELS.join(', ')}).`, inputSchema: { boardId: z.string(), cardId: z.string(), label: labelEnum } }, async ({ boardId, cardId, label }) => safe(() => db.setLabel(client, boardId, cardId, label, true)));
135
+ server.registerTool('remove_label', { title: 'Remove label', description: 'Remove a color label from a card.', inputSchema: { boardId: z.string(), cardId: z.string(), label: labelEnum } }, async ({ boardId, cardId, label }) => safe(() => db.setLabel(client, boardId, cardId, label, false)));
136
+ server.registerTool('set_target_date', { title: 'Set target date', description: 'Set or clear a card target date (ISO string, or null to clear).', inputSchema: { boardId: z.string(), cardId: z.string(), targetDate: z.string().nullable() } }, async ({ boardId, cardId, targetDate }) => safe(() => db.setTargetDate(client, boardId, cardId, targetDate)));
137
+ server.registerTool('set_cover_image', { title: 'Set cover image', description: 'Set or clear a card cover image URL (or null to clear).', inputSchema: { boardId: z.string(), cardId: z.string(), coverImage: z.string().nullable() } }, async ({ boardId, cardId, coverImage }) => safe(() => db.setCoverImage(client, boardId, cardId, coverImage)));
138
+ return server;
139
+ }
140
+ function registerResources(server, client, user) {
141
+ server.registerResource('me', 'zeroboard://me', { title: 'Current user', description: 'The authenticated ZeroBoard account.', mimeType: 'application/json' }, async (uri) => ({
142
+ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ id: user.id, email: user.email }, null, 2) }],
143
+ }));
144
+ server.registerResource('boards', 'zeroboard://boards', { title: 'Boards index', description: 'All of your boards.', mimeType: 'application/json' }, async (uri) => ({
145
+ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await db.listBoards(client), null, 2) }],
146
+ }));
147
+ server.registerResource('board', new ResourceTemplate('zeroboard://board/{boardId}', { list: undefined }), { title: 'Board', description: 'A full board (columns + cards) as JSON.', mimeType: 'application/json' }, async (uri, variables) => {
148
+ const boardId = Array.isArray(variables.boardId) ? variables.boardId[0] : variables.boardId;
149
+ return {
150
+ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await db.getBoard(client, boardId), null, 2) }],
151
+ };
152
+ });
153
+ }
154
+ export async function runServer() {
155
+ let client;
156
+ let user;
157
+ try {
158
+ ({ client, user } = await getAuthedClient());
159
+ }
160
+ catch (err) {
161
+ if (err instanceof NotAuthenticatedError) {
162
+ console.error(`[zeroboard-mcp] ${err.message}`);
163
+ process.exit(1);
164
+ }
165
+ throw err;
166
+ }
167
+ const server = buildServer(client, user);
168
+ registerResources(server, client, user);
169
+ const transport = new StdioServerTransport();
170
+ await server.connect(transport);
171
+ console.error(`[zeroboard-mcp] ready as ${user.email ?? user.id}${READ_ONLY ? ' (read-only)' : ''}. Listening on stdio.`);
172
+ }
package/dist/smoke.js ADDED
@@ -0,0 +1,89 @@
1
+ // End-to-end smoke test of the board-data layer against REAL Supabase, using the
2
+ // dedicated e2e test user. Uses a direct in-memory client (does NOT touch the
3
+ // ~/.zeroboard credential store). Run: npm run build && npm run smoke
4
+ import { readFileSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { createClient } from '@supabase/supabase-js';
7
+ import * as db from './board-data.js';
8
+ function loadEnv() {
9
+ for (const p of [resolve(process.cwd(), '.env.local'), resolve(process.cwd(), '..', '.env.local')]) {
10
+ try {
11
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
12
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
13
+ if (!m || process.env[m[1]] !== undefined)
14
+ continue;
15
+ let v = m[2];
16
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
17
+ v = v.slice(1, -1);
18
+ process.env[m[1]] = v;
19
+ }
20
+ }
21
+ catch {
22
+ /* try next path */
23
+ }
24
+ }
25
+ }
26
+ let passed = 0;
27
+ function check(label, cond) {
28
+ if (!cond)
29
+ throw new Error(`FAILED: ${label}`);
30
+ passed++;
31
+ console.log(` ✓ ${label}`);
32
+ }
33
+ async function main() {
34
+ loadEnv();
35
+ const url = process.env.VITE_SUPABASE_URL;
36
+ const anon = process.env.VITE_SUPABASE_ANON_KEY;
37
+ const email = process.env.E2E_EMAIL;
38
+ const password = process.env.E2E_PASSWORD;
39
+ if (!url || !anon || !email || !password) {
40
+ throw new Error('Missing VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY / E2E_EMAIL / E2E_PASSWORD (run scripts/e2e/ensure-test-user.mjs).');
41
+ }
42
+ const client = createClient(url, anon, {
43
+ auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
44
+ });
45
+ const signIn = await client.auth.signInWithPassword({ email, password });
46
+ if (signIn.error || !signIn.data.user)
47
+ throw new Error(`sign-in failed: ${signIn.error?.message}`);
48
+ const userId = signIn.data.user.id;
49
+ console.log(`Signed in as ${email}\n`);
50
+ let boardId = '';
51
+ try {
52
+ const board = await db.createBoard(client, userId, 'MCP Smoke Board', 'created by smoke test');
53
+ boardId = board.id;
54
+ check('create_board returns default columns', board.columns.length === 5);
55
+ const list = await db.listBoards(client);
56
+ check('list_boards includes the new board', list.some((b) => b.id === boardId));
57
+ const withCol = await db.addColumn(client, boardId, 'Review');
58
+ check('add_column appends a column', withCol.columns.length === 6);
59
+ const reviewCol = withCol.columns.find((c) => c.title === 'Review');
60
+ check('add_column sets incremental order', reviewCol.order === 5);
61
+ const todo = withCol.columns.find((c) => c.title === 'To Do');
62
+ const afterCard = await db.addCard(client, boardId, todo.id, { title: 'Ship MCP v1', content: { type: 'text', text: 'wire it up' }, labels: ['green'] });
63
+ const card = afterCard.columns.find((c) => c.id === todo.id).cards[0];
64
+ check('add_card creates a card with a label', card.title === 'Ship MCP v1' && card.labels?.includes('green') === true);
65
+ const moved = await db.moveCard(client, boardId, card.id, reviewCol.id);
66
+ check('move_card moves the card to the target column', moved.columns.find((c) => c.id === reviewCol.id).cards.some((x) => x.id === card.id));
67
+ check('move_card removes from source column', moved.columns.find((c) => c.id === todo.id).cards.length === 0);
68
+ const checked = await db.addChecklistItem(client, boardId, card.id, 'subtask 1');
69
+ const checkedCard = checked.columns.flatMap((c) => c.cards).find((x) => x.id === card.id);
70
+ check('add_checklist_item converts content to checklist', checkedCard.content.type === 'checklist' && checkedCard.content.checklist?.length === 1);
71
+ const hits = await db.search(client, 'Ship MCP');
72
+ check('search finds the card', hits.some((h) => h.cardId === card.id));
73
+ const fetched = await db.getBoard(client, boardId);
74
+ check('get_board persists across reads (round-trips boards.data JSONB)', fetched.columns.flatMap((c) => c.cards).some((x) => x.id === card.id));
75
+ const renamed = await db.updateBoardMeta(client, boardId, { name: 'MCP Smoke Board (renamed)' });
76
+ check('update_board renames', renamed.name === 'MCP Smoke Board (renamed)');
77
+ }
78
+ finally {
79
+ if (boardId) {
80
+ await db.deleteBoard(client, boardId);
81
+ console.log('\n ✓ cleanup: deleted smoke board');
82
+ }
83
+ }
84
+ console.log(`\n✅ smoke passed (${passed} checks)`);
85
+ }
86
+ main().catch((err) => {
87
+ console.error(`\n❌ ${err instanceof Error ? err.message : String(err)}`);
88
+ process.exit(1);
89
+ });
@@ -0,0 +1,44 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { SUPABASE_URL, SUPABASE_ANON_KEY, storageKeyFor, assertConfigured } from './config.js';
3
+ import { fileStorage } from './credentials.js';
4
+ /**
5
+ * Build a Supabase client backed by the on-disk credential store. With
6
+ * persistSession + autoRefreshToken, supabase-js loads the saved session, keeps
7
+ * the access token fresh, and rewrites the rotated refresh token to disk — the
8
+ * server equivalent of the web app's api/_lib/auth.ts authenticated client.
9
+ * Every request carries a fresh, RLS-scoped JWT (anon key only; never service-role).
10
+ */
11
+ export function makeClient() {
12
+ assertConfigured();
13
+ return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
14
+ auth: {
15
+ storage: fileStorage,
16
+ storageKey: storageKeyFor(SUPABASE_URL),
17
+ persistSession: true,
18
+ autoRefreshToken: true,
19
+ detectSessionInUrl: false,
20
+ },
21
+ });
22
+ }
23
+ export class NotAuthenticatedError extends Error {
24
+ constructor(message = 'Not signed in. Run `zeroboard-mcp login` first.') {
25
+ super(message);
26
+ this.name = 'NotAuthenticatedError';
27
+ }
28
+ }
29
+ /**
30
+ * Return a client whose stored session is valid against the server. Throws
31
+ * NotAuthenticatedError if there is no session or the token is revoked/expired.
32
+ */
33
+ export async function getAuthedClient() {
34
+ const client = makeClient();
35
+ const { data: sessionData } = await client.auth.getSession();
36
+ if (!sessionData.session)
37
+ throw new NotAuthenticatedError();
38
+ // Validate against the server (also triggers a refresh if needed).
39
+ const { data, error } = await client.auth.getUser();
40
+ if (error || !data.user) {
41
+ throw new NotAuthenticatedError(`Stored session is no longer valid${error ? ` (${error.message})` : ''}. Run \`zeroboard-mcp login\`.`);
42
+ }
43
+ return { client, user: data.user };
44
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // Types mirrored from the web app (src/types/index.ts). The MCP server reads and
2
+ // writes the SAME boards.data JSONB the app uses, so these shapes MUST stay in
3
+ // sync with the app's Card / Column / CardContent definitions.
4
+ export const CARD_LABELS = ['red', 'yellow', 'green', 'blue', 'purple', 'gray'];
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@zeroclickdev/zeroboard-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for ZeroBoard — manage your boards, columns, and cards from any MCP-compatible agent (Claude Code, Cursor, Zed, Windsurf).",
5
+ "type": "module",
6
+ "bin": {
7
+ "zeroboard-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "homepage": "https://board.zeroclickdev.ai",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/tmcfarlane/zeroclickboards.git",
20
+ "directory": "mcp-server"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/tmcfarlane/zeroclickboards/issues"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "start": "node dist/index.js",
31
+ "smoke": "node dist/smoke.js",
32
+ "typecheck": "tsc --noEmit",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "keywords": [
36
+ "mcp",
37
+ "model-context-protocol",
38
+ "kanban",
39
+ "zeroboard",
40
+ "supabase"
41
+ ],
42
+ "license": "MIT",
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.18.0",
45
+ "@supabase/supabase-js": "^2.56.0",
46
+ "zod": "^3.23.8"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.0.0",
50
+ "typescript": "^5.6.0"
51
+ }
52
+ }