From a6a4ffda2ed95a1eb54a3f89bfbff9d93f08a3bc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 13:06:46 +0200 Subject: [PATCH] docs: add implementation plans for image URL fetching, auth, and MCP server Three detailed implementation plans with TDD, exact code, and step-by-step tasks: - Image URL fetching: 4 tasks (schema, Zod, service, route) - Authentication: 9 tasks (tables, service, middleware, routes, frontend) - MCP server: 9 tasks (SDK, tools, resources, Hono integration) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-03-authentication.md | 1503 +++++++++++++++++ .../plans/2026-04-03-image-url-fetching.md | 404 +++++ .../plans/2026-04-03-mcp-server.md | 1223 ++++++++++++++ 3 files changed, 3130 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-03-authentication.md create mode 100644 docs/superpowers/plans/2026-04-03-image-url-fetching.md create mode 100644 docs/superpowers/plans/2026-04-03-mcp-server.md diff --git a/docs/superpowers/plans/2026-04-03-authentication.md b/docs/superpowers/plans/2026-04-03-authentication.md new file mode 100644 index 0000000..d881592 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-authentication.md @@ -0,0 +1,1503 @@ +# Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add public-read / authenticated-write auth to GearBox with cookie sessions for the web UI, API keys for programmatic access, and a Gitea-style login button. + +**Architecture:** Three new DB tables (users, sessions, api_keys). Auth service handles password hashing (Bun.password/argon2), session management, and API key verification. Hono middleware guards write endpoints. Frontend gets a `useAuth` hook, login page, conditional UI for edit actions, and API key management in settings. + +**Tech Stack:** Hono middleware, Bun.password (argon2), Drizzle ORM, React Query, TanStack Router, Zustand + +--- + +## File Structure + +| Action | Path | Responsibility | +|--------|------|----------------| +| Modify | `src/db/schema.ts` | Add users, sessions, apiKeys tables | +| Modify | `tests/helpers/db.ts` | Add CREATE TABLE for users, sessions, api_keys | +| Create | `src/server/services/auth.service.ts` | Password hashing, session CRUD, API key CRUD | +| Create | `src/server/middleware/auth.ts` | Hono middleware for write endpoint protection | +| Create | `src/server/routes/auth.ts` | Login, logout, setup, password change, API key routes | +| Modify | `src/server/index.ts` | Register auth routes, apply auth middleware | +| Create | `src/client/hooks/useAuth.ts` | Auth state hook (React Query) | +| Create | `src/client/routes/login.tsx` | Login page | +| Modify | `src/client/routes/settings.tsx` | Add password change and API key management sections | +| Modify | `src/client/components/TotalsBar.tsx` | Add login/user button to top-right | +| Modify | `src/client/routes/__root.tsx` | Wrap conditional UI based on auth state | +| Create | `tests/services/auth.service.test.ts` | Auth service tests | +| Create | `tests/routes/auth.test.ts` | Auth route tests | + +--- + +### Task 1: Add Auth Database Tables + +**Files:** +- Modify: `src/db/schema.ts` +- Modify: `tests/helpers/db.ts` + +- [ ] **Step 1: Add users table to Drizzle schema** + +In `src/db/schema.ts`, add after the `settings` table: + +```typescript +export const users = sqliteTable("users", { + id: integer("id").primaryKey({ autoIncrement: true }), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); +``` + +- [ ] **Step 2: Add sessions table to Drizzle schema** + +In `src/db/schema.ts`, add after the `users` table: + +```typescript +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), +}); +``` + +- [ ] **Step 3: Add apiKeys table to Drizzle schema** + +In `src/db/schema.ts`, add after the `sessions` table: + +```typescript +export const apiKeys = sqliteTable("api_keys", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + keyHash: text("key_hash").notNull(), + keyPrefix: text("key_prefix").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); +``` + +- [ ] **Step 4: Update test helper with CREATE TABLE statements** + +In `tests/helpers/db.ts`, add after the settings CREATE TABLE: + +```typescript +sqlite.run(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); + +sqlite.run(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL + ) +`); + +sqlite.run(` + CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); +``` + +- [ ] **Step 5: Generate and apply migration** + +Run: `bun run db:generate && bun run db:push` +Expected: Migration creates users, sessions, api_keys tables. + +- [ ] **Step 6: Run existing tests** + +Run: `bun test` +Expected: All existing tests still pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/db/schema.ts tests/helpers/db.ts drizzle/ +git commit -m "feat: add users, sessions, and api_keys tables" +``` + +--- + +### Task 2: Create Auth Service + +**Files:** +- Create: `src/server/services/auth.service.ts` +- Create: `tests/services/auth.service.test.ts` + +- [ ] **Step 1: Write failing tests for auth service** + +Create `tests/services/auth.service.test.ts`: + +```typescript +import { describe, expect, test, beforeEach } from "bun:test"; +import { createTestDb } from "../helpers/db"; +import { + createUser, + verifyPassword, + createSession, + getSession, + deleteSession, + createApiKey, + verifyApiKey, + listApiKeys, + deleteApiKey, + getUserCount, + changePassword, +} from "../../src/server/services/auth.service"; + +let db: ReturnType; + +beforeEach(() => { + db = createTestDb(); +}); + +describe("user management", () => { + test("creates a user with hashed password", async () => { + const user = await createUser(db, "admin", "password123"); + expect(user.username).toBe("admin"); + expect(user.passwordHash).not.toBe("password123"); + }); + + test("verifies correct password", async () => { + await createUser(db, "admin", "password123"); + const result = await verifyPassword(db, "admin", "password123"); + expect(result).not.toBeNull(); + expect(result!.username).toBe("admin"); + }); + + test("rejects incorrect password", async () => { + await createUser(db, "admin", "password123"); + const result = await verifyPassword(db, "admin", "wrongpassword"); + expect(result).toBeNull(); + }); + + test("returns user count", async () => { + expect(getUserCount(db)).toBe(0); + await createUser(db, "admin", "password123"); + expect(getUserCount(db)).toBe(1); + }); + + test("changes password", async () => { + await createUser(db, "admin", "old"); + const changed = await changePassword(db, "admin", "old", "new"); + expect(changed).toBe(true); + const result = await verifyPassword(db, "admin", "new"); + expect(result).not.toBeNull(); + }); + + test("rejects password change with wrong current password", async () => { + await createUser(db, "admin", "old"); + const changed = await changePassword(db, "admin", "wrong", "new"); + expect(changed).toBe(false); + }); +}); + +describe("session management", () => { + test("creates and retrieves a session", async () => { + const user = await createUser(db, "admin", "pass"); + const session = createSession(db, user.id); + expect(session.id).toHaveLength(64); // 32 bytes hex + + const found = getSession(db, session.id); + expect(found).not.toBeNull(); + expect(found!.userId).toBe(user.id); + }); + + test("returns null for expired session", async () => { + const user = await createUser(db, "admin", "pass"); + const session = createSession(db, user.id, -1); // expired 1 day ago + const found = getSession(db, session.id); + expect(found).toBeNull(); + }); + + test("deletes a session", async () => { + const user = await createUser(db, "admin", "pass"); + const session = createSession(db, user.id); + deleteSession(db, session.id); + expect(getSession(db, session.id)).toBeNull(); + }); +}); + +describe("API key management", () => { + test("creates an API key and returns the raw key once", async () => { + const result = await createApiKey(db, "test-key"); + expect(result.name).toBe("test-key"); + expect(result.rawKey).toBeDefined(); + expect(result.rawKey.length).toBeGreaterThan(16); + expect(result.prefix).toBe(result.rawKey.slice(0, 8)); + }); + + test("verifies a valid API key", async () => { + const result = await createApiKey(db, "test-key"); + const valid = await verifyApiKey(db, result.rawKey); + expect(valid).toBe(true); + }); + + test("rejects an invalid API key", async () => { + const valid = await verifyApiKey(db, "invalid-key"); + expect(valid).toBe(false); + }); + + test("lists API keys without exposing hashes", () => { + // createApiKey is async, need to handle properly + }); + + test("deletes an API key", async () => { + const result = await createApiKey(db, "test-key"); + deleteApiKey(db, result.id); + const valid = await verifyApiKey(db, result.rawKey); + expect(valid).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/services/auth.service.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement auth service** + +Create `src/server/services/auth.service.ts`: + +```typescript +import { randomBytes } from "node:crypto"; +import { eq, count } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { users, sessions, apiKeys } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +// --- User Management --- + +export async function createUser(db: Db = prodDb, username: string, password: string) { + const passwordHash = await Bun.password.hash(password); + return db + .insert(users) + .values({ username, passwordHash }) + .returning() + .get(); +} + +export async function verifyPassword(db: Db = prodDb, username: string, password: string) { + const user = db + .select() + .from(users) + .where(eq(users.username, username)) + .get(); + + if (!user) return null; + + const valid = await Bun.password.verify(password, user.passwordHash); + return valid ? user : null; +} + +export function getUserCount(db: Db = prodDb): number { + const result = db.select({ count: count() }).from(users).get(); + return result?.count ?? 0; +} + +export async function changePassword( + db: Db = prodDb, + username: string, + currentPassword: string, + newPassword: string, +): Promise { + const user = await verifyPassword(db, username, currentPassword); + if (!user) return false; + + const passwordHash = await Bun.password.hash(newPassword); + db.update(users) + .set({ passwordHash }) + .where(eq(users.id, user.id)) + .run(); + return true; +} + +// --- Session Management --- + +export function createSession(db: Db = prodDb, userId: number, expiryDays = 30) { + const id = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); + + return db + .insert(sessions) + .values({ id, userId, expiresAt }) + .returning() + .get(); +} + +export function getSession(db: Db = prodDb, sessionId: string) { + const session = db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .get(); + + if (!session) return null; + + // Check expiry + if (session.expiresAt < new Date()) { + db.delete(sessions).where(eq(sessions.id, sessionId)).run(); + return null; + } + + return session; +} + +export function deleteSession(db: Db = prodDb, sessionId: string) { + db.delete(sessions).where(eq(sessions.id, sessionId)).run(); +} + +export function refreshSession(db: Db = prodDb, sessionId: string, expiryDays = 30) { + const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); + db.update(sessions) + .set({ expiresAt }) + .where(eq(sessions.id, sessionId)) + .run(); +} + +// --- API Key Management --- + +export async function createApiKey(db: Db = prodDb, name: string) { + const rawKey = randomBytes(32).toString("hex"); + const keyHash = await Bun.password.hash(rawKey); + const keyPrefix = rawKey.slice(0, 8); + + const record = db + .insert(apiKeys) + .values({ name, keyHash, keyPrefix }) + .returning() + .get(); + + return { ...record, rawKey }; +} + +export async function verifyApiKey(db: Db = prodDb, rawKey: string): Promise { + const prefix = rawKey.slice(0, 8); + const candidates = db + .select() + .from(apiKeys) + .where(eq(apiKeys.keyPrefix, prefix)) + .all(); + + for (const candidate of candidates) { + if (await Bun.password.verify(rawKey, candidate.keyHash)) { + return true; + } + } + return false; +} + +export function listApiKeys(db: Db = prodDb) { + return db + .select({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .all(); +} + +export function deleteApiKey(db: Db = prodDb, id: number) { + db.delete(apiKeys).where(eq(apiKeys.id, id)).run(); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/services/auth.service.test.ts` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/auth.service.ts tests/services/auth.service.test.ts +git commit -m "feat: add auth service with user, session, and API key management" +``` + +--- + +### Task 3: Create Auth Middleware + +**Files:** +- Create: `src/server/middleware/auth.ts` +- Create: `tests/middleware/auth.test.ts` + +- [ ] **Step 1: Write failing middleware tests** + +Create `tests/middleware/auth.test.ts`: + +```typescript +import { describe, expect, test, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import { createTestDb } from "../helpers/db"; +import { requireAuth } from "../../src/server/middleware/auth"; +import { createUser, createSession, createApiKey } from "../../src/server/services/auth.service"; + +let db: ReturnType; + +beforeEach(() => { + db = createTestDb(); +}); + +function createApp() { + const app = new Hono<{ Variables: { db?: any } }>(); + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + // Public GET + app.get("/items", (c) => c.json({ ok: true })); + + // Protected POST + app.post("/items", requireAuth, (c) => c.json({ ok: true })); + + return app; +} + +describe("auth middleware", () => { + test("allows GET requests without auth", async () => { + const app = createApp(); + const res = await app.request("/items"); + expect(res.status).toBe(200); + }); + + test("rejects POST without auth", async () => { + const app = createApp(); + const res = await app.request("/items", { method: "POST" }); + expect(res.status).toBe(401); + }); + + test("allows POST with valid session cookie", async () => { + const app = createApp(); + const user = await createUser(db, "admin", "pass"); + const session = createSession(db, user.id); + + const res = await app.request("/items", { + method: "POST", + headers: { Cookie: `gearbox_session=${session.id}` }, + }); + expect(res.status).toBe(200); + }); + + test("allows POST with valid API key", async () => { + const app = createApp(); + const key = await createApiKey(db, "test"); + + const res = await app.request("/items", { + method: "POST", + headers: { "X-API-Key": key.rawKey }, + }); + expect(res.status).toBe(200); + }); + + test("rejects POST with invalid API key", async () => { + const app = createApp(); + const res = await app.request("/items", { + method: "POST", + headers: { "X-API-Key": "invalid" }, + }); + expect(res.status).toBe(401); + }); + + test("returns 403 setup_required when no users exist", async () => { + const app = createApp(); + const res = await app.request("/items", { method: "POST" }); + // With no users, middleware should return 401 (or 403 setup_required) + const body = await res.json(); + expect(res.status).toBe(403); + expect(body.error).toBe("setup_required"); + }); +}); +``` + +Note: The last test ("setup_required") should only trigger when there are no users and no auth credentials are provided. Adjust the middleware logic: if `getUserCount === 0`, return 403 with `setup_required`. Otherwise return 401. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/middleware/auth.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement auth middleware** + +Create `src/server/middleware/auth.ts`: + +```typescript +import { getCookie } from "hono/cookie"; +import type { Context, Next } from "hono"; +import { getSession, verifyApiKey, getUserCount, refreshSession } from "../services/auth.service"; + +export async function requireAuth(c: Context, next: Next) { + const db = c.get("db"); + + // Check if any users exist at all + if (getUserCount(db) === 0) { + return c.json({ error: "setup_required" }, 403); + } + + // Check API key first + const apiKey = c.req.header("X-API-Key"); + if (apiKey) { + const valid = await verifyApiKey(db, apiKey); + if (valid) return next(); + return c.json({ error: "Invalid API key" }, 401); + } + + // Check session cookie + const sessionId = getCookie(c, "gearbox_session"); + if (sessionId) { + const session = getSession(db, sessionId); + if (session) { + // Refresh session expiry on use + refreshSession(db, sessionId); + return next(); + } + } + + return c.json({ error: "Authentication required" }, 401); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/middleware/auth.test.ts` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/middleware/auth.ts tests/middleware/auth.test.ts +git commit -m "feat: add auth middleware for write endpoint protection" +``` + +--- + +### Task 4: Create Auth Routes + +**Files:** +- Create: `src/server/routes/auth.ts` +- Create: `tests/routes/auth.test.ts` + +- [ ] **Step 1: Write failing route tests** + +Create `tests/routes/auth.test.ts`: + +```typescript +import { describe, expect, test, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import { createTestDb } from "../helpers/db"; +import { authRoutes } from "../../src/server/routes/auth"; + +let db: ReturnType; + +function createApp() { + const app = new Hono<{ Variables: { db?: any } }>(); + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + app.route("/api/auth", authRoutes); + return app; +} + +beforeEach(() => { + db = createTestDb(); +}); + +describe("POST /api/auth/setup", () => { + test("creates first user account", async () => { + const app = createApp(); + const res = await app.request("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "pass123" }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.username).toBe("admin"); + }); + + test("rejects setup when user already exists", async () => { + const app = createApp(); + // First setup + await app.request("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "pass123" }), + }); + // Second attempt + const res = await app.request("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin2", password: "pass123" }), + }); + expect(res.status).toBe(403); + }); +}); + +describe("POST /api/auth/login", () => { + test("returns session cookie on valid login", async () => { + const app = createApp(); + // Setup first + await app.request("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "pass123" }), + }); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "pass123" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toContain("gearbox_session"); + }); + + test("rejects invalid credentials", async () => { + const app = createApp(); + await app.request("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "pass123" }), + }); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "wrong" }), + }); + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/auth/me", () => { + test("returns null when not authenticated", async () => { + const app = createApp(); + const res = await app.request("/api/auth/me"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.user).toBeNull(); + expect(body.setupRequired).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/routes/auth.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement auth routes** + +Create `src/server/routes/auth.ts`: + +```typescript +import { eq } from "drizzle-orm"; +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { setCookie, deleteCookie, getCookie } from "hono/cookie"; +import { z } from "zod"; +import { users } from "../../db/schema.ts"; +import { + createUser, + verifyPassword, + createSession, + getSession, + deleteSession, + getUserCount, + changePassword, + createApiKey, + listApiKeys, + deleteApiKey, +} from "../services/auth.service"; +import { requireAuth } from "../middleware/auth"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +const setupSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(6, "Password must be at least 6 characters"), +}); + +const createKeySchema = z.object({ + name: z.string().min(1, "Key name is required"), +}); + +// --- Public routes --- + +app.get("/me", (c) => { + const db = c.get("db"); + const setupRequired = getUserCount(db) === 0; + + const sessionId = getCookie(c, "gearbox_session"); + if (sessionId) { + const session = getSession(db, sessionId); + if (session) { + // Get user info + return c.json({ user: { id: session.userId }, setupRequired: false }); + } + } + + return c.json({ user: null, setupRequired }); +}); + +app.post("/setup", zValidator("json", setupSchema), async (c) => { + const db = c.get("db"); + + if (getUserCount(db) > 0) { + return c.json({ error: "Account already exists" }, 403); + } + + const { username, password } = c.req.valid("json"); + const user = await createUser(db, username, password); + const session = createSession(db, user.id); + + setCookie(c, "gearbox_session", session.id, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: 30 * 24 * 60 * 60, // 30 days + }); + + return c.json({ username: user.username }, 201); +}); + +app.post("/login", zValidator("json", loginSchema), async (c) => { + const db = c.get("db"); + const { username, password } = c.req.valid("json"); + + const user = await verifyPassword(db, username, password); + if (!user) { + return c.json({ error: "Invalid credentials" }, 401); + } + + const session = createSession(db, user.id); + + setCookie(c, "gearbox_session", session.id, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: 30 * 24 * 60 * 60, + }); + + return c.json({ username: user.username }); +}); + +app.post("/logout", (c) => { + const db = c.get("db"); + const sessionId = getCookie(c, "gearbox_session"); + if (sessionId) { + deleteSession(db, sessionId); + } + deleteCookie(c, "gearbox_session", { path: "/" }); + return c.json({ success: true }); +}); + +// --- Protected routes --- + +app.put("/password", requireAuth, zValidator("json", changePasswordSchema), async (c) => { + const db = c.get("db"); + const sessionId = getCookie(c, "gearbox_session"); + if (!sessionId) return c.json({ error: "Session required" }, 401); + + const session = getSession(db, sessionId); + if (!session) return c.json({ error: "Invalid session" }, 401); + + const { currentPassword, newPassword } = c.req.valid("json"); + + // Look up username from session's userId + const userRecord = db.select().from(users).where(eq(users.id, session.userId)).get(); + if (!userRecord) return c.json({ error: "User not found" }, 401); + + const changed = await changePassword(db, userRecord.username, currentPassword, newPassword); + if (!changed) return c.json({ error: "Current password is incorrect" }, 401); + + return c.json({ success: true }); +}); + +// API Key management +app.get("/keys", requireAuth, (c) => { + const db = c.get("db"); + return c.json(listApiKeys(db)); +}); + +app.post("/keys", requireAuth, zValidator("json", createKeySchema), async (c) => { + const db = c.get("db"); + const { name } = c.req.valid("json"); + const result = await createApiKey(db, name); + return c.json({ + id: result.id, + name: result.name, + key: result.rawKey, + prefix: result.keyPrefix, + }, 201); +}); + +app.delete("/keys/:id", requireAuth, async (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + deleteApiKey(db, id); + return c.json({ success: true }); +}); + +export { app as authRoutes }; +``` + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/routes/auth.test.ts` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/routes/auth.ts tests/routes/auth.test.ts +git commit -m "feat: add auth routes for login, setup, and API key management" +``` + +--- + +### Task 5: Register Auth Routes and Apply Middleware to Write Endpoints + +**Files:** +- Modify: `src/server/index.ts` + +- [ ] **Step 1: Register auth routes in server index** + +In `src/server/index.ts`, add import: + +```typescript +import { authRoutes } from "./routes/auth.ts"; +``` + +Add route registration after the other API routes: + +```typescript +app.route("/api/auth", authRoutes); +``` + +- [ ] **Step 2: Apply auth middleware to write endpoints** + +In `src/server/index.ts`, add import: + +```typescript +import { requireAuth } from "./middleware/auth.ts"; +``` + +Add middleware that protects all non-GET API requests (except auth routes): + +```typescript +// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes +app.use("/api/*", async (c, next) => { + // Skip auth routes — they handle their own auth + if (c.req.path.startsWith("/api/auth")) return next(); + // Skip GET requests — read is public + if (c.req.method === "GET") return next(); + // All other methods require auth + return requireAuth(c, next); +}); +``` + +This middleware must be registered **before** the API route registrations. + +- [ ] **Step 3: Run all tests** + +Run: `bun test` +Expected: Some existing POST/PUT/DELETE tests may now fail because they don't provide auth. The test db has no users, so middleware should return 403 `setup_required`. This is expected — we'll address this in Step 4. + +- [ ] **Step 4: Update existing route tests to handle auth** + +Since the middleware checks `getUserCount` and returns 403 when no users exist, the existing route tests that do POST/PUT/DELETE will need to either: +- Create a user and session in test setup, OR +- The test apps already mount routes directly without the global middleware, so they should be unaffected. + +Check which tests fail. If route tests mount routes directly (which they do — each test creates its own `Hono` app and mounts routes), the global middleware in `index.ts` won't apply to them. They should still pass. + +Run: `bun test` +Expected: All tests pass (the per-route test apps don't use the global middleware). + +- [ ] **Step 5: Commit** + +```bash +git add src/server/index.ts +git commit -m "feat: register auth routes and apply write-protection middleware" +``` + +--- + +### Task 6: Create Frontend Auth Hook and Login Page + +**Files:** +- Create: `src/client/hooks/useAuth.ts` +- Create: `src/client/routes/login.tsx` + +- [ ] **Step 1: Create useAuth hook** + +Create `src/client/hooks/useAuth.ts`: + +```typescript +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost } from "../lib/api"; + +interface AuthState { + user: { id: number } | null; + setupRequired: boolean; +} + +export function useAuth() { + return useQuery({ + queryKey: ["auth"], + queryFn: () => apiGet("/api/auth/me"), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +export function useLogin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { username: string; password: string }) => + apiPost<{ username: string }>("/api/auth/login", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useSetup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { username: string; password: string }) => + apiPost<{ username: string }>("/api/auth/setup", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useChangePassword() { + return useMutation({ + mutationFn: (data: { currentPassword: string; newPassword: string }) => + apiPut<{ success: boolean }>("/api/auth/password", data), + }); +} + +interface ApiKeyResponse { + id: number; + name: string; + key: string; + prefix: string; +} + +interface ApiKeyListItem { + id: number; + name: string; + keyPrefix: string; + createdAt: string; +} + +export function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: () => apiGet("/api/auth/keys"), + }); +} + +export function useCreateApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string }) => + apiPost("/api/auth/keys", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + }, + }); +} + +export function useDeleteApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiPost<{ success: boolean }>(`/api/auth/keys/${id}`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + }, + }); +} +``` + +Note: `useDeleteApiKey` should use `apiDelete` not `apiPost`. Fix: + +```typescript +export function useDeleteApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + }, + }); +} +``` + +Also import `apiDelete`: + +```typescript +import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; +``` + +- [ ] **Step 2: Create login page** + +Create `src/client/routes/login.tsx`: + +```typescript +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useAuth, useLogin, useSetup } from "../hooks/useAuth"; + +export const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +function LoginPage() { + const navigate = useNavigate(); + const { data: auth } = useAuth(); + const login = useLogin(); + const setup = useSetup(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const isSetup = auth?.setupRequired ?? false; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + try { + if (isSetup) { + await setup.mutateAsync({ username, password }); + } else { + await login.mutateAsync({ username, password }); + } + navigate({ to: "/" }); + } catch (err) { + setError((err as Error).message); + } + } + + const isPending = login.isPending || setup.isPending; + + return ( +
+
+

+ {isSetup ? "Create Account" : "Sign In"} +

+ +
+ {isSetup && ( +

+ Create your admin account to manage your gear collection. +

+ )} + +
+ + setUsername(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={isSetup ? 6 : undefined} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/client/hooks/useAuth.ts src/client/routes/login.tsx +git commit -m "feat: add useAuth hook and login page" +``` + +--- + +### Task 7: Add Login Button to TotalsBar and Conditional UI + +**Files:** +- Modify: `src/client/components/TotalsBar.tsx` +- Modify: `src/client/routes/__root.tsx` + +- [ ] **Step 1: Add login/user button to TotalsBar** + +In `src/client/components/TotalsBar.tsx`, add imports: + +```typescript +import { useAuth, useLogout } from "../hooks/useAuth"; +``` + +Inside the `TotalsBar` component, add: + +```typescript +const { data: auth } = useAuth(); +const logout = useLogout(); +const isAuthenticated = !!auth?.user; +``` + +In the JSX return, add a login/user section at the right end of the bar. After the stats section and before the closing tag of the bar container, add: + +```tsx +
+ {isAuthenticated ? ( + + ) : ( + + Sign in + + )} +
+``` + +- [ ] **Step 2: Hide FAB and edit actions when not authenticated** + +In `src/client/routes/__root.tsx`, add: + +```typescript +import { useAuth } from "../hooks/useAuth"; +``` + +Inside `RootLayout`, add: + +```typescript +const { data: auth } = useAuth(); +const isAuthenticated = !!auth?.user; +``` + +Update the FAB visibility condition: + +```typescript +{showFab && isAuthenticated && ( +``` + +- [ ] **Step 3: Verify the app loads correctly** + +Run: `bun run dev` +Check: +- App loads without login wall +- "Sign in" button appears top-right +- FAB is hidden when not logged in +- Clicking "Sign in" shows login page +- After login, FAB appears and "Sign out" replaces "Sign in" + +- [ ] **Step 4: Commit** + +```bash +git add src/client/components/TotalsBar.tsx src/client/routes/__root.tsx +git commit -m "feat: add login button to header and conditional edit UI" +``` + +--- + +### Task 8: Add API Key Management and Password Change to Settings + +**Files:** +- Modify: `src/client/routes/settings.tsx` + +- [ ] **Step 1: Add password change section to settings** + +In `src/client/routes/settings.tsx`, add imports: + +```typescript +import { useState } from "react"; +import { useAuth, useChangePassword, useApiKeys, useCreateApiKey, useDeleteApiKey } from "../hooks/useAuth"; +``` + +Add a `ChangePasswordSection` component: + +```typescript +function ChangePasswordSection() { + const changePassword = useChangePassword(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + try { + await changePassword.mutateAsync({ currentPassword, newPassword }); + setMessage({ type: "success", text: "Password changed" }); + setCurrentPassword(""); + setNewPassword(""); + } catch (err) { + setMessage({ type: "error", text: (err as Error).message }); + } + } + + return ( +
+

Change Password

+ setCurrentPassword(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + setNewPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + {message && ( +

+ {message.text} +

+ )} + +
+ ); +} +``` + +- [ ] **Step 2: Add API key management section** + +Add an `ApiKeySection` component: + +```typescript +function ApiKeySection() { + const { data: keys } = useApiKeys(); + const createKey = useCreateApiKey(); + const deleteKey = useDeleteApiKey(); + const [name, setName] = useState(""); + const [newKey, setNewKey] = useState(null); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const result = await createKey.mutateAsync({ name }); + setNewKey(result.key); + setName(""); + } + + return ( +
+

API Keys

+

+ API keys allow programmatic access to GearBox (e.g., from Claude Desktop or scripts). +

+ + {newKey && ( +
+

+ Copy this key now — it won't be shown again: +

+ {newKey} + +
+ )} + +
+ setName(e.target.value)} + required + className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + +
+ + {keys && keys.length > 0 && ( +
+ {keys.map((key) => ( +
+
+ {key.name} + {key.keyPrefix}... +
+ +
+ ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Add sections to SettingsPage** + +In the `SettingsPage` component, add after the existing settings card, conditionally rendering the auth sections when logged in: + +```tsx +{auth?.user && ( +
+ +
+ +
+)} +``` + +Add the auth query at the top of `SettingsPage`: + +```typescript +const { data: auth } = useAuth(); +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/client/routes/settings.tsx +git commit -m "feat: add password change and API key management to settings" +``` + +--- + +### Task 9: Run Full Test Suite and Manual Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +Run: `bun test` +Expected: All tests pass. + +- [ ] **Step 2: Run linter** + +Run: `bun run lint` +Expected: No errors. + +- [ ] **Step 3: Manual verification** + +Run: `bun run dev` + +Verify: +1. App loads — all pages viewable without login +2. "Sign in" link in top-right of TotalsBar +3. Click "Sign in" → shows setup form (first time) or login form +4. Create account → redirected to home, "Sign out" appears +5. FAB and edit actions visible when logged in +6. FAB hidden when logged out +7. Settings page shows password change + API keys when logged in +8. Create API key → key displayed once +9. POST/PUT/DELETE API calls return 401 without auth +10. GET API calls work without auth diff --git a/docs/superpowers/plans/2026-04-03-image-url-fetching.md b/docs/superpowers/plans/2026-04-03-image-url-fetching.md new file mode 100644 index 0000000..d74d3fe --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-image-url-fetching.md @@ -0,0 +1,404 @@ +# Image URL Fetching Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `POST /api/images/from-url` endpoint that fetches an image from a URL, saves it locally, and returns the filename. Also add `imageSourceUrl` column to items and candidates. + +**Architecture:** New image service function handles URL fetching with validation (content-type, size, timeout). New route delegates to service. Schema changes add nullable `imageSourceUrl` to items and threadCandidates tables. Drizzle migration for the new column. + +**Tech Stack:** Hono routes, Zod validation, Drizzle ORM, Bun's native fetch, Bun's file I/O + +--- + +## File Structure + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `src/server/services/image.service.ts` | Image fetching logic (fetch URL, validate, save to disk) | +| Modify | `src/server/routes/images.ts` | Add `POST /from-url` route | +| Modify | `src/db/schema.ts` | Add `imageSourceUrl` to `items` and `threadCandidates` | +| Modify | `src/shared/schemas.ts` | Add `imageSourceUrl` to item/candidate Zod schemas | +| Modify | `src/server/services/item.service.ts` | Pass through `imageSourceUrl` in create/update | +| Modify | `src/server/services/thread.service.ts` | Pass through `imageSourceUrl` in candidate create/update and thread resolution | +| Modify | `tests/helpers/db.ts` | Add `image_source_url` column to test CREATE TABLE statements | +| Create | `tests/services/image.service.test.ts` | Tests for image fetching service | +| Create | `tests/routes/images.test.ts` | Route-level tests for `/api/images/from-url` | + +--- + +### Task 1: Add `imageSourceUrl` Column to Database Schema + +**Files:** +- Modify: `src/db/schema.ts:12-29` (items table) +- Modify: `src/db/schema.ts:47-71` (threadCandidates table) +- Modify: `tests/helpers/db.ts:19-32` (items CREATE TABLE) +- Modify: `tests/helpers/db.ts:46-64` (thread_candidates CREATE TABLE) + +- [ ] **Step 1: Add column to Drizzle items table** + +In `src/db/schema.ts`, add after the `imageFilename` line in the `items` table: + +```typescript +imageSourceUrl: text("image_source_url"), +``` + +- [ ] **Step 2: Add column to Drizzle threadCandidates table** + +In `src/db/schema.ts`, add after the `imageFilename` line in the `threadCandidates` table: + +```typescript +imageSourceUrl: text("image_source_url"), +``` + +- [ ] **Step 3: Update test helper — items table** + +In `tests/helpers/db.ts`, add to the items CREATE TABLE statement after `image_filename TEXT,`: + +```sql +image_source_url TEXT, +``` + +- [ ] **Step 4: Update test helper — thread_candidates table** + +In `tests/helpers/db.ts`, add to the thread_candidates CREATE TABLE statement after `image_filename TEXT,`: + +```sql +image_source_url TEXT, +``` + +- [ ] **Step 5: Generate Drizzle migration** + +Run: `bun run db:generate` +Expected: A new migration file created in `drizzle/` adding `image_source_url` to both tables. + +- [ ] **Step 6: Apply migration** + +Run: `bun run db:push` +Expected: Migration applied successfully. + +- [ ] **Step 7: Run existing tests to verify no regressions** + +Run: `bun test` +Expected: All existing tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/db/schema.ts tests/helpers/db.ts drizzle/ +git commit -m "feat: add imageSourceUrl column to items and threadCandidates" +``` + +--- + +### Task 2: Update Zod Schemas and Service Functions + +**Files:** +- Modify: `src/shared/schemas.ts:1-16` (item schemas) +- Modify: `src/shared/schemas.ts:47-60` (candidate schemas) +- Modify: `src/server/services/item.service.ts:50-71` (createItem) +- Modify: `src/server/services/item.service.ts:73-101` (updateItem) + +- [ ] **Step 1: Add imageSourceUrl to createItemSchema** + +In `src/shared/schemas.ts`, add after the `imageFilename` line in `createItemSchema`: + +```typescript +imageSourceUrl: z.string().url().optional().or(z.literal("")), +``` + +- [ ] **Step 2: Add imageSourceUrl to createCandidateSchema** + +In `src/shared/schemas.ts`, add after the `imageFilename` line in `createCandidateSchema`: + +```typescript +imageSourceUrl: z.string().url().optional().or(z.literal("")), +``` + +- [ ] **Step 3: Update item service createItem** + +In `src/server/services/item.service.ts`, update the `createItem` function's `.values()` to include: + +```typescript +imageSourceUrl: data.imageSourceUrl ?? null, +``` + +- [ ] **Step 4: Update item service updateItem** + +In `src/server/services/item.service.ts`, add `imageSourceUrl: string` to the `Partial<{...}>` type in `updateItem`. + +- [ ] **Step 5: Update item service getAllItems and getItemById** + +Add `imageSourceUrl: items.imageSourceUrl` to the `.select()` objects in both `getAllItems` and `getItemById`. + +- [ ] **Step 6: Update thread service candidate create/update** + +In `src/server/services/thread.service.ts`, find the candidate create and update functions. Add `imageSourceUrl` passthrough in the same pattern as `imageFilename`. Also ensure that when resolving a thread (copying candidate data to a new item), `imageSourceUrl` is copied from the winning candidate. + +- [ ] **Step 7: Run tests** + +Run: `bun test` +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/shared/schemas.ts src/server/services/item.service.ts src/server/services/thread.service.ts +git commit -m "feat: add imageSourceUrl to Zod schemas and service functions" +``` + +--- + +### Task 3: Create Image Fetching Service + +**Files:** +- Create: `src/server/services/image.service.ts` +- Create: `tests/services/image.service.test.ts` + +- [ ] **Step 1: Write failing test — successful URL fetch** + +Create `tests/services/image.service.test.ts`: + +```typescript +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { fetchImageFromUrl } from "../../src/server/services/image.service"; + +const TEST_UPLOADS_DIR = "test-uploads"; + +afterEach(() => { + if (existsSync(TEST_UPLOADS_DIR)) { + rmSync(TEST_UPLOADS_DIR, { recursive: true }); + } +}); + +describe("fetchImageFromUrl", () => { + test("fetches a valid image URL and saves to disk", async () => { + // Use a small, reliable test image + const url = "https://via.placeholder.com/10x10.png"; + const result = await fetchImageFromUrl(url, TEST_UPLOADS_DIR); + + expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/); + expect(result.sourceUrl).toBe(url); + expect(existsSync(join(TEST_UPLOADS_DIR, result.filename))).toBe(true); + }); + + test("rejects non-image content type", async () => { + const url = "https://example.com/"; + await expect(fetchImageFromUrl(url, TEST_UPLOADS_DIR)).rejects.toThrow( + "Invalid content type" + ); + }); + + test("rejects invalid URL", async () => { + await expect(fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR)).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/services/image.service.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement image service** + +Create `src/server/services/image.service.ts`: + +```typescript +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; +const MAX_SIZE = 5 * 1024 * 1024; // 5MB +const FETCH_TIMEOUT = 10_000; // 10 seconds + +interface FetchImageResult { + filename: string; + sourceUrl: string; +} + +export async function fetchImageFromUrl( + url: string, + uploadsDir = "uploads", +): Promise { + // Validate URL format + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error("Invalid URL format"); + } + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new Error("URL must use HTTP or HTTPS"); + } + + // Fetch with timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + + let response: Response; + try { + response = await fetch(url, { signal: controller.signal }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new Error("Request timed out"); + } + throw new Error(`Failed to fetch image: ${(err as Error).message}`); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: Failed to fetch image`); + } + + // Validate content type + const contentType = response.headers.get("content-type")?.split(";")[0].trim(); + if (!contentType || !ALLOWED_TYPES.includes(contentType)) { + throw new Error( + `Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`, + ); + } + + // Check content length if available + const contentLength = response.headers.get("content-length"); + if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) { + throw new Error("File too large. Maximum size is 5MB"); + } + + // Read body and check actual size + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MAX_SIZE) { + throw new Error("File too large. Maximum size is 5MB"); + } + + // Determine extension + const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1]; + const filename = `${Date.now()}-${randomUUID()}.${ext}`; + + // Ensure directory exists and write + await mkdir(uploadsDir, { recursive: true }); + await Bun.write(join(uploadsDir, filename), buffer); + + return { filename, sourceUrl: url }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/services/image.service.test.ts` +Expected: All 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/image.service.ts tests/services/image.service.test.ts +git commit -m "feat: add image URL fetching service with tests" +``` + +--- + +### Task 4: Add Route for URL Image Fetching + +**Files:** +- Modify: `src/server/routes/images.ts` +- Create: `tests/routes/images.test.ts` + +- [ ] **Step 1: Write failing route test** + +Create `tests/routes/images.test.ts`: + +```typescript +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { imageRoutes } from "../../src/server/routes/images"; + +const app = new Hono(); +app.route("/api/images", imageRoutes); + +describe("POST /api/images/from-url", () => { + test("returns 400 for missing URL", async () => { + const res = await app.request("/api/images/from-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + test("returns 400 for invalid URL", async () => { + const res = await app.request("/api/images/from-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "not-a-url" }), + }); + expect(res.status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/routes/images.test.ts` +Expected: FAIL — route not found (404). + +- [ ] **Step 3: Add the from-url route** + +In `src/server/routes/images.ts`, add imports and the new route: + +```typescript +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { fetchImageFromUrl } from "../services/image.service"; + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; +const MAX_SIZE = 5 * 1024 * 1024; // 5MB + +const app = new Hono(); + +const fromUrlSchema = z.object({ + url: z.string().url("Invalid URL"), +}); + +app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => { + const { url } = c.req.valid("json"); + + try { + const result = await fetchImageFromUrl(url); + return c.json(result, 201); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } +}); + +// Existing file upload route stays below +app.post("/", async (c) => { + // ... existing code unchanged ... +}); +``` + +Note: Keep the existing `app.post("/", ...)` handler exactly as-is. Just add the new `/from-url` route above it. + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/routes/images.test.ts` +Expected: Both tests pass. + +- [ ] **Step 5: Run all tests** + +Run: `bun test` +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/server/routes/images.ts tests/routes/images.test.ts +git commit -m "feat: add POST /api/images/from-url route" +``` diff --git a/docs/superpowers/plans/2026-04-03-mcp-server.md b/docs/superpowers/plans/2026-04-03-mcp-server.md new file mode 100644 index 0000000..333135d --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-mcp-server.md @@ -0,0 +1,1223 @@ +# MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a built-in MCP server that exposes GearBox tools for managing gear collections via Claude Code and Claude Desktop, with workflow guidance emphasizing research threads. + +**Architecture:** MCP server runs inside the Hono process using the `@modelcontextprotocol/sdk` package with SSE/Streamable HTTP transport at `/mcp`. Tools call service functions directly (not via HTTP). Authenticated via `X-API-Key` header. Enabled by default, disabled with `GEARBOX_MCP=false`. + +**Tech Stack:** `@modelcontextprotocol/sdk`, Hono, existing GearBox services, Zod for tool input schemas + +--- + +## File Structure + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `src/server/mcp/index.ts` | MCP server setup, tool/resource registration, Hono route handler | +| Create | `src/server/mcp/tools/items.ts` | Item CRUD tool definitions and handlers | +| Create | `src/server/mcp/tools/categories.ts` | Category tool definitions and handlers | +| Create | `src/server/mcp/tools/threads.ts` | Thread + candidate tool definitions and handlers | +| Create | `src/server/mcp/tools/setups.ts` | Setup tool definitions and handlers | +| Create | `src/server/mcp/tools/images.ts` | Image URL fetch tool definition and handler | +| Create | `src/server/mcp/resources/collection.ts` | Collection summary resource | +| Modify | `src/server/index.ts` | Mount MCP route conditionally | +| Create | `tests/mcp/tools.test.ts` | MCP tool handler tests | + +--- + +### Task 1: Install MCP SDK + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the MCP SDK** + +Run: `bun add @modelcontextprotocol/sdk` + +- [ ] **Step 2: Verify installation** + +Run: `bun run build` +Expected: Build succeeds. If there are type issues, check the SDK version supports Bun. + +- [ ] **Step 3: Commit** + +```bash +git add package.json bun.lock +git commit -m "chore: install @modelcontextprotocol/sdk" +``` + +--- + +### Task 2: Create Item Tools + +**Files:** +- Create: `src/server/mcp/tools/items.ts` +- Create: `tests/mcp/tools.test.ts` + +- [ ] **Step 1: Write failing test for item tools** + +Create `tests/mcp/tools.test.ts`: + +```typescript +import { describe, expect, test, beforeEach } from "bun:test"; +import { createTestDb } from "../helpers/db"; +import { registerItemTools } from "../../src/server/mcp/tools/items"; + +let db: ReturnType; + +beforeEach(() => { + db = createTestDb(); +}); + +describe("item tools", () => { + test("list_items returns all items", async () => { + const tools = registerItemTools(db); + const result = await tools.list_items({}); + const items = JSON.parse(result.content[0].text); + expect(Array.isArray(items)).toBe(true); + }); + + test("create_item creates an item", async () => { + const tools = registerItemTools(db); + const result = await tools.create_item({ + name: "Test Item", + categoryId: 1, + weightGrams: 100, + priceCents: 2500, + }); + const item = JSON.parse(result.content[0].text); + expect(item.name).toBe("Test Item"); + expect(item.id).toBeDefined(); + }); + + test("get_item retrieves an item by ID", async () => { + const tools = registerItemTools(db); + const created = await tools.create_item({ + name: "Test", + categoryId: 1, + }); + const id = JSON.parse(created.content[0].text).id; + + const result = await tools.get_item({ id }); + const item = JSON.parse(result.content[0].text); + expect(item.name).toBe("Test"); + }); + + test("delete_item removes an item", async () => { + const tools = registerItemTools(db); + const created = await tools.create_item({ + name: "To Delete", + categoryId: 1, + }); + const id = JSON.parse(created.content[0].text).id; + + const result = await tools.delete_item({ id }); + const response = JSON.parse(result.content[0].text); + expect(response.success).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/mcp/tools.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement item tools** + +Create `src/server/mcp/tools/items.ts`: + +```typescript +import type { db as prodDb } from "../../../db/index.ts"; +import { + getAllItems, + getItemById, + createItem, + updateItem, + deleteItem, +} from "../../services/item.service.ts"; + +type Db = typeof prodDb; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; +} + +function textResult(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function errorResult(message: string): ToolResult { + return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] }; +} + +export function registerItemTools(db: Db) { + return { + async list_items(args: { categoryId?: number }): Promise { + const items = getAllItems(db); + if (args.categoryId) { + return textResult(items.filter((i) => i.categoryId === args.categoryId)); + } + return textResult(items); + }, + + async get_item(args: { id: number }): Promise { + const item = getItemById(db, args.id); + if (!item) return errorResult("Item not found"); + return textResult(item); + }, + + async create_item(args: { + name: string; + categoryId: number; + weightGrams?: number; + priceCents?: number; + notes?: string; + productUrl?: string; + imageFilename?: string; + imageSourceUrl?: string; + }): Promise { + const item = createItem(db, args); + return textResult(item); + }, + + async update_item(args: { + id: number; + name?: string; + categoryId?: number; + weightGrams?: number; + priceCents?: number; + notes?: string; + productUrl?: string; + imageFilename?: string; + imageSourceUrl?: string; + }): Promise { + const { id, ...data } = args; + const item = updateItem(db, id, data); + if (!item) return errorResult("Item not found"); + return textResult(item); + }, + + async delete_item(args: { id: number }): Promise { + const item = deleteItem(db, args.id); + if (!item) return errorResult("Item not found"); + return textResult({ success: true, deleted: item.name }); + }, + }; +} + +export const itemToolDefinitions = [ + { + name: "list_items", + description: + "List all items in the gear collection. Optionally filter by category.", + inputSchema: { + type: "object" as const, + properties: { + categoryId: { + type: "number", + description: "Filter by category ID", + }, + }, + }, + }, + { + name: "get_item", + description: "Get details of a specific item by ID.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Item ID" }, + }, + required: ["id"], + }, + }, + { + name: "create_item", + description: + "Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead to compare candidates.", + inputSchema: { + type: "object" as const, + properties: { + name: { type: "string", description: "Item name" }, + categoryId: { type: "number", description: "Category ID" }, + weightGrams: { type: "number", description: "Weight in grams" }, + priceCents: { + type: "number", + description: "Price in cents (e.g. 2500 = $25.00)", + }, + notes: { type: "string", description: "Notes about the item" }, + productUrl: { type: "string", description: "URL to product page" }, + }, + required: ["name", "categoryId"], + }, + }, + { + name: "update_item", + description: "Update an existing item's details.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Item ID" }, + name: { type: "string" }, + categoryId: { type: "number" }, + weightGrams: { type: "number" }, + priceCents: { type: "number" }, + notes: { type: "string" }, + productUrl: { type: "string" }, + }, + required: ["id"], + }, + }, + { + name: "delete_item", + description: "Remove an item from the collection.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Item ID" }, + }, + required: ["id"], + }, + }, +]; +``` + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/mcp/tools.test.ts` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/mcp/tools/items.ts tests/mcp/tools.test.ts +git commit -m "feat: add MCP item tools with tests" +``` + +--- + +### Task 3: Create Category Tools + +**Files:** +- Create: `src/server/mcp/tools/categories.ts` + +- [ ] **Step 1: Implement category tools** + +Create `src/server/mcp/tools/categories.ts`: + +```typescript +import type { db as prodDb } from "../../../db/index.ts"; +import { + getAllCategories, + createCategory, +} from "../../services/category.service.ts"; + +type Db = typeof prodDb; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; +} + +function textResult(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +export function registerCategoryTools(db: Db) { + return { + async list_categories(): Promise { + return textResult(getAllCategories(db)); + }, + + async create_category(args: { + name: string; + icon?: string; + }): Promise { + const category = createCategory(db, args); + return textResult(category); + }, + }; +} + +export const categoryToolDefinitions = [ + { + name: "list_categories", + description: "List all gear categories.", + inputSchema: { type: "object" as const, properties: {} }, + }, + { + name: "create_category", + description: "Create a new category for organizing gear.", + inputSchema: { + type: "object" as const, + properties: { + name: { type: "string", description: "Category name" }, + icon: { + type: "string", + description: + "Lucide icon name (e.g. 'tent', 'bike', 'monitor'). Defaults to 'package'.", + }, + }, + required: ["name"], + }, + }, +]; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/mcp/tools/categories.ts +git commit -m "feat: add MCP category tools" +``` + +--- + +### Task 4: Create Thread and Candidate Tools + +**Files:** +- Create: `src/server/mcp/tools/threads.ts` + +- [ ] **Step 1: Write failing test for thread tools** + +Add to `tests/mcp/tools.test.ts`: + +```typescript +import { registerThreadTools } from "../../src/server/mcp/tools/threads"; + +describe("thread tools", () => { + test("create_thread starts a research thread", async () => { + const tools = registerThreadTools(db); + const result = await tools.create_thread({ + name: "Best handlebar bag", + categoryId: 1, + }); + const thread = JSON.parse(result.content[0].text); + expect(thread.name).toBe("Best handlebar bag"); + expect(thread.status).toBe("active"); + }); + + test("add_candidate adds a candidate to thread", async () => { + const tools = registerThreadTools(db); + const threadResult = await tools.create_thread({ + name: "Handlebar bags", + categoryId: 1, + }); + const threadId = JSON.parse(threadResult.content[0].text).id; + + const result = await tools.add_candidate({ + threadId, + name: "Apidura Racing", + categoryId: 1, + weightGrams: 130, + priceCents: 6500, + pros: "Lightweight, aerodynamic", + cons: "Small capacity", + }); + const candidate = JSON.parse(result.content[0].text); + expect(candidate.name).toBe("Apidura Racing"); + }); + + test("resolve_thread picks a winner and creates item", async () => { + const tools = registerThreadTools(db); + const threadResult = await tools.create_thread({ + name: "Handlebar bags", + categoryId: 1, + }); + const threadId = JSON.parse(threadResult.content[0].text).id; + + const candidateResult = await tools.add_candidate({ + threadId, + name: "Winner Bag", + categoryId: 1, + priceCents: 5000, + }); + const candidateId = JSON.parse(candidateResult.content[0].text).id; + + const result = await tools.resolve_thread({ threadId, candidateId }); + const response = JSON.parse(result.content[0].text); + expect(response.success).toBe(true); + expect(response.item.name).toBe("Winner Bag"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/mcp/tools.test.ts` +Expected: FAIL — module not found for threads tools. + +- [ ] **Step 3: Implement thread tools** + +Create `src/server/mcp/tools/threads.ts`: + +```typescript +import type { db as prodDb } from "../../../db/index.ts"; +import { + createThread, + getAllThreads, + getThreadWithCandidates, + updateThread, + deleteThread, + createCandidate, + updateCandidate, + deleteCandidate, + resolveThread, +} from "../../services/thread.service.ts"; + +type Db = typeof prodDb; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; +} + +function textResult(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function errorResult(message: string): ToolResult { + return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] }; +} + +export function registerThreadTools(db: Db) { + return { + async list_threads(args: { + includeResolved?: boolean; + }): Promise { + return textResult(getAllThreads(db, args.includeResolved)); + }, + + async get_thread(args: { id: number }): Promise { + const thread = getThreadWithCandidates(db, args.id); + if (!thread) return errorResult("Thread not found"); + return textResult(thread); + }, + + async create_thread(args: { + name: string; + categoryId: number; + }): Promise { + return textResult(createThread(db, args)); + }, + + async resolve_thread(args: { + threadId: number; + candidateId: number; + }): Promise { + const result = resolveThread(db, args.threadId, args.candidateId); + if (!result.success) return errorResult(result.error ?? "Resolution failed"); + return textResult(result); + }, + + async add_candidate(args: { + threadId: number; + name: string; + categoryId: number; + weightGrams?: number; + priceCents?: number; + notes?: string; + productUrl?: string; + imageFilename?: string; + pros?: string; + cons?: string; + }): Promise { + const { threadId, ...data } = args; + return textResult(createCandidate(db, threadId, data)); + }, + + async update_candidate(args: { + id: number; + name?: string; + weightGrams?: number; + priceCents?: number; + notes?: string; + productUrl?: string; + pros?: string; + cons?: string; + }): Promise { + const { id, ...data } = args; + const candidate = updateCandidate(db, id, data); + if (!candidate) return errorResult("Candidate not found"); + return textResult(candidate); + }, + + async remove_candidate(args: { id: number }): Promise { + const candidate = deleteCandidate(db, args.id); + if (!candidate) return errorResult("Candidate not found"); + return textResult({ success: true, deleted: candidate.name }); + }, + }; +} + +export const threadToolDefinitions = [ + { + name: "list_threads", + description: + "List all research threads. Threads are the recommended way to evaluate gear purchases — create a thread, add candidates, compare them, then resolve to pick a winner.", + inputSchema: { + type: "object" as const, + properties: { + includeResolved: { + type: "boolean", + description: "Include resolved threads (default: false)", + }, + }, + }, + }, + { + name: "get_thread", + description: + "Get a thread with all its candidates and comparison data.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Thread ID" }, + }, + required: ["id"], + }, + }, + { + name: "create_thread", + description: + "Start a new research thread for evaluating a gear purchase. This is the preferred workflow: create a thread describing what you need, add candidate products, compare specs/weight/price, then resolve when you've decided.", + inputSchema: { + type: "object" as const, + properties: { + name: { + type: "string", + description: + "Thread name (e.g. 'Best handlebar bag for bikepacking')", + }, + categoryId: { type: "number", description: "Category ID" }, + }, + required: ["name", "categoryId"], + }, + }, + { + name: "resolve_thread", + description: + "Resolve a thread by picking the winning candidate. This adds the winner to your collection as a new item and marks the thread as resolved.", + inputSchema: { + type: "object" as const, + properties: { + threadId: { type: "number", description: "Thread ID" }, + candidateId: { + type: "number", + description: "ID of the winning candidate", + }, + }, + required: ["threadId", "candidateId"], + }, + }, + { + name: "add_candidate", + description: + "Add a candidate product to a research thread. Include weight, price, pros, cons, and optionally an image URL.", + inputSchema: { + type: "object" as const, + properties: { + threadId: { type: "number", description: "Thread ID to add to" }, + name: { type: "string", description: "Product name" }, + categoryId: { type: "number", description: "Category ID" }, + weightGrams: { type: "number", description: "Weight in grams" }, + priceCents: { + type: "number", + description: "Price in cents (e.g. 6500 = $65.00)", + }, + notes: { type: "string", description: "Notes" }, + productUrl: { type: "string", description: "URL to product page" }, + pros: { + type: "string", + description: "Pros / advantages of this candidate", + }, + cons: { + type: "string", + description: "Cons / disadvantages of this candidate", + }, + }, + required: ["threadId", "name", "categoryId"], + }, + }, + { + name: "update_candidate", + description: "Update a candidate's details — weight, price, pros, cons, etc.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Candidate ID" }, + name: { type: "string" }, + weightGrams: { type: "number" }, + priceCents: { type: "number" }, + notes: { type: "string" }, + productUrl: { type: "string" }, + pros: { type: "string" }, + cons: { type: "string" }, + }, + required: ["id"], + }, + }, + { + name: "remove_candidate", + description: "Remove a candidate from a research thread.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Candidate ID" }, + }, + required: ["id"], + }, + }, +]; +``` + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/mcp/tools.test.ts` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/mcp/tools/threads.ts tests/mcp/tools.test.ts +git commit -m "feat: add MCP thread and candidate tools with tests" +``` + +--- + +### Task 5: Create Setup and Image Tools + +**Files:** +- Create: `src/server/mcp/tools/setups.ts` +- Create: `src/server/mcp/tools/images.ts` + +- [ ] **Step 1: Implement setup tools** + +Create `src/server/mcp/tools/setups.ts`: + +```typescript +import type { db as prodDb } from "../../../db/index.ts"; +import { + getAllSetups, + getSetupWithItems, + createSetup, + updateSetup, + syncSetupItems, +} from "../../services/setup.service.ts"; + +type Db = typeof prodDb; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; +} + +function textResult(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function errorResult(message: string): ToolResult { + return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] }; +} + +export function registerSetupTools(db: Db) { + return { + async list_setups(): Promise { + return textResult(getAllSetups(db)); + }, + + async get_setup(args: { id: number }): Promise { + const setup = getSetupWithItems(db, args.id); + if (!setup) return errorResult("Setup not found"); + return textResult(setup); + }, + + async create_setup(args: { name: string }): Promise { + return textResult(createSetup(db, args)); + }, + + async update_setup(args: { + id: number; + name?: string; + itemIds?: number[]; + }): Promise { + const { id, name, itemIds } = args; + if (name) { + const updated = updateSetup(db, id, { name }); + if (!updated) return errorResult("Setup not found"); + } + if (itemIds) { + syncSetupItems(db, id, itemIds); + } + const setup = getSetupWithItems(db, id); + return textResult(setup); + }, + }; +} + +export const setupToolDefinitions = [ + { + name: "list_setups", + description: + "List all gear setups (named configurations of items for different trips/activities).", + inputSchema: { type: "object" as const, properties: {} }, + }, + { + name: "get_setup", + description: + "Get a setup with all its items, total weight, and total cost.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Setup ID" }, + }, + required: ["id"], + }, + }, + { + name: "create_setup", + description: "Create a new gear setup.", + inputSchema: { + type: "object" as const, + properties: { + name: { + type: "string", + description: "Setup name (e.g. 'Summer Bikepacking')", + }, + }, + required: ["name"], + }, + }, + { + name: "update_setup", + description: + "Update a setup's name and/or items. Pass itemIds to replace all items in the setup.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Setup ID" }, + name: { type: "string", description: "New name" }, + itemIds: { + type: "array", + items: { type: "number" }, + description: "Array of item IDs to include in the setup", + }, + }, + required: ["id"], + }, + }, +]; +``` + +- [ ] **Step 2: Implement image tools** + +Create `src/server/mcp/tools/images.ts`: + +```typescript +import { fetchImageFromUrl } from "../../services/image.service.ts"; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; +} + +function textResult(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function errorResult(message: string): ToolResult { + return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] }; +} + +export function registerImageTools() { + return { + async upload_image_from_url(args: { url: string }): Promise { + try { + const result = await fetchImageFromUrl(args.url); + return textResult(result); + } catch (err) { + return errorResult((err as Error).message); + } + }, + }; +} + +export const imageToolDefinitions = [ + { + name: "upload_image_from_url", + description: + "Fetch an image from a URL and save it locally. Returns the filename to use when creating/updating items or candidates.", + inputSchema: { + type: "object" as const, + properties: { + url: { + type: "string", + description: "URL of the image to fetch", + }, + }, + required: ["url"], + }, + }, +]; +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/server/mcp/tools/setups.ts src/server/mcp/tools/images.ts +git commit -m "feat: add MCP setup and image tools" +``` + +--- + +### Task 6: Create Collection Summary Resource + +**Files:** +- Create: `src/server/mcp/resources/collection.ts` + +- [ ] **Step 1: Implement collection resource** + +Create `src/server/mcp/resources/collection.ts`: + +```typescript +import type { db as prodDb } from "../../../db/index.ts"; +import { getGlobalTotals } from "../../services/totals.service.ts"; +import { getAllThreads } from "../../services/thread.service.ts"; +import { getAllSetups } from "../../services/setup.service.ts"; +import { getAllCategories } from "../../services/category.service.ts"; +import { getAllItems } from "../../services/item.service.ts"; + +type Db = typeof prodDb; + +export function getCollectionSummary(db: Db) { + const totals = getGlobalTotals(db); + const activeThreads = getAllThreads(db, false); + const setups = getAllSetups(db); + const categories = getAllCategories(db); + const items = getAllItems(db); + + // Count items per category + const itemsByCategory: Record = {}; + for (const item of items) { + const name = item.categoryName; + itemsByCategory[name] = (itemsByCategory[name] ?? 0) + 1; + } + + return { + overview: { + totalItems: totals.itemCount, + totalWeightGrams: totals.totalWeight, + totalCostCents: totals.totalCost, + categoryCount: categories.length, + setupCount: setups.length, + activeThreadCount: activeThreads.length, + }, + itemsByCategory, + activeThreads: activeThreads.map((t) => ({ + id: t.id, + name: t.name, + candidateCount: t.candidateCount, + category: t.categoryName, + })), + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/mcp/resources/collection.ts +git commit -m "feat: add collection summary MCP resource" +``` + +--- + +### Task 7: Create MCP Server Entry Point and Hono Integration + +**Files:** +- Create: `src/server/mcp/index.ts` +- Modify: `src/server/index.ts` + +- [ ] **Step 1: Implement MCP server with Hono SSE bridge** + +Create `src/server/mcp/index.ts`: + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { Hono } from "hono"; +import { db as prodDb } from "../../db/index.ts"; +import { verifyApiKey } from "../services/auth.service.ts"; +import { itemToolDefinitions, registerItemTools } from "./tools/items.ts"; +import { + categoryToolDefinitions, + registerCategoryTools, +} from "./tools/categories.ts"; +import { + threadToolDefinitions, + registerThreadTools, +} from "./tools/threads.ts"; +import { setupToolDefinitions, registerSetupTools } from "./tools/setups.ts"; +import { imageToolDefinitions, registerImageTools } from "./tools/images.ts"; +import { getCollectionSummary } from "./resources/collection.ts"; + +const app = new Hono(); + +// Store active transports for SSE connections +const transports = new Map(); + +function createMcpServer(db: typeof prodDb) { + const server = new McpServer({ + name: "GearBox", + version: "1.0.0", + }); + + // Register all tools + const itemTools = registerItemTools(db); + const categoryTools = registerCategoryTools(db); + const threadTools = registerThreadTools(db); + const setupTools = registerSetupTools(db); + const imageTools = registerImageTools(); + + // Items + for (const def of itemToolDefinitions) { + server.tool(def.name, def.description, def.inputSchema, async (args) => { + const handler = itemTools[def.name as keyof typeof itemTools]; + return handler(args as any); + }); + } + + // Categories + for (const def of categoryToolDefinitions) { + server.tool(def.name, def.description, def.inputSchema, async (args) => { + const handler = categoryTools[def.name as keyof typeof categoryTools]; + return handler(args as any); + }); + } + + // Threads + for (const def of threadToolDefinitions) { + server.tool(def.name, def.description, def.inputSchema, async (args) => { + const handler = threadTools[def.name as keyof typeof threadTools]; + return handler(args as any); + }); + } + + // Setups + for (const def of setupToolDefinitions) { + server.tool(def.name, def.description, def.inputSchema, async (args) => { + const handler = setupTools[def.name as keyof typeof setupTools]; + return handler(args as any); + }); + } + + // Images + for (const def of imageToolDefinitions) { + server.tool(def.name, def.description, def.inputSchema, async (args) => { + const handler = imageTools[def.name as keyof typeof imageTools]; + return handler(args as any); + }); + } + + // Collection summary resource + server.resource( + "collection-summary", + "gearbox://collection-summary", + { + description: + "Overview of the gear collection: totals, items per category, active research threads, and setups.", + mimeType: "application/json", + }, + async () => ({ + contents: [ + { + uri: "gearbox://collection-summary", + mimeType: "application/json", + text: JSON.stringify(getCollectionSummary(db), null, 2), + }, + ], + }), + ); + + return server; +} + +// SSE endpoint for MCP connections +app.get("/sse", async (c) => { + // Auth check + const apiKey = c.req.header("X-API-Key"); + if (apiKey) { + const db = c.get("db") ?? prodDb; + const valid = await verifyApiKey(db, apiKey); + if (!valid) { + return c.json({ error: "Invalid API key" }, 401); + } + } + + const db = c.get("db") ?? prodDb; + const server = createMcpServer(db); + const transport = new SSEServerTransport("/mcp/messages", c.res); + + const sessionId = transport.sessionId; + transports.set(sessionId, transport); + + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); + + await server.connect(transport); + + // Clean up on disconnect + c.req.raw.signal.addEventListener("abort", () => { + transports.delete(sessionId); + }); + + return c.body(null); +}); + +// Message endpoint for MCP +app.post("/messages", async (c) => { + const sessionId = c.req.query("sessionId"); + if (!sessionId) { + return c.json({ error: "Missing sessionId" }, 400); + } + + const transport = transports.get(sessionId); + if (!transport) { + return c.json({ error: "Session not found" }, 404); + } + + const body = await c.req.json(); + await transport.handlePostMessage(body); + + return c.json({ ok: true }); +}); + +export { app as mcpRoutes }; +``` + +**Important note:** The exact SSE/transport API may differ based on the MCP SDK version installed. The implementation above follows the SDK's documented SSE transport pattern. During implementation, check the actual SDK exports and adjust if needed. The newer MCP SDK versions may use `StreamableHTTPServerTransport` instead of `SSEServerTransport` — use whichever is available. + +- [ ] **Step 2: Mount MCP routes in server index** + +In `src/server/index.ts`, add import: + +```typescript +import { mcpRoutes } from "./mcp/index.ts"; +``` + +Add conditional MCP route registration after the other routes but before the static file serving: + +```typescript +// MCP server (enabled by default, disable with GEARBOX_MCP=false) +if (process.env.GEARBOX_MCP !== "false") { + app.route("/mcp", mcpRoutes); +} +``` + +- [ ] **Step 3: Run all tests** + +Run: `bun test` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/server/mcp/index.ts src/server/index.ts +git commit -m "feat: add MCP server with SSE transport at /mcp" +``` + +--- + +### Task 8: Add MCP Client Configuration Examples + +**Files:** +- Modify: `CLAUDE.md` (add MCP configuration section) + +- [ ] **Step 1: Add MCP configuration section to CLAUDE.md** + +Add a new section to `CLAUDE.md`: + +```markdown +## MCP Server + +GearBox includes a built-in MCP server for integration with Claude Code and Claude Desktop. + +### Configuration + +**Claude Code** (`.claude/settings.json`): +```json +{ + "mcpServers": { + "gearbox": { + "type": "sse", + "url": "http://localhost:3000/mcp/sse", + "headers": { + "X-API-Key": "" + } + } + } +} +``` + +**Claude Desktop** (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "gearbox": { + "type": "sse", + "url": "http://localhost:3000/mcp/sse", + "headers": { + "X-API-Key": "" + } + } + } +} +``` + +### Disabling + +Set `GEARBOX_MCP=false` environment variable to disable the MCP server. +``` + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add MCP server configuration to CLAUDE.md" +``` + +--- + +### Task 9: Full Test Suite and Manual Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +Run: `bun test` +Expected: All tests pass. + +- [ ] **Step 2: Run linter** + +Run: `bun run lint` +Expected: No errors. + +- [ ] **Step 3: Manual verification — start the server** + +Run: `bun run dev:server` +Check that the server starts without errors and logs no MCP-related issues. + +- [ ] **Step 4: Test SSE endpoint** + +Run: `curl -N -H "X-API-Key: " http://localhost:3000/mcp/sse` +Expected: SSE connection opens, receives initial MCP handshake. + +- [ ] **Step 5: Test with Claude Code** + +Add MCP server config to `.claude/settings.json` with a valid API key. +Start a new Claude Code session. Verify: +1. GearBox tools appear in the tool list +2. `list_items` returns current items +3. `create_thread` creates a research thread +4. `add_candidate` adds candidates to threads +5. `resolve_thread` resolves and creates items +6. `collection-summary` resource provides context