UNPKG

@briefhq/mcp-server

Version:

Brief MCP server and CLI – connect Cursor/Claude MCP to your Brief organization

868 lines (867 loc) 57.2 kB
import { z } from "zod"; import { apiFetch } from "../lib/supabase.js"; import { zodToJsonSchema } from "zod-to-json-schema"; // Helper function for POST requests with JSON error handling async function postJsonOrThrow(path, body, label) { const res = await apiFetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (!res.ok) { const msg = await res.text().catch(() => ""); const reqId = res.headers?.get?.("x-request-id") || res.headers?.get?.("x-amzn-requestid") || ""; const suffix = reqId ? `, reqId=${reqId}` : ""; throw new Error(`${label} failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } return await res.json(); } // Layer 1: Discovery - What can I do in this domain? const briefDiscoverCapabilitiesSchema = z.object({ domain: z.enum(['context', 'content', 'documents', 'folders', 'decisions', 'product', 'bulk', 'all']).optional().default('all'), detail_level: z.enum(['summary', 'detailed']).optional().default('summary') }); // Layer 2: Planning - How should I structure this request? const briefPlanOperationSchema = z.object({ operation: z.enum([ // Content operations (also available through prepare_context) 'search', 'browse', 'find_related', 'get_recent', 'retrieve_content', // Document operations 'create_document', 'get_document', 'update_document', 'delete_document', 'move_document', // Folder operations 'create_folder', 'get_folder', 'update_folder', 'delete_folder', 'move_folder', 'get_folder_tree', 'get_folder_documents', // Bulk operations 'bulk_move', 'bulk_delete', // Product graph 'get_product_graph', 'update_product_graph', // Decision operations 'record_decision', 'confirm_decision', 'archive_decision', 'search_decisions', 'create_session_decision', 'get_session_decisions', 'delete_session_decision', 'propose_decision', 'batch_propose_decisions', 'get_decision_proposals', 'confirm_decision_proposal', // Context operations 'get_context', 'guard_approach' ]), target: z.enum(['document', 'folder', 'decision', 'product_graph', 'content']).optional(), context: z.record(z.unknown()).optional() }); // Layer 3: Preparation - Get the context I need for this operation const briefPrepareContextSchema = z.object({ preparation_type: z.enum(['get_context', 'search', 'browse', 'find_related', 'get_recent', 'retrieve_content']), // Context preparation keys: z.array(z.string()).optional(), // Search preparation query: z.string().optional(), folder_id: z.string().optional(), // Browse preparation path: z.string().optional(), include_subfolders: z.boolean().optional(), depth: z.number().int().min(1).max(10).optional(), // Related preparation document_id: z.string().optional(), // Retrieve preparation document_ids: z.array(z.string()).optional(), max_tokens: z.number().int().min(1).max(8000).optional().default(2000), mode: z.enum(['smart_subset', 'summaries']).optional().default('smart_subset'), // Common params limit: z.number().int().min(1).max(100).optional().default(10) }); // Layer 4: Execution - Perform the actual operation with confirmation support const briefExecuteOperationSchema = z.object({ operation: z.enum([ // Document operations 'create_document', 'get_document', 'update_document', 'delete_document', 'move_document', // Folder operations 'create_folder', 'get_folder', 'update_folder', 'delete_folder', 'move_folder', 'get_folder_tree', 'get_folder_documents', // Bulk operations 'bulk_move', 'bulk_delete', // Product graph 'get_product_graph', 'update_product_graph', // Decision operations 'record_decision', 'confirm_decision', 'archive_decision', 'search_decisions', 'create_session_decision', 'get_session_decisions', 'delete_session_decision', 'propose_decision', 'batch_propose_decisions', 'get_decision_proposals', 'confirm_decision_proposal', // Context operations 'guard_approach', // Content operations (convenience - also available via prepare_context) 'search' ]), parameters: z.record(z.unknown()), confirmation_token: z.string().optional(), limit: z.number().optional() }); export const layeredTools = { brief_discover_capabilities: { description: "Discover what Brief can do across different domains. Provides overview of available operations and capabilities. See @brief-guidelines.md for complete usage guidelines.", inputSchema: briefDiscoverCapabilitiesSchema, inputJsonSchema: zodToJsonSchema(briefDiscoverCapabilitiesSchema, "brief_discover_capabilities"), execute: async (args) => { const { domain, detail_level } = args; // Base capabilities structure const capabilities = { context: { description: "Context discovery and planning operations", operations: ['get_context', 'guard_approach'], requires_confirmation: false }, content: { description: "Content search, browse, and retrieval operations", operations: ['search', 'browse', 'find_related', 'get_recent', 'retrieve_content'], requires_confirmation: false }, documents: { description: "Document CRUD operations", operations: ['create_document', 'get_document', 'update_document', 'delete_document', 'move_document'], requires_confirmation: ['update_document', 'delete_document', 'move_document'] }, folders: { description: "Folder management and navigation", operations: ['create_folder', 'get_folder', 'update_folder', 'delete_folder', 'move_folder', 'get_folder_tree', 'get_folder_documents'], requires_confirmation: ['delete_folder', 'move_folder'] }, decisions: { description: "Decision recording and management", operations: ['record_decision', 'confirm_decision', 'archive_decision', 'search_decisions', 'create_session_decision', 'get_session_decisions', 'delete_session_decision', 'propose_decision', 'batch_propose_decisions', 'get_decision_proposals', 'confirm_decision_proposal'], requires_confirmation: ['archive_decision'] }, product: { description: "Product graph operations", operations: ['get_product_graph', 'update_product_graph'], requires_confirmation: ['update_product_graph'] }, bulk: { description: "Bulk operations for efficiency", operations: ['bulk_move', 'bulk_delete'], requires_confirmation: ['bulk_move', 'bulk_delete'] } }; if (domain === 'all') { if (detail_level === 'summary') { return { available_domains: Object.keys(capabilities), total_operations: Object.values(capabilities).reduce((sum, domain) => sum + domain.operations.length, 0), confirmation_required_operations: Object.values(capabilities).reduce((sum, domain) => sum + (Array.isArray(domain.requires_confirmation) ? domain.requires_confirmation.length : 0), 0), workflow: "Use brief_plan_operation to get detailed schemas for specific operations, then brief_prepare_context for any needed context, finally brief_execute_operation to perform the action.", capabilities: Object.fromEntries(Object.entries(capabilities).map(([key, value]) => [key, { description: value.description, operation_count: value.operations.length }])) }; } else { return { capabilities }; } } else { const domainCaps = capabilities[domain]; if (!domainCaps) { throw new Error(`Unknown domain: ${domain}. Available domains: ${Object.keys(capabilities).join(', ')}`); } return { domain, ...domainCaps }; } } }, brief_plan_operation: { description: "Get detailed schemas, validation rules, and workflow guidance for operations. Shows required/optional parameters and whether confirmation is needed.", inputSchema: briefPlanOperationSchema, inputJsonSchema: zodToJsonSchema(briefPlanOperationSchema, "brief_plan_operation"), execute: async (args) => { const { operation, target, context } = args; // Operation definitions with detailed schemas and requirements const operationPlans = { // Content operations search: { description: "Search documents by content, title, or metadata", required_params: ['query'], optional_params: ['folder_id', 'limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly OR brief_prepare_context with preparation_type: 'search'", note: "Available both as direct operation and through prepare_context" }, browse: { description: "Navigate folder structure and list contents", required_params: [], optional_params: ['folder_id', 'include_subfolders', 'limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_prepare_context with preparation_type: 'browse'" }, find_related: { description: "Find documents related to a specific document", required_params: ['document_id'], optional_params: ['limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_prepare_context with preparation_type: 'find_related'" }, get_recent: { description: "Get recently accessed or updated documents", required_params: [], optional_params: ['folder_id', 'limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_prepare_context with preparation_type: 'get_recent'" }, retrieve_content: { description: "Retrieve full document content with smart token budgeting", required_params: [], optional_params: ['document_ids', 'folder_id', 'query', 'max_tokens', 'mode'], preparation_needed: false, confirmation_required: false, next_layer: "brief_prepare_context with preparation_type: 'retrieve_content'" }, // Document operations create_document: { description: "Create a new document", required_params: ['title'], optional_params: ['content', 'folder_id'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly", folder_guidance: "If no folder_id specified, document will be created in My Documents folder. Consider appropriate folders: Projects (for project docs), Planning (for strategy docs), or specify a folder_id." }, get_document: { description: "Retrieve a single document by ID with full details", required_params: ['id'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, update_document: { description: "Update an existing document", required_params: ['id'], optional_params: ['title', 'content', 'folder_id'], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see changes, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, delete_document: { description: "Delete a document", required_params: ['id'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see what will be deleted, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, move_document: { description: "Move a document to a different folder", required_params: ['id', 'folder_id'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see the move, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, // Folder operations create_folder: { description: "Create a new folder within an existing folder", required_params: ['name', 'parent_id'], optional_params: ['is_private'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly", important_note: "parent_id is REQUIRED - folders must be created within existing folders (Projects, Planning, User Research, My Documents, or their subfolders). There is no 'root level' in Brief." }, get_folder: { description: "Retrieve a single folder by ID with full details", required_params: ['id'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, update_folder: { description: "Update folder properties (name, privacy settings)", required_params: ['id'], optional_params: ['name', 'is_private'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly", note: "Cannot modify system folders. Privacy can only be changed on custom folders you own." }, delete_folder: { description: "Delete a folder (must be empty or allow cascading)", required_params: ['id'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see what will be deleted, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, move_folder: { description: "Move a folder to a different parent folder", required_params: ['id', 'parent_id'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see the move, 2. Get user approval, 3. Call with confirmation_token", next_layer: "brief_execute_operation with preview: true first" }, get_folder_tree: { description: "Get folder tree structure", required_params: [], optional_params: ['path', 'depth', 'include_documents'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, get_folder_documents: { description: "List all documents within a specific folder", required_params: ['folder_id'], optional_params: ['limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, // Bulk operations bulk_move: { description: "Move multiple documents/folders at once", required_params: ['items', 'folder_id'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see all moves, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, bulk_delete: { description: "Delete multiple documents/folders at once", required_params: ['items'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see all deletions, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, // Product graph get_product_graph: { description: "Get current product graph configuration", required_params: [], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, update_product_graph: { description: "Update product graph configuration", required_params: ['config'], optional_params: [], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see changes, 2. Get user approval, 3. Call with confirm: true + token", next_layer: "brief_execute_operation with preview: true first" }, // Decision operations record_decision: { description: "Record a new architectural or business decision", required_params: ['decision', 'rationale'], optional_params: ['category', 'severity', 'scope', 'author', 'supersedes'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, confirm_decision: { description: "Confirm a previously proposed decision", required_params: ['id'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, search_decisions: { description: "Search through existing decisions", required_params: [], optional_params: ['query', 'limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, archive_decision: { description: "Archive a decision (hide from active use but preserve for audit)", required_params: ['id'], optional_params: ['reason'], preparation_needed: false, confirmation_required: true, confirmation_workflow: "1. Call with preview: true to see what will be archived, 2. Get user approval, 3. Call with confirmation_token", next_layer: "brief_execute_operation with preview: true first" }, create_session_decision: { description: "Create a temporary session-scoped decision", required_params: ['session_id', 'decision', 'rationale'], optional_params: ['expires_in_minutes'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, get_session_decisions: { description: "Retrieve session-scoped decisions", required_params: [], optional_params: ['session_id'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, delete_session_decision: { description: "Delete a session decision", required_params: ['session_id'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, propose_decision: { description: "Propose a new decision for review", required_params: ['detected_text'], optional_params: ['confidence', 'category', 'severity'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, batch_propose_decisions: { description: "Propose multiple decisions in batch", required_params: ['documents'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, get_decision_proposals: { description: "List pending decision proposals", required_params: [], optional_params: ['status', 'limit'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, confirm_decision_proposal: { description: "Confirm and accept a decision proposal", required_params: ['proposal_id'], optional_params: ['edits'], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" }, // Context operations get_context: { description: "Fetch specific context by keys", required_params: ['keys'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_prepare_context with preparation_type: 'get_context'" }, guard_approach: { description: "Check for conflicts against existing decisions", required_params: ['approach'], optional_params: [], preparation_needed: false, confirmation_required: false, next_layer: "brief_execute_operation directly" } }; const plan = operationPlans[operation]; if (!plan) { throw new Error(`Unknown operation: ${operation}. Use brief_discover_capabilities to see available operations.`); } return { operation, target, ...plan, context_provided: context || null }; } }, brief_prepare_context: { description: "Fetch context, search documents, browse folders, or retrieve content. For destructive operation previews, use brief_execute_operation with preview: true in parameters.", inputSchema: briefPrepareContextSchema, inputJsonSchema: zodToJsonSchema(briefPrepareContextSchema, "brief_prepare_context"), execute: async (args) => { // Support both flat args and { parameters } wrapper from client JSON schema // For prepare_context, parameters come in args.parameters const params = args.parameters || args; const preparation_type = args.preparation_type || params.preparation_type; switch (preparation_type) { case 'get_context': { if (!params.keys || !Array.isArray(params.keys) || params.keys.length === 0) { throw new Error('keys parameter is required for get_context preparation'); } return postJsonOrThrow("/api/v1/context", { keys: params.keys }, "context"); } case 'search': { if (!params.query) { throw new Error('query parameter is required for search preparation'); } const searchParams = new URLSearchParams(); searchParams.set('query', params.query); if (params.folder_id) searchParams.set('folder_id', params.folder_id); searchParams.set('limit', (params.limit || 10).toString()); const searchRes = await apiFetch(`/api/v1/search?${searchParams}`, { method: "GET" }); if (!searchRes.ok) { const msg = await searchRes.text().catch(() => ""); throw new Error(`search failed (${searchRes.status}): ${msg}`); } return await searchRes.json(); } case 'browse': { const browseParams = new URLSearchParams(); if (params.folder_id) browseParams.set('folder_id', params.folder_id); if (params.include_subfolders) browseParams.set('include_subfolders', 'true'); browseParams.set('limit', (params.limit || 20).toString()); if (params.path) browseParams.set('path', params.path); if (typeof params.depth !== 'undefined') browseParams.set('depth', String(params.depth)); const browseRes = await apiFetch(`/api/v1/browse?${browseParams}`, { method: "GET" }); if (!browseRes.ok) { const msg = await browseRes.text().catch(() => ""); throw new Error(`browse failed (${browseRes.status}): ${msg}`); } return await browseRes.json(); } case 'find_related': { if (!params.document_id) { throw new Error('document_id parameter is required for find_related preparation'); } return postJsonOrThrow("/api/context/find-related", { document_id: params.document_id, limit: params.limit || 5 }, "find_related"); } case 'get_recent': { const recentParams = new URLSearchParams(); if (params.folder_id) recentParams.set('folder_id', params.folder_id); recentParams.set('limit', (params.limit || 10).toString()); const recentRes = await apiFetch(`/api/context/recent?${recentParams}`, { method: "GET" }); if (!recentRes.ok) { const msg = await recentRes.text().catch(() => ""); throw new Error(`get_recent failed (${recentRes.status}): ${msg}`); } return await recentRes.json(); } case 'retrieve_content': { const retrievePayload = { max_tokens: params.max_tokens || 2000, mode: params.mode || 'smart_subset' }; if (params.document_ids && params.document_ids.length > 0) { retrievePayload.document_ids = params.document_ids; } if (params.folder_id) { retrievePayload.folder_id = params.folder_id; } if (params.query) { retrievePayload.query = params.query; } return postJsonOrThrow("/api/v1/retrieve", retrievePayload, "retrieve"); } default: throw new Error(`Unknown preparation_type: ${preparation_type}`); } } }, brief_execute_operation: { description: "Execute Brief operations with full v1 API coverage. CRITICAL: Follow @brief-guidelines.md confirmation workflow - For destructive operations (delete, move, bulk), MUST get user permission before using confirmation token. Safe operations (create, read, update) can be executed directly. IMPORTANT: confirmation_token is a TOP-LEVEL parameter alongside 'operation' and 'parameters', NOT nested inside parameters!", inputSchema: briefExecuteOperationSchema, inputJsonSchema: zodToJsonSchema(briefExecuteOperationSchema, "brief_execute_operation"), execute: async (args) => { const { operation, parameters, confirmation_token, limit } = args; const params = parameters; // If using a confirmation token, wait to ensure minimum time has passed // Brief enforces a 5-second minimum wait to prevent auto-confirmation if (confirmation_token) { await new Promise(resolve => setTimeout(resolve, 5500)); // Wait 5.5 seconds } // Route operations to appropriate endpoints switch (operation) { // Document operations case 'create_document': { if (!params.title) throw new Error('title is required for create_document'); // Use My Documents folder if not specified // Fallback chain: provided ID -> env variable -> hardcoded default (for backwards compatibility) const folder_id = params.folder_id || process.env.DEFAULT_MY_DOCUMENTS_FOLDER_ID || "e9a09872-d078-4ba3-b229-f7626d7978e8"; const result = await postJsonOrThrow("/api/v1/documents", { title: params.title, content: params.content || '', folder_id: folder_id }, "create_document"); // Add folder context to response const resultObj = result; if (params.folder_id) { return { ...resultObj, folder_placement: "Document created in specified folder" }; } else { return { ...resultObj, folder_placement: "Document created in My Documents folder (default)" }; } } case 'get_document': { if (!params.id) throw new Error('id is required for get_document'); const getDocRes = await apiFetch(`/api/v1/documents/${params.id}`, { method: "GET" }); if (!getDocRes.ok) { const msg = await getDocRes.text().catch(() => ""); throw new Error(`get_document failed (${getDocRes.status}): ${msg}`); } return await getDocRes.json(); } case 'update_document': { if (!params.id) throw new Error('Document ID is required for updates. Find the ID using get_folder_tree or search first.'); // Build update payload - only include fields that are being changed const updatePayload = {}; if (params.title !== undefined) updatePayload.title = params.title; if (params.content !== undefined) updatePayload.content = params.content; if (params.folder_id !== undefined) updatePayload.folder_id = params.folder_id; // Don't add preview to body - it goes in query string if (confirmation_token) updatePayload.token = confirmation_token; const updateUrl = `/api/v1/documents/${params.id}/update${params.preview ? '?preview=true' : ''}`; const updRes = await apiFetch(updateUrl, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(updatePayload) }); if (!updRes.ok) { const msg = await updRes.text().catch(() => ""); throw new Error(`update_document failed (${updRes.status}): ${msg}`); } return await updRes.json(); } case 'delete_document': { if (!params.id) throw new Error('id is required for delete_document'); // Check for common mistake: token in params instead of top-level if (params.token && !confirmation_token) { throw new Error('IMPORTANT: confirmation_token must be a TOP-LEVEL parameter alongside "operation" and "parameters", NOT inside parameters. Example: {"operation": "delete_document", "parameters": {"id": "..."}, "confirmation_token": "confirm_..."}'); } const deleteUrl = `/api/v1/documents/${params.id}/delete${params.preview ? '?preview=true' : ''}`; const deleteRes = await apiFetch(deleteUrl, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(confirmation_token ? { token: confirmation_token } : {}) }); if (!deleteRes.ok) { const msg = await deleteRes.text().catch(() => ""); throw new Error(`delete_document failed (${deleteRes.status}): ${msg}`); } // Handle 204 No Content or empty response if (deleteRes.status === 204 || deleteRes.headers.get('content-length') === '0') { return { success: true, message: 'Document deleted' }; } return await deleteRes.json(); } case 'move_document': { if (!params.id || !params.folder_id) throw new Error('id and folder_id are required for move_document'); const movePayload = { folder_id: params.folder_id }; if (params.preview) movePayload.preview = true; if (confirmation_token) movePayload.token = confirmation_token; const moveUrl = `/api/v1/documents/${params.id}/move${params.preview ? '?preview=true' : ''}`; return postJsonOrThrow(moveUrl, movePayload, "move_document"); } // Folder operations case 'create_folder': { if (!params.name) throw new Error('name is required for create_folder'); if (!params.parent_id) { throw new Error('parent_id is required - folders must be created within one of the system folders (Projects, Planning, User Research, or My Documents) or their subfolders. There is no "root level" in Brief.'); } return postJsonOrThrow("/api/v1/folders", { name: params.name, parent_id: params.parent_id, is_private: params.is_private }, "create_folder"); } case 'get_folder': { if (!params.id) throw new Error('id is required for get_folder'); const getFolderRes = await apiFetch(`/api/v1/folders/${params.id}`, { method: "GET" }); if (!getFolderRes.ok) { const msg = await getFolderRes.text().catch(() => ""); throw new Error(`get_folder failed (${getFolderRes.status}): ${msg}`); } return await getFolderRes.json(); } case 'update_folder': { if (!params.id) throw new Error('id is required for update_folder'); const updateFolderPayload = {}; if (params.name !== undefined) updateFolderPayload.name = params.name; if (params.is_private !== undefined) updateFolderPayload.is_private = params.is_private; const updateFolderRes = await apiFetch(`/api/v1/folders/${params.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updateFolderPayload) }); if (!updateFolderRes.ok) { const msg = await updateFolderRes.text().catch(() => ""); throw new Error(`update_folder failed (${updateFolderRes.status}): ${msg}`); } return await updateFolderRes.json(); } case 'delete_folder': { if (!params.id) throw new Error('id is required for delete_folder'); // Check for common mistake: token in params instead of top-level if (params.token && !confirmation_token) { throw new Error('IMPORTANT: confirmation_token must be a TOP-LEVEL parameter alongside "operation" and "parameters", NOT inside parameters. Example: {"operation": "delete_folder", "parameters": {"id": "..."}, "confirmation_token": "confirm_..."}'); } const deleteFolderUrl = `/api/v1/folders/${params.id}/delete${params.preview ? '?preview=true' : ''}`; const dfInit = { method: "DELETE", headers: { "content-type": "application/json" } }; // Always send a small JSON body; preview ignores token dfInit.body = JSON.stringify(confirmation_token ? { token: confirmation_token } : {}); const dfRes = await apiFetch(deleteFolderUrl, dfInit); if (!dfRes.ok) { const msg = await dfRes.text().catch(() => ""); throw new Error(`delete_folder failed (${dfRes.status}): ${msg}`); } // Handle 204 No Content or empty response if (dfRes.status === 204 || dfRes.headers.get('content-length') === '0') { return { success: true, message: 'Folder deleted' }; } return await dfRes.json(); } case 'get_folder_tree': { const treeParams = new URLSearchParams(); if (params.path) treeParams.set('path', params.path); if (params.depth) treeParams.set('depth', params.depth.toString()); if (params.include_documents) treeParams.set('include_documents', 'true'); const treeRes = await apiFetch(`/api/v1/folders/tree?${treeParams}`, { method: "GET" }); if (!treeRes.ok) { const msg = await treeRes.text().catch(() => ""); throw new Error(`get_folder_tree failed (${treeRes.status}): ${msg}`); } return await treeRes.json(); } case 'get_folder_documents': { if (!params.folder_id) throw new Error('folder_id is required for get_folder_documents'); const folderDocsParams = new URLSearchParams(); if (params.limit) folderDocsParams.set('limit', params.limit.toString()); const folderDocsRes = await apiFetch(`/api/v1/folders/${params.folder_id}/documents?${folderDocsParams}`, { method: "GET" }); if (!folderDocsRes.ok) { const msg = await folderDocsRes.text().catch(() => ""); throw new Error(`get_folder_documents failed (${folderDocsRes.status}): ${msg}`); } return await folderDocsRes.json(); } // Bulk operations case 'bulk_move': { if (!params.items || !params.folder_id) throw new Error('items and folder_id are required for bulk_move'); // Check for common mistake: token in params instead of top-level if (params.token && !confirmation_token) { throw new Error('IMPORTANT: confirmation_token must be a TOP-LEVEL parameter alongside "operation" and "parameters", NOT inside parameters.'); } const bulkMovePayload = { items: params.items, folder_id: params.folder_id }; // Don't add preview to body - it goes in query string if (confirmation_token) bulkMovePayload.token = confirmation_token; const bulkMoveUrl = `/api/v1/bulk/move${params.preview ? '?preview=true' : ''}`; return postJsonOrThrow(bulkMoveUrl, bulkMovePayload, "bulk_move"); } case 'bulk_delete': { if (!params.items) throw new Error('items are required for bulk_delete'); // Check for common mistake: token in params instead of top-level if (params.token && !confirmation_token) { throw new Error('IMPORTANT: confirmation_token must be a TOP-LEVEL parameter alongside "operation" and "parameters", NOT inside parameters.'); } const bulkDeletePayload = { items: params.items }; // Don't add preview to body - it goes in query string if (confirmation_token) bulkDeletePayload.token = confirmation_token; const bulkDeleteUrl = `/api/v1/bulk/delete${params.preview ? '?preview=true' : ''}`; return postJsonOrThrow(bulkDeleteUrl, bulkDeletePayload, "bulk_delete"); } // Product graph case 'get_product_graph': { const graphRes = await apiFetch("/api/v1/product-graph", { method: "GET" }); if (!graphRes.ok) { const msg = await graphRes.text().catch(() => ""); throw new Error(`get_product_graph failed (${graphRes.status}): ${msg}`); } return await graphRes.json(); } case 'move_folder': { if (!params.id || !params.parent_id) throw new Error('id and parent_id are required for move_folder'); // Check for common mistake: token in params instead of top-level if (params.token && !confirmation_token) { throw new Error('IMPORTANT: confirmation_token must be a TOP-LEVEL parameter alongside "operation" and "parameters", NOT inside parameters.'); } const moveFolderUrl = `/api/v1/folders/${params.id}/move${params.preview ? '?preview=true' : ''}`; const moveFolderPayload = { parent_id: params.parent_id }; if (confirmation_token) moveFolderPayload.token = confirmation_token; return postJsonOrThrow(moveFolderUrl, moveFolderPayload, "move_folder"); } case 'update_product_graph': { if (!params.config) throw new Error('config is required for update_product_graph'); const updateGraphPayload = { ...params.config }; // Don't add preview to config - it goes in query string if (confirmation_token) updateGraphPayload.token = confirmation_token; const updateGraphUrl = `/api/v1/product-graph${params.preview ? '?preview=true' : ''}`; const graphUpdateRes = await apiFetch(updateGraphUrl, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updateGraphPayload) }); if (!graphUpdateRes.ok) { const msg = await graphUpdateRes.text().catch(() => ""); throw new Error(`update_product_graph failed (${graphUpdateRes.status}): ${msg}`); } return await graphUpdateRes.json(); } // Decision operations - delegate to existing decision tools for now case 'record_decision': { if (!params.decision || !params.rationale) throw new Error('decision and rationale are required for record_decision'); return postJsonOrThrow("/api/v1/decisions/record", { decision: params.decision, rationale: params.rationale, category: params.category, severity: params.severity, scope: params.scope, author: params.author, supersedes: params.supersedes }, "record_decision"); } case 'confirm_decision': { if (!params.id) throw new Error('id is required for confirm_decision'); return postJsonOrThrow("/api/v1/decisions/confirm", { id: params.id }, "confirm_decision"); } case 'search_decisions': { // Search existing decisions const searchDecParams = new URLSearchParams(); if (params.query) searchDecParams.set('query', params.query); if (params.limit) searchDecParams.set('limit', params.limit.toString()); const searchDecRes = await apiFetch(`/api/v1/decisions?${searchDecParams}`, { method: "GET" }); if (!searchDecRes.ok) { const msg = await searchDecRes.text().catch(() => ""); throw new Error(`search_decisions failed (${searchDecRes.status}): ${msg}`); } return await searchDecRes.json(); }