From 1d083e891349d3edc8046a6719ad10da7cf02576 Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:12:15 -0400 Subject: [PATCH 01/16] feat(mcp): add api_keys table for MCP authentication Add database schema and migration for storing API keys with SHA-256 hashing. Keys include prefix for UI display, label, and soft-delete via revokedAt. --- drizzle/0006_add_api_keys.sql | 16 ++++++++++++++++ drizzle/meta/_journal.json | 7 +++++++ src/lib/db/schema.ts | 13 +++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 drizzle/0006_add_api_keys.sql diff --git a/drizzle/0006_add_api_keys.sql b/drizzle/0006_add_api_keys.sql new file mode 100644 index 00000000..263fedd4 --- /dev/null +++ b/drizzle/0006_add_api_keys.sql @@ -0,0 +1,16 @@ +-- API keys for MCP server authentication +CREATE TABLE "api_keys" ( + "id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "key_hash" text NOT NULL, + "key_prefix" text NOT NULL, + "label" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + CONSTRAINT "api_keys_key_hash_key" UNIQUE("key_hash") +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "api_keys_user_id_idx" ON "api_keys" USING btree ("user_id" text_ops); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8b5c51d8..036717c7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1771800001000, "tag": "0005_add_chat_messages_thread_message_unique", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1744156800000, + "tag": "0006_add_api_keys", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 37417bc6..bab0021f 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -376,3 +376,16 @@ export const workspaceItemReads = pgTable( }), ] ); + +export const apiKeys = pgTable("api_keys", { + id: text("id").primaryKey().default(sql`gen_random_uuid()`), + userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }), + keyHash: text("key_hash").notNull().unique(), + keyPrefix: text("key_prefix").notNull(), + label: text("label"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }).defaultNow().notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: "string" }), + revokedAt: timestamp("revoked_at", { withTimezone: true, mode: "string" }), +}, (table) => [ + index("api_keys_user_id_idx").on(table.userId), +]); From 17fccf1dc80804447393aa86ec72acda6c506706 Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:13:14 -0400 Subject: [PATCH 02/16] feat(mcp): add API key management endpoints --- src/app/api/mcp-keys/route.ts | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/app/api/mcp-keys/route.ts diff --git a/src/app/api/mcp-keys/route.ts b/src/app/api/mcp-keys/route.ts new file mode 100644 index 00000000..e79c21bc --- /dev/null +++ b/src/app/api/mcp-keys/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { createHash, randomBytes } from "crypto"; +import { db } from "@/lib/db/client"; +import { apiKeys } from "@/lib/db/schema"; +import { eq, and, isNull } from "drizzle-orm"; +import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; + +async function handlePOST(req: Request) { + const userId = await requireAuth(); + const body = await req.json().catch(() => ({})); + const label = body?.label || null; + + const rawKey = `tx_${randomBytes(32).toString("base64url")}`; + const keyHash = createHash("sha256").update(rawKey).digest("hex"); + const keyPrefix = rawKey.substring(0, 8); + + const [result] = await db + .insert(apiKeys) + .values({ + userId, + keyHash, + keyPrefix, + label, + createdAt: new Date().toISOString(), + revokedAt: null, + lastUsedAt: null, + }) + .returning({ id: apiKeys.id, prefix: apiKeys.keyPrefix }); + + return NextResponse.json({ + id: result.id, + rawKey, + prefix: result.prefix, + }); +} + +async function handleGET() { + const userId = await requireAuth(); + + const keys = await db + .select({ + id: apiKeys.id, + prefix: apiKeys.keyPrefix, + label: apiKeys.label, + createdAt: apiKeys.createdAt, + lastUsedAt: apiKeys.lastUsedAt, + }) + .from(apiKeys) + .where(and(eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt))) + .orderBy(apiKeys.createdAt); + + return NextResponse.json({ keys }); +} + +export const POST = withErrorHandling(handlePOST, "POST /api/mcp-keys"); +export const GET = withErrorHandling(handleGET, "GET /api/mcp-keys"); From cf02e550ad284ddf2feb4b879ea4a753542a836e Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:14:12 -0400 Subject: [PATCH 03/16] feat(mcp): add MCP Access UI to account settings --- src/components/auth/AccountModal.tsx | 268 ++++++++++++++++++++++++++- src/components/ui/table.tsx | 120 ++++++++++++ 2 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/table.tsx diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 7cbb4f63..ad69f01b 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Dialog, DialogContent, @@ -12,7 +12,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient, useSession } from "@/lib/auth-client"; import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; +import { Loader2, Copy, Plus, Trash2 } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { AlertDialog, @@ -24,6 +24,14 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; interface AccountModalProps { open: boolean; @@ -43,6 +51,7 @@ export function AccountModal({ open, onOpenChange }: AccountModalProps) {
+
@@ -102,6 +111,261 @@ function ProfileForm({ user }: { user: any }) { ); } +interface APIKey { + id: string; + prefix: string; + label: string | null; + createdAt: string; + lastUsedAt: string | null; +} + +function MCPAccessSection() { + const [keys, setKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showKeyModal, setShowKeyModal] = useState(false); + const [newKeyData, setNewKeyData] = useState<{ rawKey: string; prefix: string } | null>(null); + const [label, setLabel] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [keyToRevoke, setKeyToRevoke] = useState(null); + + const fetchKeys = async () => { + try { + const res = await fetch("/api/mcp-keys"); + const data = await res.json(); + setKeys(data.keys || []); + } catch (error) { + toast.error("Failed to load API keys"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchKeys(); + }, []); + + const handleCreateKey = async () => { + setIsCreating(true); + try { + const res = await fetch("/api/mcp-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: label || null }), + }); + + if (!res.ok) throw new Error("Failed to create API key"); + + const data = await res.json(); + setNewKeyData({ rawKey: data.rawKey, prefix: data.prefix }); + setShowCreateDialog(false); + setShowKeyModal(true); + setLabel(""); + await fetchKeys(); + } catch (error) { + toast.error("Failed to create API key"); + } finally { + setIsCreating(false); + } + }; + + const handleRevokeKey = async (id: string) => { + try { + const res = await fetch(`/api/mcp-keys/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to revoke API key"); + + toast.success("API key revoked successfully"); + await fetchKeys(); + } catch (error) { + toast.error("Failed to revoke API key"); + } finally { + setKeyToRevoke(null); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return "—"; + return new Date(dateStr).toLocaleDateString(); + }; + + return ( + <> +
+

MCP Access

+

+ API keys allow external tools like IDEs to access your workspaces via the Model Context Protocol. +

+ + {isLoading ? ( +
+ +
+ ) : keys.length === 0 ? ( +
+ No API keys yet. Create one to get started. +
+ ) : ( +
+ + + + Label + Key Prefix + Created + Last Used + Actions + + + + {keys.map((key) => ( + + {key.label || Unlabeled} + + {key.prefix}... + + {formatDate(key.createdAt)} + {formatDate(key.lastUsedAt)} + + + + + ))} + +
+
+ )} + + + +
+

IDE Configuration

+

+ Add this to your MCP settings file: +

+
+{`{
+  "mcpServers": {
+    "thinkex": {
+      "url": "${typeof window !== 'undefined' ? window.location.origin : 'https://thinkex.app'}/api/mcp",
+      "headers": {
+        "Authorization": "Bearer "
+      }
+    }
+  }
+}`}
+          
+
+
+ + + + + Create API Key + + Give this API key a label to help you identify it later (optional). + + +
+ + setLabel(e.target.value)} + placeholder="e.g., My MacBook" + className="mt-2" + /> +
+ + Cancel + { + e.preventDefault(); + handleCreateKey(); + }} + disabled={isCreating} + > + {isCreating ? : null} + Create Key + + +
+
+ + + + + API Key Created + + Copy this key now. You will not be able to see it again. + + +
+
+ + +
+

+ This key will not be shown again. Store it in a secure location. +

+
+ + { setShowKeyModal(false); setNewKeyData(null); }}> + Done + + +
+
+ + setKeyToRevoke(null)}> + + + Revoke API Key? + + This will immediately revoke the API key. Any applications using this key will lose access. + + + + Cancel + { + e.preventDefault(); + if (keyToRevoke) handleRevokeKey(keyToRevoke); + }} + className="bg-red-500 hover:bg-red-600" + > + Revoke Key + + + + + + ); +} + function DangerZone() { const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [isDeleting, setIsDeleting] = useState(false); diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..c0df655c --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} From 80122fadba305c7a1847ab829f6d36a945adfb5d Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:14:30 -0400 Subject: [PATCH 04/16] feat(mcp): implement MCP server with five tools Add Model Context Protocol server with JSON-RPC support: - list_workspaces: List all user workspaces - list_workspace: List items in workspace (with folder filtering) - get_recent: Get N most recently modified items - search_workspace: Regex search with snippets - read_item: Read full content with fuzzy matching & pagination Includes API key authentication, 30s in-memory cache, and extractText helper supporting all item types (document, pdf, flashcard, quiz, audio, image). --- src/app/api/mcp/route.ts | 633 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 src/app/api/mcp/route.ts diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts new file mode 100644 index 00000000..a363fbb6 --- /dev/null +++ b/src/app/api/mcp/route.ts @@ -0,0 +1,633 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { createHash } from "crypto"; +import { db, workspaces } from "@/lib/db/client"; +import { apiKeys } from "@/lib/db/schema"; +import { eq, and, isNull } from "drizzle-orm"; +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import type { AgentState, Item } from "@/lib/workspace-state/types"; + +const stateCache = new Map(); +const CACHE_TTL_MS = 30_000; + +async function getCachedState(workspaceId: string): Promise { + const cached = stateCache.get(workspaceId); + if (cached && cached.expiresAt > Date.now()) return cached.state; + + const state = await loadWorkspaceState(workspaceId); + stateCache.set(workspaceId, { state, expiresAt: Date.now() + CACHE_TTL_MS }); + return state; +} + +export function invalidateWorkspaceCache(workspaceId: string) { + stateCache.delete(workspaceId); +} + +function extractText(item: Item): string | null { + switch (item.type) { + case "document": + return (item.data as any).markdown ?? null; + case "pdf": { + const pages = (item.data as any).ocrPages; + return pages?.map((p: any) => p.markdown).join("\n\n") ?? null; + } + case "flashcard": { + const cards = (item.data as any).cards ?? []; + return cards.map((c: any) => `Q: ${c.front}\nA: ${c.back}`).join("\n\n"); + } + case "quiz": { + const questions = (item.data as any).questions ?? []; + return questions + .map((q: any) => + [q.questionText, ...(q.options ?? []), q.explanation] + .filter(Boolean) + .join("\n") + ) + .join("\n\n") || null; + } + case "audio": { + const transcript = (item.data as any).transcript; + const summary = (item.data as any).summary; + return [transcript, summary].filter(Boolean).join("\n\n") || null; + } + case "image": { + const pages = (item.data as any).ocrPages; + return pages?.map((p: any) => p.markdown).join("\n\n") ?? null; + } + case "website": + case "youtube": + case "folder": + default: + return null; + } +} + +function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = []; + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + return matrix[b.length][a.length]; +} + +async function authenticateRequest(req: NextRequest): Promise { + const authHeader = req.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + + const rawKey = authHeader.substring(7); + if (!rawKey.startsWith("tx_")) { + return null; + } + + const keyHash = createHash("sha256").update(rawKey).digest("hex"); + + const [key] = await db + .select({ userId: apiKeys.userId, id: apiKeys.id }) + .from(apiKeys) + .where(and(eq(apiKeys.keyHash, keyHash), isNull(apiKeys.revokedAt))) + .limit(1); + + if (!key) { + return null; + } + + db.update(apiKeys) + .set({ lastUsedAt: new Date().toISOString() }) + .where(eq(apiKeys.id, key.id)) + .execute() + .catch(() => {}); + + return key.userId; +} + +let mcpServer: Server | null = null; + +function getOrCreateServer(userId: string): Server { + if (!mcpServer) { + mcpServer = new Server( + { + name: "thinkex", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } + ); + registerTools(mcpServer, userId); + } + return mcpServer; +} + +function registerTools(server: Server, userId: string) { + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: "list_workspaces", + description: "List all workspaces for the authenticated user", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "list_workspace", + description: "List all items in a workspace (or in a specific folder)", + inputSchema: { + type: "object", + properties: { + workspaceId: { type: "string", description: "Workspace ID" }, + folderId: { + type: "string", + description: "Optional folder ID to filter items", + }, + }, + required: ["workspaceId"], + }, + }, + { + name: "get_recent", + description: "Get the N most recently modified items in a workspace", + inputSchema: { + type: "object", + properties: { + workspaceId: { type: "string", description: "Workspace ID" }, + limit: { + type: "number", + description: "Number of items to return (default 5, max 20)", + }, + }, + required: ["workspaceId"], + }, + }, + { + name: "search_workspace", + description: "Search for items in a workspace using regex", + inputSchema: { + type: "object", + properties: { + workspaceId: { type: "string", description: "Workspace ID" }, + query: { type: "string", description: "Regex search pattern" }, + folderId: { + type: "string", + description: "Optional folder ID to filter search", + }, + type: { + type: "string", + description: "Optional item type to filter search", + }, + limit: { + type: "number", + description: "Number of results to return (default 5, max 10)", + }, + }, + required: ["workspaceId", "query"], + }, + }, + { + name: "read_item", + description: "Read the full content of an item (with pagination support)", + inputSchema: { + type: "object", + properties: { + workspaceId: { type: "string", description: "Workspace ID" }, + name: { type: "string", description: "Item name (fuzzy matched)" }, + lineStart: { + type: "number", + description: "Line number to start from (1-indexed, for text items)", + }, + limit: { + type: "number", + description: "Number of lines to return (default 100, max 500)", + }, + pageStart: { + type: "number", + description: "Page number to start from (1-indexed, for PDFs)", + }, + pageEnd: { + type: "number", + description: "Page number to end at (for PDFs)", + }, + }, + required: ["workspaceId", "name"], + }, + }, + ], + }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + switch (name) { + case "list_workspaces": { + const workspacesList = await db + .select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name }) + .from(workspaces) + .where(eq(workspaces.userId, userId)); + + return { + content: [ + { + type: "text", + text: JSON.stringify(workspacesList, null, 2), + }, + ], + }; + } + + case "list_workspace": { + const { workspaceId, folderId } = args as { workspaceId: string; folderId?: string }; + const state = await getCachedState(workspaceId); + let items = state.items; + + if (folderId !== undefined) { + items = items.filter((i) => i.folderId === folderId); + } + + const itemsList = items.map((i) => ({ + name: i.name, + type: i.type, + folderId: i.folderId, + lastModified: i.lastModified, + })); + + return { + content: [ + { + type: "text", + text: JSON.stringify(itemsList, null, 2), + }, + ], + }; + } + + case "get_recent": { + const { workspaceId, limit } = args as { workspaceId: string; limit?: number }; + const state = await getCachedState(workspaceId); + const recentLimit = Math.min(limit ?? 5, 20); + + const recent = [...state.items] + .filter((i) => i.lastModified) + .sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)) + .slice(0, recentLimit) + .map((i) => ({ + name: i.name, + type: i.type, + folderId: i.folderId, + lastModified: i.lastModified, + })); + + return { + content: [ + { + type: "text", + text: JSON.stringify(recent, null, 2), + }, + ], + }; + } + + case "search_workspace": { + const { workspaceId, query, folderId, type, limit } = args as { + workspaceId: string; + query: string; + folderId?: string; + type?: string; + limit?: number; + }; + const state = await getCachedState(workspaceId); + let items = state.items; + + if (folderId !== undefined) { + items = items.filter((i) => i.folderId === folderId); + } + if (type !== undefined) { + items = items.filter((i) => i.type === type); + } + + let regex: RegExp; + try { + regex = new RegExp(query, "gi"); + } catch { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: "Invalid regex pattern" }, null, 2), + }, + ], + isError: true, + }; + } + + const matches: Array<{ + itemName: string; + itemType: string; + folderId?: string; + lineStart: number; + content: string; + }> = []; + const INTERNAL_CAP = 100; + + for (const item of items) { + const text = extractText(item); + if (!text) continue; + + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + matches.push({ + itemName: item.name, + itemType: item.type, + folderId: item.folderId, + lineStart: i + 1, + content: lines[i].trim(), + }); + if (matches.length >= INTERNAL_CAP) break; + } + } + if (matches.length >= INTERNAL_CAP) break; + } + + if (matches.length === 0) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + results: [], + suggestion: + "No matches found. Try list_workspace() to browse available items or broaden your query.", + }, + null, + 2 + ), + }, + ], + }; + } + + const resultLimit = Math.min(limit ?? 5, 10); + return { + content: [ + { + type: "text", + text: JSON.stringify(matches.slice(0, resultLimit), null, 2), + }, + ], + }; + } + + case "read_item": { + const { workspaceId, name, lineStart, limit, pageStart, pageEnd } = args as { + workspaceId: string; + name: string; + lineStart?: number; + limit?: number; + pageStart?: number; + pageEnd?: number; + }; + const state = await getCachedState(workspaceId); + + const nameLower = name.toLowerCase(); + let matchedItem: Item | null = null; + + const exactMatch = state.items.find((i) => i.name.toLowerCase() === nameLower); + if (exactMatch) { + matchedItem = exactMatch; + } else { + const substringMatches = state.items.filter((i) => + i.name.toLowerCase().includes(nameLower) + ); + if (substringMatches.length === 1) { + matchedItem = substringMatches[0]; + } else if (substringMatches.length > 1) { + let closestItem = substringMatches[0]; + let closestDistance = levenshteinDistance(nameLower, closestItem.name.toLowerCase()); + for (const item of substringMatches.slice(1)) { + const distance = levenshteinDistance(nameLower, item.name.toLowerCase()); + if (distance < closestDistance) { + closestDistance = distance; + closestItem = item; + } + } + matchedItem = closestItem; + } + } + + if (!matchedItem) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: "Item not found. Try list_workspace() to see available items.", + }, + null, + 2 + ), + }, + ], + isError: true, + }; + } + + if (matchedItem.type === "pdf") { + const pages = ((matchedItem.data as any).ocrPages ?? []) as Array<{ + markdown: string; + }>; + const start = (pageStart ?? 1) - 1; + const end = (pageEnd ?? pages.length) - 1; + const content = pages + .slice(start, end + 1) + .map((p) => p.markdown) + .join("\n\n---\n\n"); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + itemName: matchedItem.name, + itemType: matchedItem.type, + content, + estimatedTokens: Math.ceil(content.length / 4), + totalPages: pages.length, + pageStart: start + 1, + }, + null, + 2 + ), + }, + ], + }; + } + + const text = extractText(matchedItem); + if (!text) { + const url = (matchedItem.data as any).url; + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + content: null, + note: `This item has no stored body text. URL: ${url ?? "N/A"}`, + }, + null, + 2 + ), + }, + ], + }; + } + + const lines = text.split("\n"); + const start = (lineStart ?? 1) - 1; + const lineLimit = Math.min(limit ?? 100, 500); + const slice = lines.slice(start, start + lineLimit); + const content = slice.join("\n"); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + itemName: matchedItem.name, + itemType: matchedItem.type, + content, + estimatedTokens: Math.ceil(content.length / 4), + totalLines: lines.length, + lineStart: start + 1, + }, + null, + 2 + ), + }, + ], + }; + } + + default: + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: `Unknown tool: ${name}` }, null, 2), + }, + ], + isError: true, + }; + } + } catch (error: any) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { error: error.message || "Internal server error" }, + null, + 2 + ), + }, + ], + isError: true, + }; + } + }); +} + +async function handleMCP(req: NextRequest) { + const userId = await authenticateRequest(req); + + if (!userId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const server = getOrCreateServer(userId); + + if (req.method !== "POST") { + return NextResponse.json( + { error: "Only POST method is supported for MCP" }, + { status: 405 } + ); + } + + try { + const body = await req.json(); + + const { method, params, id } = body; + + if (method === "tools/list") { + const response = await server.request({ method: "tools/list", params }, ListToolsRequestSchema); + return NextResponse.json({ + jsonrpc: "2.0", + id, + result: response, + }); + } else if (method === "tools/call") { + const response = await server.request({ method: "tools/call", params }, CallToolRequestSchema); + return NextResponse.json({ + jsonrpc: "2.0", + id, + result: response, + }); + } else { + return NextResponse.json({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Method not found: ${method}`, + }, + }); + } + } catch (error: any) { + return NextResponse.json({ + jsonrpc: "2.0", + id: null, + error: { + code: -32603, + message: error.message || "Internal error", + }, + }, { status: 500 }); + } +} + +export const POST = handleMCP; From be86f17119361fbc689153947415a79a0ede02b5 Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:15:09 -0400 Subject: [PATCH 05/16] feat(mcp): add workspace cache invalidation hooks --- src/lib/ai/workers/workspace-worker.ts | 12 ++++++++++++ .../audio-transcribe/steps/persist-result.ts | 3 +++ src/workflows/ocr-dispatch/steps/persist-results.ts | 2 ++ 3 files changed, 17 insertions(+) diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 1c426770..8131064d 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -31,6 +31,7 @@ import { import { parseJsonWithRepair } from "@/lib/utils/json-repair"; import { buildPdfDataFromUpload } from "@/lib/pdf/pdf-item"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; +import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; /** Create params for a single item (used by create and bulkCreate). Exported for autogen. */ export type CreateItemParams = { @@ -457,6 +458,7 @@ export async function workspaceWorker( // If no conflict, we're done if (!appendResult.conflict) { + invalidateWorkspaceCache(params.workspaceId); break; } @@ -555,6 +557,7 @@ export async function workspaceWorker( "Workspace was modified by another user, please try again", ); } + invalidateWorkspaceCache(params.workspaceId); logger.info( `📝 [WORKSPACE-WORKER] Bulk created ${items.length} items`, @@ -682,6 +685,7 @@ export async function workspaceWorker( "Workspace was modified by another user, please try again", ); } + invalidateWorkspaceCache(params.workspaceId); logger.info("🎴 [WORKSPACE-WORKER] Updated flashcard deck:", { itemId: params.itemId, @@ -841,6 +845,7 @@ export async function workspaceWorker( "Workspace was modified by another user, please try again", ); } + invalidateWorkspaceCache(params.workspaceId); logger.info("🎯 [WORKSPACE-WORKER] Updated quiz:", { itemId: params.itemId, @@ -967,6 +972,7 @@ export async function workspaceWorker( "Workspace was modified by another user, please try again", ); } + invalidateWorkspaceCache(params.workspaceId); const contentLen = getOcrPagesTextContent(params.pdfOcrPages).length; logger.info("📄 [WORKSPACE-WORKER] Updated PDF OCR content:", { @@ -1072,6 +1078,7 @@ export async function workspaceWorker( "Workspace was modified by another user, please try again", ); } + invalidateWorkspaceCache(params.workspaceId); await broadcastPersistedWorkspaceEvent( params.workspaceId, @@ -1215,6 +1222,7 @@ export async function workspaceWorker( throw new Error( "Workspace was modified by another user, please try again", ); + invalidateWorkspaceCache(params.workspaceId); await broadcastPersistedWorkspaceEvent( params.workspaceId, @@ -1349,6 +1357,7 @@ export async function workspaceWorker( throw new Error( "Workspace was modified by another user, please try again", ); + invalidateWorkspaceCache(params.workspaceId); await broadcastPersistedWorkspaceEvent( params.workspaceId, @@ -1457,6 +1466,7 @@ export async function workspaceWorker( throw new Error( "Workspace was modified by another user, please try again", ); + invalidateWorkspaceCache(params.workspaceId); await broadcastPersistedWorkspaceEvent( params.workspaceId, @@ -1538,6 +1548,7 @@ export async function workspaceWorker( throw new Error( "Workspace was modified by another user, please try again", ); + invalidateWorkspaceCache(params.workspaceId); await broadcastPersistedWorkspaceEvent( params.workspaceId, event, @@ -1600,6 +1611,7 @@ export async function workspaceWorker( "Workspace was modified by another user, please try again", ); } + invalidateWorkspaceCache(params.workspaceId); logger.info("📝 [WORKSPACE-WORKER] Deleted item:", params.itemId); diff --git a/src/workflows/audio-transcribe/steps/persist-result.ts b/src/workflows/audio-transcribe/steps/persist-result.ts index 80b5496d..6e53e9de 100644 --- a/src/workflows/audio-transcribe/steps/persist-result.ts +++ b/src/workflows/audio-transcribe/steps/persist-result.ts @@ -3,6 +3,7 @@ import { db } from "@/lib/db/client"; import { createEvent } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; +import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; import type { AudioData, Item } from "@/lib/workspace-state/types"; import type { TranscribeResult } from "./transcribe"; @@ -67,6 +68,7 @@ export async function persistAudioResult( `Version conflict appending event ${event.id} to workspace ${workspaceId} (baseVersion=${versionResult[0]?.version ?? 0}). Workflow will retry automatically.`, ); } + invalidateWorkspaceCache(workspaceId); await broadcastWorkspaceEventFromServer(workspaceId, { ...event, @@ -134,6 +136,7 @@ export async function persistAudioFailure( `Version conflict appending event ${event.id} to workspace ${workspaceId} (baseVersion=${versionResult[0]?.version ?? 0}). Workflow will retry automatically.`, ); } + invalidateWorkspaceCache(workspaceId); await broadcastWorkspaceEventFromServer(workspaceId, { ...event, diff --git a/src/workflows/ocr-dispatch/steps/persist-results.ts b/src/workflows/ocr-dispatch/steps/persist-results.ts index 99c27c34..ca86c417 100644 --- a/src/workflows/ocr-dispatch/steps/persist-results.ts +++ b/src/workflows/ocr-dispatch/steps/persist-results.ts @@ -5,6 +5,7 @@ import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; import type { WorkspaceEvent } from "@/lib/workspace/events"; import type { OcrItemResult } from "@/lib/ocr/types"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; +import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; import type { ImageData, Item, PdfData } from "@/lib/workspace-state/types"; const APPEND_RESULT_REGEX = /\(\s*(\d+)\s*,\s*(t|f|true|false)\s*\)/i; @@ -51,6 +52,7 @@ async function appendWorkspaceEvent( `Version conflict appending event ${event.id} to workspace ${workspaceId} (baseVersion=${baseVersion}). Workflow will retry automatically.`, ); } + invalidateWorkspaceCache(workspaceId); await broadcastWorkspaceEventFromServer(workspaceId, { ...event, From 3df562e9662c8a0200f81e621f5055bf46706887 Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:15:52 -0400 Subject: [PATCH 06/16] chore: add @modelcontextprotocol/sdk dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 40e4339b..bed35a17 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@ai-sdk/devtools": "^0.0.15", "@ai-sdk/google": "^3.0.58", + "@modelcontextprotocol/sdk": "^1.0.4", "@assistant-ui/react": "^0.12.23", "@assistant-ui/react-ai-sdk": "^1.3.17", "@assistant-ui/react-devtools": "^1.0.4", From f07925d00b2a72826c4c077b0a687cfb37dd4b2d Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:18:18 -0400 Subject: [PATCH 07/16] feat(mcp): API key deletion --- .gitignore | 3 ++ src/app/api/mcp-keys/[id]/route.ts | 36 +++++++++++++++++++++ src/app/api/workspaces/[id]/events/route.ts | 4 +++ 3 files changed, 43 insertions(+) create mode 100644 src/app/api/mcp-keys/[id]/route.ts diff --git a/.gitignore b/.gitignore index 7093805a..dc93fe6e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ bun.lockb # One-off / local scripts (keep on disk, not versioned) /scripts/ + + +.cursorrules diff --git a/src/app/api/mcp-keys/[id]/route.ts b/src/app/api/mcp-keys/[id]/route.ts new file mode 100644 index 00000000..cf31cfc9 --- /dev/null +++ b/src/app/api/mcp-keys/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { apiKeys } from "@/lib/db/schema"; +import { eq, and } from "drizzle-orm"; +import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; + +async function handleDELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const userId = await requireAuth(); + const { id } = await params; + + const [key] = await db + .select({ userId: apiKeys.userId }) + .from(apiKeys) + .where(eq(apiKeys.id, id)) + .limit(1); + + if (!key) { + return NextResponse.json({ error: "API key not found" }, { status: 404 }); + } + + if (key.userId !== userId) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + await db + .update(apiKeys) + .set({ revokedAt: new Date().toISOString() }) + .where(eq(apiKeys.id, id)); + + return NextResponse.json({ success: true }); +} + +export const DELETE = withErrorHandling(handleDELETE, "DELETE /api/mcp-keys/[id]"); diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index dab5ea0b..47d4b8ea 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -67,6 +67,7 @@ import { withErrorHandling, } from "@/lib/api/workspace-helpers"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; +import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; /** * GET /api/workspaces/[id]/events @@ -458,6 +459,9 @@ async function handlePOST( } // Success - no conflict + // Invalidate MCP cache for this workspace + invalidateWorkspaceCache(id); + // Check if we need to create a snapshot (async, non-blocking) checkAndCreateSnapshot(id).catch((err) => { console.error( From 032173732c64bfca2b5f39ee29d8ce6c371050f1 Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 00:48:48 -0400 Subject: [PATCH 08/16] fix: hardened security to prevent workspace leaks. Added functionality to prevent ReDoS. --- src/app/api/mcp-keys/route.ts | 18 +- src/app/api/mcp/route.ts | 178 ++++++++++-------- src/app/api/workspaces/[id]/events/route.ts | 2 +- src/components/auth/AccountModal.tsx | 104 +++++++--- src/lib/ai/workers/workspace-worker.ts | 2 +- src/lib/base-url.ts | 20 ++ src/lib/mcp/workspace-cache.ts | 18 ++ .../audio-transcribe/steps/persist-result.ts | 2 +- .../ocr-dispatch/steps/persist-results.ts | 2 +- 9 files changed, 239 insertions(+), 107 deletions(-) create mode 100644 src/lib/base-url.ts create mode 100644 src/lib/mcp/workspace-cache.ts diff --git a/src/app/api/mcp-keys/route.ts b/src/app/api/mcp-keys/route.ts index e79c21bc..449cfc2c 100644 --- a/src/app/api/mcp-keys/route.ts +++ b/src/app/api/mcp-keys/route.ts @@ -2,13 +2,27 @@ import { NextResponse } from "next/server"; import { createHash, randomBytes } from "crypto"; import { db } from "@/lib/db/client"; import { apiKeys } from "@/lib/db/schema"; -import { eq, and, isNull } from "drizzle-orm"; +import { eq, and, isNull, count } from "drizzle-orm"; import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; +const MAX_KEYS_PER_USER = 10; + async function handlePOST(req: Request) { const userId = await requireAuth(); const body = await req.json().catch(() => ({})); - const label = body?.label || null; + const label = typeof body?.label === "string" ? body.label.slice(0, 100) : null; + + const [{ total }] = await db + .select({ total: count() }) + .from(apiKeys) + .where(and(eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt))); + + if (total >= MAX_KEYS_PER_USER) { + return NextResponse.json( + { error: `You can have at most ${MAX_KEYS_PER_USER} active API keys. Revoke an existing key first.` }, + { status: 422 } + ); + } const rawKey = `tx_${randomBytes(32).toString("base64url")}`; const keyHash = createHash("sha256").update(rawKey).digest("hex"); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index a363fbb6..0d92176f 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -8,23 +8,35 @@ import { createHash } from "crypto"; import { db, workspaces } from "@/lib/db/client"; import { apiKeys } from "@/lib/db/schema"; import { eq, and, isNull } from "drizzle-orm"; -import { loadWorkspaceState } from "@/lib/workspace/state-loader"; -import type { AgentState, Item } from "@/lib/workspace-state/types"; - -const stateCache = new Map(); -const CACHE_TTL_MS = 30_000; - -async function getCachedState(workspaceId: string): Promise { - const cached = stateCache.get(workspaceId); - if (cached && cached.expiresAt > Date.now()) return cached.state; +import { getCachedState } from "@/lib/mcp/workspace-cache"; +import type { Item } from "@/lib/workspace-state/types"; + +const MAX_BODY_BYTES = 64 * 1024; // 64 KB — more than enough for any MCP request +const MAX_REGEX_LENGTH = 300; +const MAX_ID_LENGTH = 256; +const MAX_NAME_LENGTH = 500; + +// Throws if the workspace does not belong to userId — prevents cross-user data access. +async function assertOwnsWorkspace(workspaceId: string, userId: string): Promise { + const [ws] = await db + .select({ id: workspaces.id }) + .from(workspaces) + .where(and(eq(workspaces.id, workspaceId), eq(workspaces.userId, userId))) + .limit(1); + if (!ws) throw new McpAuthError("Workspace not found or access denied"); +} - const state = await loadWorkspaceState(workspaceId); - stateCache.set(workspaceId, { state, expiresAt: Date.now() + CACHE_TTL_MS }); - return state; +class McpAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "McpAuthError"; + } } -export function invalidateWorkspaceCache(workspaceId: string) { - stateCache.delete(workspaceId); +// Sanitise errors before sending to the caller — never leak raw DB messages. +function safeErrorMessage(error: unknown): string { + if (error instanceof McpAuthError) return error.message; + return "Internal server error"; } function extractText(item: Item): string | null { @@ -123,24 +135,13 @@ async function authenticateRequest(req: NextRequest): Promise { return key.userId; } -let mcpServer: Server | null = null; - -function getOrCreateServer(userId: string): Server { - if (!mcpServer) { - mcpServer = new Server( - { - name: "thinkex", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } - ); - registerTools(mcpServer, userId); - } - return mcpServer; +function createServer(userId: string): Server { + const server = new Server( + { name: "thinkex", version: "1.0.0" }, + { capabilities: { tools: {} } } + ); + registerTools(server, userId); + return server; } function registerTools(server: Server, userId: string) { @@ -265,6 +266,7 @@ function registerTools(server: Server, userId: string) { case "list_workspace": { const { workspaceId, folderId } = args as { workspaceId: string; folderId?: string }; + await assertOwnsWorkspace(workspaceId, userId); const state = await getCachedState(workspaceId); let items = state.items; @@ -291,6 +293,7 @@ function registerTools(server: Server, userId: string) { case "get_recent": { const { workspaceId, limit } = args as { workspaceId: string; limit?: number }; + await assertOwnsWorkspace(workspaceId, userId); const state = await getCachedState(workspaceId); const recentLimit = Math.min(limit ?? 5, 20); @@ -323,6 +326,15 @@ function registerTools(server: Server, userId: string) { type?: string; limit?: number; }; + await assertOwnsWorkspace(workspaceId, userId); + + if (typeof query !== "string" || query.length > MAX_REGEX_LENGTH) { + return { + content: [{ type: "text", text: JSON.stringify({ error: `Query must be a string of at most ${MAX_REGEX_LENGTH} characters` }, null, 2) }], + isError: true, + }; + } + const state = await getCachedState(workspaceId); let items = state.items; @@ -416,6 +428,15 @@ function registerTools(server: Server, userId: string) { pageStart?: number; pageEnd?: number; }; + await assertOwnsWorkspace(workspaceId, userId); + + if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME_LENGTH) { + return { + content: [{ type: "text", text: JSON.stringify({ error: `name must be a non-empty string of at most ${MAX_NAME_LENGTH} characters` }, null, 2) }], + isError: true, + }; + } + const state = await getCachedState(workspaceId); const nameLower = name.toLowerCase(); @@ -552,16 +573,12 @@ function registerTools(server: Server, userId: string) { isError: true, }; } - } catch (error: any) { + } catch (error: unknown) { return { content: [ { type: "text", - text: JSON.stringify( - { error: error.message || "Internal server error" }, - null, - 2 - ), + text: JSON.stringify({ error: safeErrorMessage(error) }, null, 2), }, ], isError: true, @@ -571,63 +588,74 @@ function registerTools(server: Server, userId: string) { } async function handleMCP(req: NextRequest) { - const userId = await authenticateRequest(req); + // Enforce body size limit before doing anything else + const contentLength = Number(req.headers.get("content-length") ?? 0); + if (contentLength > MAX_BODY_BYTES) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } + const userId = await authenticateRequest(req); if (!userId) { - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const server = getOrCreateServer(userId); + const server = createServer(userId); - if (req.method !== "POST") { - return NextResponse.json( - { error: "Only POST method is supported for MCP" }, - { status: 405 } - ); + let body: unknown; + try { + const text = await req.text(); + if (text.length > MAX_BODY_BYTES) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } + body = JSON.parse(text); + } catch { + return NextResponse.json({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Parse error" }, + }, { status: 400 }); } - try { - const body = await req.json(); - - const { method, params, id } = body; + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return NextResponse.json({ + jsonrpc: "2.0", + id: null, + error: { code: -32600, message: "Invalid request" }, + }, { status: 400 }); + } + const { method, params, id: rawId } = body as Record; + + // JSON-RPC id must be a string, number, or null — never reflect arbitrary values + const id: string | number | null = + typeof rawId === "string" && rawId.length <= MAX_ID_LENGTH ? rawId + : typeof rawId === "number" && Number.isFinite(rawId) ? rawId + : null; + + try { if (method === "tools/list") { - const response = await server.request({ method: "tools/list", params }, ListToolsRequestSchema); - return NextResponse.json({ - jsonrpc: "2.0", - id, - result: response, - }); + const response = await server.request({ method: "tools/list", params: params as any }, ListToolsRequestSchema); + return NextResponse.json({ jsonrpc: "2.0", id, result: response }); } else if (method === "tools/call") { - const response = await server.request({ method: "tools/call", params }, CallToolRequestSchema); - return NextResponse.json({ - jsonrpc: "2.0", - id, - result: response, - }); + const response = await server.request({ method: "tools/call", params: params as any }, CallToolRequestSchema); + return NextResponse.json({ jsonrpc: "2.0", id, result: response }); } else { return NextResponse.json({ jsonrpc: "2.0", id, - error: { - code: -32601, - message: `Method not found: ${method}`, - }, + error: { code: -32601, message: "Method not found" }, }); } - } catch (error: any) { + } catch { return NextResponse.json({ jsonrpc: "2.0", - id: null, - error: { - code: -32603, - message: error.message || "Internal error", - }, + id, + error: { code: -32603, message: "Internal error" }, }, { status: 500 }); } } export const POST = handleMCP; + +// Allow up to 30s for workspace state loading on large workspaces +export const maxDuration = 30; diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index 47d4b8ea..dae50e61 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -67,7 +67,7 @@ import { withErrorHandling, } from "@/lib/api/workspace-helpers"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; -import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; +import { invalidateWorkspaceCache } from "@/lib/mcp/workspace-cache"; /** * GET /api/workspaces/[id]/events diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index ad69f01b..26026892 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -7,6 +7,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getBaseURL } from "@/lib/base-url"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -45,14 +47,18 @@ export function AccountModal({ open, onOpenChange }: AccountModalProps) { return ( - - - Account Settings - -
- - - + +
+ + Account Settings + +
+
+
+ + + +
@@ -251,24 +257,7 @@ function MCPAccessSection() { Create API Key -
-

IDE Configuration

-

- Add this to your MCP settings file: -

-
-{`{
-  "mcpServers": {
-    "thinkex": {
-      "url": "${typeof window !== 'undefined' ? window.location.origin : 'https://thinkex.app'}/api/mcp",
-      "headers": {
-        "Authorization": "Bearer "
-      }
-    }
-  }
-}`}
-          
-
+ @@ -366,6 +355,69 @@ function MCPAccessSection() { ); } +function IDEConfigSection({ copyToClipboard }: { copyToClipboard: (text: string) => void }) { + const mcpUrl = `${getBaseURL()}/api/mcp`; + + const snippet = (filePath: string) => `// ${filePath} +{ + "mcpServers": { + "thinkex": { + "url": "${mcpUrl}", + "headers": { + "Authorization": "Bearer " + } + } + } +}`; + + const cursorGlobal = snippet("~/.cursor/mcp.json (global — all projects)"); + const cursorProject = snippet(".cursor/mcp.json (project-level)"); + const vscode = snippet(".vscode/mcp.json (project-level)"); + + return ( +
+

IDE Configuration

+

+ Create (or edit) the config file shown below and paste the snippet inside. + After saving, your IDE will discover the ThinkEx MCP server automatically. +

+ + + + Cursor — global + Cursor — project + VS Code + + + {[ + { value: "cursor-global", label: "~/.cursor/mcp.json", code: cursorGlobal, hint: "Applies to all your Cursor projects. Create the file if it doesn't exist." }, + { value: "cursor-project", label: ".cursor/mcp.json", code: cursorProject, hint: "Place this inside the root of a specific project. Useful for per-project keys." }, + { value: "vscode", label: ".vscode/mcp.json", code: vscode, hint: "Requires the MCP extension for VS Code. Place in the project root." }, + ].map(({ value, label, code, hint }) => ( + +
+
+ {label} + +
+
{code}
+
+

{hint}

+
+ ))} +
+
+ ); +} + function DangerZone() { const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [isDeleting, setIsDeleting] = useState(false); diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 8131064d..c61b9c29 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -31,7 +31,7 @@ import { import { parseJsonWithRepair } from "@/lib/utils/json-repair"; import { buildPdfDataFromUpload } from "@/lib/pdf/pdf-item"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; -import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; +import { invalidateWorkspaceCache } from "@/lib/mcp/workspace-cache"; /** Create params for a single item (used by create and bulkCreate). Exported for autogen. */ export type CreateItemParams = { diff --git a/src/lib/base-url.ts b/src/lib/base-url.ts new file mode 100644 index 00000000..56ece673 --- /dev/null +++ b/src/lib/base-url.ts @@ -0,0 +1,20 @@ +const PRODUCTION_URL = "https://thinkex.app"; + +/** + * Returns the canonical app base URL. + * Safe to call from both server and client code. + * + * Priority: + * 1. NEXT_PUBLIC_APP_URL — if it is not a localhost address + * 2. Hard-coded production URL as fallback + * + * Localhost values are intentionally ignored so that config snippets + * shown to users always reference the live deployment. + */ +export function getBaseURL(): string { + const envUrl = process.env.NEXT_PUBLIC_APP_URL; + if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) { + return envUrl.replace(/\/$/, ""); + } + return PRODUCTION_URL; +} diff --git a/src/lib/mcp/workspace-cache.ts b/src/lib/mcp/workspace-cache.ts new file mode 100644 index 00000000..169d06d1 --- /dev/null +++ b/src/lib/mcp/workspace-cache.ts @@ -0,0 +1,18 @@ +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import type { AgentState } from "@/lib/workspace-state/types"; + +const stateCache = new Map(); +const CACHE_TTL_MS = 30_000; + +export async function getCachedState(workspaceId: string): Promise { + const cached = stateCache.get(workspaceId); + if (cached && cached.expiresAt > Date.now()) return cached.state; + + const state = await loadWorkspaceState(workspaceId); + stateCache.set(workspaceId, { state, expiresAt: Date.now() + CACHE_TTL_MS }); + return state; +} + +export function invalidateWorkspaceCache(workspaceId: string) { + stateCache.delete(workspaceId); +} diff --git a/src/workflows/audio-transcribe/steps/persist-result.ts b/src/workflows/audio-transcribe/steps/persist-result.ts index 6e53e9de..87323002 100644 --- a/src/workflows/audio-transcribe/steps/persist-result.ts +++ b/src/workflows/audio-transcribe/steps/persist-result.ts @@ -3,7 +3,7 @@ import { db } from "@/lib/db/client"; import { createEvent } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; -import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; +import { invalidateWorkspaceCache } from "@/lib/mcp/workspace-cache"; import type { AudioData, Item } from "@/lib/workspace-state/types"; import type { TranscribeResult } from "./transcribe"; diff --git a/src/workflows/ocr-dispatch/steps/persist-results.ts b/src/workflows/ocr-dispatch/steps/persist-results.ts index ca86c417..c2efd411 100644 --- a/src/workflows/ocr-dispatch/steps/persist-results.ts +++ b/src/workflows/ocr-dispatch/steps/persist-results.ts @@ -5,7 +5,7 @@ import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; import type { WorkspaceEvent } from "@/lib/workspace/events"; import type { OcrItemResult } from "@/lib/ocr/types"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; -import { invalidateWorkspaceCache } from "@/app/api/mcp/route"; +import { invalidateWorkspaceCache } from "@/lib/mcp/workspace-cache"; import type { ImageData, Item, PdfData } from "@/lib/workspace-state/types"; const APPEND_RESULT_REGEX = /\(\s*(\d+)\s*,\s*(t|f|true|false)\s*\)/i; From b794f7c814b41f0f28f2cb02238188fa4c1ac7db Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 01:34:54 -0400 Subject: [PATCH 09/16] fix: oprimized searching to reduce token overhead. --- src/app/api/mcp/route.ts | 313 ++++++++++++--------------- src/components/auth/AccountModal.tsx | 72 +++++- 2 files changed, 196 insertions(+), 189 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 0d92176f..0232d88f 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -11,10 +11,17 @@ import { eq, and, isNull } from "drizzle-orm"; import { getCachedState } from "@/lib/mcp/workspace-cache"; import type { Item } from "@/lib/workspace-state/types"; -const MAX_BODY_BYTES = 64 * 1024; // 64 KB — more than enough for any MCP request +const MAX_BODY_BYTES = 64 * 1024; const MAX_REGEX_LENGTH = 300; const MAX_ID_LENGTH = 256; const MAX_NAME_LENGTH = 500; +const MAX_PDF_PAGES = 50; // ~25k words per call — fits comfortably in any modern model context window +const MAX_LINE_LIMIT = 2000; // ~20k words at typical prose density +const MIN_LINE_LIMIT = 1; +const DEFAULT_LINE_LIMIT = 500; // enough for a full section without requiring a follow-up call +const LIST_MAX_ITEMS = 200; // items returned per list_workspace call; use search_workspace for larger workspaces + +const VALID_ITEM_TYPES = new Set(["document", "pdf", "flashcard", "quiz", "audio", "image", "website", "youtube", "folder"]); // Throws if the workspace does not belong to userId — prevents cross-user data access. async function assertOwnsWorkspace(workspaceId: string, userId: string): Promise { @@ -151,90 +158,60 @@ function registerTools(server: Server, userId: string) { tools: [ { name: "list_workspaces", - description: "List all workspaces for the authenticated user", - inputSchema: { - type: "object", - properties: {}, - }, + description: "List all workspaces for the authenticated user. Call this first if you don't know the workspaceId.", + inputSchema: { type: "object", properties: {} }, }, { name: "list_workspace", - description: "List all items in a workspace (or in a specific folder)", + description: "List items in a workspace (metadata only — no content). Returns up to 200 most-recently-modified items. If totalItems exceeds returned, use search_workspace to find specific items instead of calling list_workspace repeatedly.", inputSchema: { type: "object", properties: { workspaceId: { type: "string", description: "Workspace ID" }, - folderId: { - type: "string", - description: "Optional folder ID to filter items", - }, + folderId: { type: "string", description: "Restrict to a specific folder (optional)" }, }, required: ["workspaceId"], }, }, { name: "get_recent", - description: "Get the N most recently modified items in a workspace", + description: "Get the N most recently modified items in a workspace. Use this to orient yourself quickly before deciding what to read.", inputSchema: { type: "object", properties: { workspaceId: { type: "string", description: "Workspace ID" }, - limit: { - type: "number", - description: "Number of items to return (default 5, max 20)", - }, + limit: { type: "number", description: "Items to return (default 5, max 20)" }, }, required: ["workspaceId"], }, }, { name: "search_workspace", - description: "Search for items in a workspace using regex", + description: "Search item content by keyword or pattern. Returns matching snippets with item names and line numbers. Use this before read_item to locate the right section — it is far more token-efficient than loading full documents to scan manually.", inputSchema: { type: "object", properties: { workspaceId: { type: "string", description: "Workspace ID" }, - query: { type: "string", description: "Regex search pattern" }, - folderId: { - type: "string", - description: "Optional folder ID to filter search", - }, - type: { - type: "string", - description: "Optional item type to filter search", - }, - limit: { - type: "number", - description: "Number of results to return (default 5, max 10)", - }, + query: { type: "string", description: "Search keyword or regex pattern" }, + folderId: { type: "string", description: "Restrict to a folder (optional)" }, + type: { type: "string", description: "Restrict to an item type: document, pdf, flashcard, quiz, audio, image (optional)" }, + limit: { type: "number", description: "Snippets to return (default 5, max 10)" }, }, required: ["workspaceId", "query"], }, }, { name: "read_item", - description: "Read the full content of an item (with pagination support)", + description: "Read the content of a named item. Fuzzy-matches the name. For text items use lineStart+limit; for PDFs use pageStart+pageEnd. The response includes hasMore and a note with the exact next call when the document continues beyond the current window.", inputSchema: { type: "object", properties: { workspaceId: { type: "string", description: "Workspace ID" }, name: { type: "string", description: "Item name (fuzzy matched)" }, - lineStart: { - type: "number", - description: "Line number to start from (1-indexed, for text items)", - }, - limit: { - type: "number", - description: "Number of lines to return (default 100, max 500)", - }, - pageStart: { - type: "number", - description: "Page number to start from (1-indexed, for PDFs)", - }, - pageEnd: { - type: "number", - description: "Page number to end at (for PDFs)", - }, + lineStart: { type: "number", description: "Start line, 1-indexed (text items only, default 1)" }, + limit: { type: "number", description: "Lines to return (default 500, max 2000)" }, + pageStart: { type: "number", description: "Start page, 1-indexed (PDFs only, default 1)" }, + pageEnd: { type: "number", description: "End page inclusive (PDFs only, max 50 pages per call)" }, }, required: ["workspaceId", "name"], }, @@ -255,12 +232,7 @@ function registerTools(server: Server, userId: string) { .where(eq(workspaces.userId, userId)); return { - content: [ - { - type: "text", - text: JSON.stringify(workspacesList, null, 2), - }, - ], + content: [{ type: "text", text: JSON.stringify(workspacesList) }], }; } @@ -274,20 +246,25 @@ function registerTools(server: Server, userId: string) { items = items.filter((i) => i.folderId === folderId); } - const itemsList = items.map((i) => ({ - name: i.name, - type: i.type, - folderId: i.folderId, - lastModified: i.lastModified, - })); + const totalItems = items.length; + + // Sort most-recently-modified first so the cap preserves the most relevant items + const sorted = [...items] + .sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)) + .slice(0, LIST_MAX_ITEMS) + .map((i) => ({ name: i.name, type: i.type, folderId: i.folderId ?? null, lastModified: i.lastModified })); + + const result: Record = { + items: sorted, + returned: sorted.length, + totalItems, + }; + if (totalItems > LIST_MAX_ITEMS) { + result.hint = `Showing ${LIST_MAX_ITEMS} most-recent of ${totalItems} items. Use search_workspace() to find specific items by name or content.`; + } return { - content: [ - { - type: "text", - text: JSON.stringify(itemsList, null, 2), - }, - ], + content: [{ type: "text", text: JSON.stringify(result) }], }; } @@ -301,20 +278,10 @@ function registerTools(server: Server, userId: string) { .filter((i) => i.lastModified) .sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)) .slice(0, recentLimit) - .map((i) => ({ - name: i.name, - type: i.type, - folderId: i.folderId, - lastModified: i.lastModified, - })); + .map((i) => ({ name: i.name, type: i.type, folderId: i.folderId ?? null, lastModified: i.lastModified })); return { - content: [ - { - type: "text", - text: JSON.stringify(recent, null, 2), - }, - ], + content: [{ type: "text", text: JSON.stringify(recent) }], }; } @@ -328,9 +295,16 @@ function registerTools(server: Server, userId: string) { }; await assertOwnsWorkspace(workspaceId, userId); - if (typeof query !== "string" || query.length > MAX_REGEX_LENGTH) { + if (typeof query !== "string" || query.length === 0 || query.length > MAX_REGEX_LENGTH) { return { - content: [{ type: "text", text: JSON.stringify({ error: `Query must be a string of at most ${MAX_REGEX_LENGTH} characters` }, null, 2) }], + content: [{ type: "text", text: JSON.stringify({ error: `Query must be a non-empty string of at most ${MAX_REGEX_LENGTH} characters` }) }], + isError: true, + }; + } + + if (type !== undefined && !VALID_ITEM_TYPES.has(type)) { + return { + content: [{ type: "text", text: JSON.stringify({ error: `Unknown item type "${type}". Valid types: ${[...VALID_ITEM_TYPES].join(", ")}` }) }], isError: true, }; } @@ -345,15 +319,17 @@ function registerTools(server: Server, userId: string) { items = items.filter((i) => i.type === type); } + // Use case-insensitive flag only (no `g`) to avoid lastIndex state bleeding + // between test() calls on separate lines, which causes matches to be skipped. let regex: RegExp; try { - regex = new RegExp(query, "gi"); + regex = new RegExp(query, "i"); } catch { return { content: [ { type: "text", - text: JSON.stringify({ error: "Invalid regex pattern" }, null, 2), + text: JSON.stringify({ error: "Invalid regex pattern" }), }, ], isError: true, @@ -363,7 +339,7 @@ function registerTools(server: Server, userId: string) { const matches: Array<{ itemName: string; itemType: string; - folderId?: string; + folderId: string | null; lineStart: number; content: string; }> = []; @@ -379,7 +355,7 @@ function registerTools(server: Server, userId: string) { matches.push({ itemName: item.name, itemType: item.type, - folderId: item.folderId, + folderId: item.folderId ?? null, lineStart: i + 1, content: lines[i].trim(), }); @@ -391,31 +367,16 @@ function registerTools(server: Server, userId: string) { if (matches.length === 0) { return { - content: [ - { - type: "text", - text: JSON.stringify( - { - results: [], - suggestion: - "No matches found. Try list_workspace() to browse available items or broaden your query.", - }, - null, - 2 - ), - }, - ], + content: [{ + type: "text", + text: JSON.stringify({ results: [], suggestion: "No matches found. Try list_workspace() to browse available items or broaden your query." }), + }], }; } - const resultLimit = Math.min(limit ?? 5, 10); + const resultLimit = Math.min(Math.max(limit ?? 5, 1), 10); return { - content: [ - { - type: "text", - text: JSON.stringify(matches.slice(0, resultLimit), null, 2), - }, - ], + content: [{ type: "text", text: JSON.stringify(matches.slice(0, resultLimit)) }], }; } @@ -432,7 +393,7 @@ function registerTools(server: Server, userId: string) { if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME_LENGTH) { return { - content: [{ type: "text", text: JSON.stringify({ error: `name must be a non-empty string of at most ${MAX_NAME_LENGTH} characters` }, null, 2) }], + content: [{ type: "text", text: JSON.stringify({ error: `name must be a non-empty string of at most ${MAX_NAME_LENGTH} characters` }) }], isError: true, }; } @@ -467,99 +428,95 @@ function registerTools(server: Server, userId: string) { if (!matchedItem) { return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: "Item not found. Try list_workspace() to see available items.", - }, - null, - 2 - ), - }, - ], + content: [{ type: "text", text: JSON.stringify({ error: "Item not found. Try list_workspace() to see available items." }) }], isError: true, }; } if (matchedItem.type === "pdf") { - const pages = ((matchedItem.data as any).ocrPages ?? []) as Array<{ - markdown: string; - }>; - const start = (pageStart ?? 1) - 1; - const end = (pageEnd ?? pages.length) - 1; + const pages = ((matchedItem.data as any).ocrPages ?? []) as Array<{ markdown: string }>; + const totalPages = pages.length; + + // Clamp pageStart to a valid 1-based page number + const clampedStart = Math.max(1, Math.floor(pageStart ?? 1)); + if (clampedStart > totalPages) { + return { + content: [{ type: "text", text: JSON.stringify({ error: `pageStart (${clampedStart}) exceeds total pages (${totalPages})` }) }], + isError: true, + }; + } + + // Cap the window: if pageEnd is supplied, honour it but never exceed MAX_PDF_PAGES per request + const requestedEnd = pageEnd !== undefined ? Math.floor(pageEnd) : clampedStart + MAX_PDF_PAGES - 1; + if (requestedEnd < clampedStart) { + return { + content: [{ type: "text", text: JSON.stringify({ error: "pageEnd must be >= pageStart" }) }], + isError: true, + }; + } + const clampedEnd = Math.min(requestedEnd, clampedStart + MAX_PDF_PAGES - 1, totalPages); + const content = pages - .slice(start, end + 1) + .slice(clampedStart - 1, clampedEnd) .map((p) => p.markdown) .join("\n\n---\n\n"); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - itemName: matchedItem.name, - itemType: matchedItem.type, - content, - estimatedTokens: Math.ceil(content.length / 4), - totalPages: pages.length, - pageStart: start + 1, - }, - null, - 2 - ), - }, - ], + const pdfResult: Record = { + itemName: matchedItem.name, + itemType: matchedItem.type, + content, + estimatedTokens: Math.ceil(content.length / 4), + pageStart: clampedStart, + pageEnd: clampedEnd, + totalPages, + hasMore: clampedEnd < totalPages, }; + if (clampedEnd < totalPages) { + pdfResult.note = `Showing pages ${clampedStart}–${clampedEnd} of ${totalPages}. Call read_item again with pageStart=${clampedEnd + 1} for the next section.`; + } + + return { content: [{ type: "text", text: JSON.stringify(pdfResult) }] }; } const text = extractText(matchedItem); if (!text) { const url = (matchedItem.data as any).url; return { - content: [ - { - type: "text", - text: JSON.stringify( - { - content: null, - note: `This item has no stored body text. URL: ${url ?? "N/A"}`, - }, - null, - 2 - ), - }, - ], + content: [{ type: "text", text: JSON.stringify({ content: null, note: `This item has no stored body text. URL: ${url ?? "N/A"}` }) }], }; } const lines = text.split("\n"); - const start = (lineStart ?? 1) - 1; - const lineLimit = Math.min(limit ?? 100, 500); - const slice = lines.slice(start, start + lineLimit); - const content = slice.join("\n"); + const totalLines = lines.length; - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - itemName: matchedItem.name, - itemType: matchedItem.type, - content, - estimatedTokens: Math.ceil(content.length / 4), - totalLines: lines.length, - lineStart: start + 1, - }, - null, - 2 - ), - }, - ], + const clampedLineStart = Math.max(1, Math.floor(lineStart ?? 1)); + if (clampedLineStart > totalLines) { + return { + content: [{ type: "text", text: JSON.stringify({ error: `lineStart (${clampedLineStart}) exceeds total lines (${totalLines})` }) }], + isError: true, + }; + } + + const lineLimit = Math.min(Math.max(Math.floor(limit ?? DEFAULT_LINE_LIMIT), MIN_LINE_LIMIT), MAX_LINE_LIMIT); + const slice = lines.slice(clampedLineStart - 1, clampedLineStart - 1 + lineLimit); + const content = slice.join("\n"); + const returnedEnd = clampedLineStart - 1 + slice.length; + + const textResult: Record = { + itemName: matchedItem.name, + itemType: matchedItem.type, + content, + estimatedTokens: Math.ceil(content.length / 4), + lineStart: clampedLineStart, + lineEnd: returnedEnd, + totalLines, + hasMore: returnedEnd < totalLines, }; + if (returnedEnd < totalLines) { + textResult.note = `Showing lines ${clampedLineStart}–${returnedEnd} of ${totalLines}. Call read_item again with lineStart=${returnedEnd + 1} for the next section.`; + } + + return { content: [{ type: "text", text: JSON.stringify(textResult) }] }; } default: @@ -567,7 +524,7 @@ function registerTools(server: Server, userId: string) { content: [ { type: "text", - text: JSON.stringify({ error: `Unknown tool: ${name}` }, null, 2), + text: JSON.stringify({ error: `Unknown tool: ${name}` }), }, ], isError: true, @@ -578,7 +535,7 @@ function registerTools(server: Server, userId: string) { content: [ { type: "text", - text: JSON.stringify({ error: safeErrorMessage(error) }, null, 2), + text: JSON.stringify({ error: safeErrorMessage(error) }), }, ], isError: true, diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 26026892..cdf9f5c2 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { Fragment, useState, useEffect } from "react"; import { Dialog, DialogContent, @@ -370,10 +370,54 @@ function IDEConfigSection({ copyToClipboard }: { copyToClipboard: (text: string) } }`; + const claudeCodeSnippet = `// .mcp.json (project-level) +{ + "mcpServers": { + "thinkex": { + "type": "http", + "url": "${mcpUrl}", + "headers": { + "Authorization": "Bearer " + } + } + } +}`; + const cursorGlobal = snippet("~/.cursor/mcp.json (global — all projects)"); const cursorProject = snippet(".cursor/mcp.json (project-level)"); const vscode = snippet(".vscode/mcp.json (project-level)"); + const ideTabs = [ + { + value: "cursor-global", + tabLabel: "Cursor — global", + fileLabel: "~/.cursor/mcp.json", + code: cursorGlobal, + hint: "Applies to all your Cursor projects. Create the file if it doesn't exist.", + }, + { + value: "cursor-project", + tabLabel: "Cursor — project", + fileLabel: ".cursor/mcp.json", + code: cursorProject, + hint: "Place this inside the root of a specific project. Useful for per-project keys.", + }, + { + value: "vscode", + tabLabel: "VS Code", + fileLabel: ".vscode/mcp.json", + code: vscode, + hint: "Requires the MCP extension for VS Code. Place in the project root.", + }, + { + value: "claude-code", + tabLabel: "Claude Code", + fileLabel: ".mcp.json", + code: claudeCodeSnippet, + hint: 'Place .mcp.json in your project root. Claude Code requires the "type": "http" field for remote servers.', + }, + ] as const; + return (

IDE Configuration

@@ -383,21 +427,27 @@ function IDEConfigSection({ copyToClipboard }: { copyToClipboard: (text: string)

- - Cursor — global - Cursor — project - VS Code + + {ideTabs.map((tab, index) => ( + + {index > 0 ? ( + + ) : null} + + {tab.tabLabel} + + + ))} - {[ - { value: "cursor-global", label: "~/.cursor/mcp.json", code: cursorGlobal, hint: "Applies to all your Cursor projects. Create the file if it doesn't exist." }, - { value: "cursor-project", label: ".cursor/mcp.json", code: cursorProject, hint: "Place this inside the root of a specific project. Useful for per-project keys." }, - { value: "vscode", label: ".vscode/mcp.json", code: vscode, hint: "Requires the MCP extension for VS Code. Place in the project root." }, - ].map(({ value, label, code, hint }) => ( + {ideTabs.map(({ value, fileLabel, code, hint }) => (
- {label} + {fileLabel} + - {isLoading ? ( -
- -
- ) : loadFailed ? ( -
- Failed to load API keys. Please try again. -
- ) : keys.length === 0 ? ( -
- No API keys yet. Create one to get started. -
- ) : ( -
- - - - Label - Key Prefix - Created - Last Used - Actions - - - - {keys.map((key) => ( - - {key.label || Unlabeled} - - {key.prefix}... - - {formatDate(key.createdAt)} - {formatDate(key.lastUsedAt)} - - - + + {isLoading ? ( +
+ +
+ ) : loadFailed ? ( +
+ Failed to load API keys. Please try again. +
+ ) : keys.length === 0 ? ( +
+ No API keys yet. Create one to get started. +
+ ) : ( +
+
+ + + Label + Key Prefix + Created + Last Used + Actions - ))} - -
-
- )} + + + {keys.map((key) => ( + + {key.label || Unlabeled} + + {key.prefix}... + + {formatDate(key.createdAt)} + {formatDate(key.lastUsedAt)} + + + + + ))} + +
+
+ )} - + - - + + + diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 87eeec56..5a0dab25 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef< Date: Tue, 7 Apr 2026 11:45:37 -0400 Subject: [PATCH 13/16] fix(mcp): enhance error handling and text extraction logic - Improved error handling in `loadWorkspaceState` to catch and log errors, ensuring better reliability when fetching workspace state. - Updated text extraction logic to handle PDF items more effectively, allowing for line-by-line matching within pages. - Removed unsupported item types from `VALID_ITEM_TYPES` to streamline processing. --- src/app/api/mcp/route.ts | 53 +++++++++++++++++++--------- src/app/share-copy/[id]/layout.tsx | 10 ++++-- src/components/auth/AccountModal.tsx | 12 ++++--- src/lib/ai/tools/tool-utils.ts | 9 +++-- 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index e14c0310..30b9a3ae 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -23,7 +23,7 @@ const MIN_LINE_LIMIT = 1; const DEFAULT_LINE_LIMIT = 500; // enough for a full section without requiring a follow-up call const LIST_MAX_ITEMS = 200; // items returned per list_workspace call; use search_workspace for larger workspaces -const VALID_ITEM_TYPES = new Set(["document", "pdf", "flashcard", "quiz", "audio", "image", "website", "youtube", "folder"]); +const VALID_ITEM_TYPES = new Set(["document", "pdf", "flashcard", "quiz", "audio", "image"]); // Escapes all regex metacharacters so user-supplied query strings are always // treated as literal substrings rather than patterns, preventing ReDoS. @@ -83,7 +83,7 @@ function extractText(item: Item): string | null { ((data.segments as any[] | undefined) ?.map((s: any) => s.content) .filter(Boolean) - .join(" ") || null); + .join("\n") || null); const summary: string | null = data.summary ?? null; return [transcript, summary].filter(Boolean).join("\n\n") || null; } @@ -344,25 +344,46 @@ function registerTools(server: Server, userId: string) { itemType: string; folderId: string | null; lineStart: number; + pageNumber?: number; content: string; }> = []; const INTERNAL_CAP = 100; for (const item of items) { - const text = extractText(item); - if (!text) continue; - - const lines = text.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (regex.test(lines[i])) { - matches.push({ - itemName: item.name, - itemType: item.type, - folderId: item.folderId ?? null, - lineStart: i + 1, - content: lines[i].trim(), - }); - if (matches.length >= INTERNAL_CAP) break; + if (item.type === "pdf") { + const pages = ((item.data as any).ocrPages ?? []) as Array<{ markdown: string }>; + for (let pageIdx = 0; pageIdx < pages.length && matches.length < INTERNAL_CAP; pageIdx++) { + const pageLines = (pages[pageIdx].markdown ?? "").split("\n"); + for (let lineIdx = 0; lineIdx < pageLines.length; lineIdx++) { + if (regex.test(pageLines[lineIdx])) { + matches.push({ + itemName: item.name, + itemType: item.type, + folderId: item.folderId ?? null, + pageNumber: pageIdx + 1, + lineStart: lineIdx + 1, + content: pageLines[lineIdx].trim(), + }); + if (matches.length >= INTERNAL_CAP) break; + } + } + } + } else { + const text = extractText(item); + if (!text) continue; + + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + matches.push({ + itemName: item.name, + itemType: item.type, + folderId: item.folderId ?? null, + lineStart: i + 1, + content: lines[i].trim(), + }); + if (matches.length >= INTERNAL_CAP) break; + } } } if (matches.length >= INTERNAL_CAP) break; diff --git a/src/app/share-copy/[id]/layout.tsx b/src/app/share-copy/[id]/layout.tsx index a83bdfb7..763806c5 100644 --- a/src/app/share-copy/[id]/layout.tsx +++ b/src/app/share-copy/[id]/layout.tsx @@ -38,9 +38,15 @@ export async function generateMetadata({ params }: Props): Promise { } // Fetch full state to get potentially updated title/description - const state = await loadWorkspaceState(id); + let stateTitle: string | undefined; + try { + const state = await loadWorkspaceState(id); + stateTitle = state.globalTitle; + } catch (err) { + console.error("[generateMetadata] loadWorkspaceState failed for workspace", id, err); + } - const title = state.globalTitle || workspace[0].name || "Untitled Workspace"; + const title = stateTitle || workspace[0].name || "Untitled Workspace"; const sharedTitle = `Shared Workspace: ${title}`; const description = workspace[0].description || "View and import this shared ThinkEx workspace."; const fullTitle = getPageTitle(sharedTitle); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 3b498d08..ab74fbb2 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -207,9 +207,13 @@ function MCPAccessSection() { } }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast.success("Copied to clipboard"); + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + } catch { + toast.error("Failed to copy to clipboard"); + } }; const formatDate = (dateStr: string | null) => { @@ -328,7 +332,7 @@ function MCPAccessSection() { - + { setShowKeyModal(open); if (!open) setNewKeyData(null); }}> API Key Created diff --git a/src/lib/ai/tools/tool-utils.ts b/src/lib/ai/tools/tool-utils.ts index 23cdb9e0..de66035e 100644 --- a/src/lib/ai/tools/tool-utils.ts +++ b/src/lib/ai/tools/tool-utils.ts @@ -59,8 +59,13 @@ export async function loadStateForTool( return { success: false, message: "No workspace context available" }; } - const state = await loadWorkspaceState(ctx.workspaceId); - return { success: true, state }; + try { + const state = await loadWorkspaceState(ctx.workspaceId); + return { success: true, state }; + } catch (err) { + console.error("[loadStateForTool] loadWorkspaceState failed", err); + return { success: false, message: "Failed to load workspace state" }; + } } /** From 1b85f5e4c951e7dbb3ad1a5ea40d10ffe89cbcff Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 11:50:14 -0400 Subject: [PATCH 14/16] fix(auth): improve API key fetching and error handling in AccountModal --- src/components/auth/AccountModal.tsx | 30 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index ab74fbb2..3963351d 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useState, useEffect } from "react"; +import { Fragment, useState, useEffect, useRef } from "react"; import { Dialog, DialogContent, @@ -141,10 +141,13 @@ function MCPAccessSection() { const [label, setLabel] = useState(""); const [isCreating, setIsCreating] = useState(false); const [keyToRevoke, setKeyToRevoke] = useState(null); + const latestFetchIdRef = useRef(0); const fetchKeys = async () => { + const fetchId = ++latestFetchIdRef.current; try { const res = await fetch("/api/mcp-keys"); + if (fetchId !== latestFetchIdRef.current) return; if (!res.ok) { console.error("Failed to load API keys:", res.status, res.statusText); toast.error("Failed to load API keys"); @@ -152,22 +155,27 @@ function MCPAccessSection() { return; } const data = await res.json(); + if (fetchId !== latestFetchIdRef.current) return; setKeys(data.keys || []); setLoadFailed(false); } catch (error) { + if (fetchId !== latestFetchIdRef.current) return; toast.error("Failed to load API keys"); setLoadFailed(true); } finally { - setIsLoading(false); + if (fetchId === latestFetchIdRef.current) { + setIsLoading(false); + } } }; useEffect(() => { - if (open && keys.length === 0 && !loadFailed) { + if (open && keys.length === 0) { setIsLoading(true); fetchKeys(); } - }, [open]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, loadFailed, keys.length]); const handleCreateKey = async () => { setIsCreating(true); @@ -178,7 +186,15 @@ function MCPAccessSection() { body: JSON.stringify({ label: label || null }), }); - if (!res.ok) throw new Error("Failed to create API key"); + if (!res.ok) { + let errorMessage = "Failed to create API key"; + try { + const errData = await res.json(); + errorMessage = errData.message || errData.error || errorMessage; + } catch {} + toast.error(errorMessage); + return; + } const data = await res.json(); setNewKeyData({ rawKey: data.rawKey, prefix: data.prefix }); @@ -186,8 +202,8 @@ function MCPAccessSection() { setShowKeyModal(true); setLabel(""); await fetchKeys(); - } catch (error) { - toast.error("Failed to create API key"); + } catch (error: any) { + toast.error(error?.message || "Failed to create API key"); } finally { setIsCreating(false); } From aa49eab0832c9d25a5434eda160bdf02e00c502f Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 12:00:21 -0400 Subject: [PATCH 15/16] fix(auth): refine API key loading logic in AccountModal --- src/components/auth/AccountModal.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 3963351d..26df3a76 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -170,12 +170,19 @@ function MCPAccessSection() { }; useEffect(() => { - if (open && keys.length === 0) { + if (open) { + setLoadFailed(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (open && keys.length === 0 && !loadFailed) { setIsLoading(true); fetchKeys(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, loadFailed, keys.length]); + }, [open, keys.length]); const handleCreateKey = async () => { setIsCreating(true); From b99e6ccab4e17d38b7c38a5fa5ab3f235b1ea4e8 Mon Sep 17 00:00:00 2001 From: syednahm Date: Tue, 7 Apr 2026 15:34:57 -0400 Subject: [PATCH 16/16] fix(auth): update dependency in API key loading logic for improved reliability --- src/components/auth/AccountModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 26df3a76..666355ac 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -182,7 +182,7 @@ function MCPAccessSection() { fetchKeys(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, keys.length]); + }, [open, keys.length, loadFailed]); const handleCreateKey = async () => { setIsCreating(true);