UNPKG

@briefhq/mcp-server

Version:

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

484 lines (483 loc) 24 kB
import { z } from "zod"; import { apiFetch } from "../lib/supabase.js"; import { zodToJsonSchema } from "zod-to-json-schema"; import { DECISION_ID_REGEX } from "../lib/decision-ids.js"; import { layeredTools } from "./layered.js"; // Minimal placeholder tools; real logic will call /api/v1 endpoints or Supabase directly const briefDiscoverContextSchema = z.object({ task: z.string().optional(), cold_start: z.boolean().optional(), }); const briefPlanContextSchema = z.object({ task: z.string().optional(), categories: z.array(z.any()).optional(), cold_start: z.boolean().optional(), }); const briefGetContextSchema = z.object({ keys: z.array(z.string()).optional(), }); const briefGuardApproachSchema = z.object({ approach: z.string(), }); const briefRecordDecisionSchema = z.object({ topic: z.string().optional(), decision: z.string(), rationale: z.string(), category: z.enum(["tech", "product", "design", "process", "general"]).optional(), severity: z.enum(["info", "important", "blocking"]).optional(), scope: z.enum(["permanent", "temporary", "session"]).optional(), author: z .object({ id: z.string().optional(), name: z.string().optional(), email: z.string().optional() }) .optional(), supersedes: z.array(z.string()).optional(), }); const briefConfirmDecisionSchema = z.object({ id: z.string(), }); const briefArchiveDecisionSchema = z.object({ id: z.string().regex(DECISION_ID_REGEX), reason: z.string().optional(), }); // Session decision schemas const briefCreateSessionDecisionSchema = z.object({ session_id: z.string(), decision: z.string(), rationale: z.string(), context: z.record(z.unknown()).optional(), expires_in_minutes: z.number().int().min(1).max(1440).default(60), // 1 minute to 24 hours }); const briefGetSessionDecisionsSchema = z.object({ session_id: z.string().optional(), }); const briefDeleteSessionDecisionSchema = z.object({ session_id: z.string(), }); // Decision proposal schemas const briefProposeDecisionFromContextSchema = z.object({ detected_text: z.string().min(1).describe("The text that suggests a decision"), rationale: z.string().optional().describe("Extracted reasoning for the decision"), source_context: z.string().optional().describe("Surrounding context from document"), confidence: z.number().min(0).max(1).default(0.7).describe("AI confidence score 0.0-1.0"), category: z.enum(["tech", "product", "design", "process", "general"]).optional().describe("Suggested decision category"), severity: z.enum(["info", "important", "blocking"]).optional().describe("Suggested decision severity"), confirm: z.boolean().default(false).describe("Whether to auto-confirm (requires high confidence)") }); const briefBatchProposeDecisionsSchema = z.object({ documents: z.array(z.object({ id: z.string().optional().describe("Document ID if known"), content: z.string().min(1).describe("Document content to analyze"), title: z.string().optional().describe("Document title"), source_url: z.string().optional().describe("Source URL or path") })).min(1).max(10).describe("Documents to analyze for decisions (1-10 max)"), min_confidence: z.number().min(0).max(1).default(0.6).describe("Minimum confidence threshold for proposals") }); const briefGetDecisionProposalsSchema = z.object({ status: z.enum(["pending", "confirmed", "rejected"]).optional().describe("Filter by proposal status"), limit: z.number().int().min(1).max(50).default(10).describe("Maximum proposals to return"), min_confidence: z.number().min(0).max(1).optional().describe("Minimum confidence threshold") }); const briefConfirmDecisionProposalSchema = z.object({ proposal_id: z.string().min(1).describe("ID of the proposal to confirm"), edits: z.object({ decision: z.string().optional().describe("Override proposed decision text"), rationale: z.string().optional().describe("Override proposed rationale"), category: z.enum(["tech", "product", "design", "process", "general"]).optional(), severity: z.enum(["info", "important", "blocking"]).optional(), topic: z.string().optional().describe("Override proposed topic") }).optional().describe("Optional edits to apply before confirming") }); // Document access tool schemas const briefSearchDocumentsSchema = z.object({ query: z.string().min(1).refine(val => val.trim().length > 0, { message: "Query cannot be empty or whitespace-only" }), // Non-empty, non-whitespace string folder_id: z.string().optional(), limit: z.number().int().min(1).max(100).default(10), }); const briefBrowseContextSchema = z.object({ folder_id: z.string().optional(), limit: z.number().int().min(1).max(100).default(20), include_subfolders: z.boolean().optional(), }); const briefRetrieveContentSchema = z.object({ query: z.string().min(1).refine(val => val.trim().length > 0, { message: "Query cannot be empty or whitespace-only" }).optional(), // Non-empty, non-whitespace string document_ids: z.array(z.string()).min(1).optional(), folder_id: z.string().optional(), max_tokens: z.number().int().min(1).max(8000).default(2000), mode: z.enum(["smart_subset", "summaries"]).default("smart_subset"), }).refine(obj => !!obj.query || (obj.document_ids && obj.document_ids.length > 0) || !!obj.folder_id, { message: "Must provide at least one of query, document_ids or folder_id" }); const briefFindRelatedSchema = z.object({ document_id: z.string().min(1).optional(), query: z.string().min(1).refine(val => val.trim().length > 0, { message: "Query cannot be empty or whitespace-only" }).optional(), // Non-empty, non-whitespace string limit: z.number().int().min(1).max(50).default(5), }).refine(obj => !!obj.document_id || !!obj.query, { message: "Must provide at least one of document_id or query" }); const briefGetRecentSchema = z.object({ limit: z.number().int().min(1).max(100).default(10), folder_id: z.string().optional(), }); // Ensure layeredTools is typed properly to avoid conflicts const verifiedLayeredTools = layeredTools; // Check for conflicts at runtime (only in development) if (process.env.NODE_ENV !== 'production') { const layeredKeys = Object.keys(verifiedLayeredTools); const legacyKeys = ['brief_discover_context', 'brief_plan_context', 'brief_execute_context', 'brief_write_document', 'brief_update_document', 'brief_delete_document', 'brief_move_document', 'brief_create_folder', 'brief_delete_folder', 'brief_get_folder_tree', 'brief_bulk_move', 'brief_bulk_delete', 'brief_product_graph', 'brief_update_product_graph', 'brief_record_decision', 'brief_confirm_decision', 'brief_search_decisions', 'brief_guard_approach', 'brief_create_session_decision', 'brief_get_session_decisions', 'brief_delete_session_decision', 'brief_propose_decision', 'brief_batch_propose_decisions', 'brief_get_decision_proposals', 'brief_confirm_decision_proposal']; const conflicts = layeredKeys.filter(key => legacyKeys.includes(key)); if (conflicts.length > 0) { console.warn('Warning: Tool key conflicts detected:', conflicts); } } export const tools = { // New layered architecture tools (4 tools replace 23) ...verifiedLayeredTools, // Legacy tools (deprecated - will be removed after migration) brief_discover_context: { description: "[DEPRECATED] Use brief_discover_capabilities instead. Discover available context or cold-start summary", inputSchema: briefDiscoverContextSchema, inputJsonSchema: zodToJsonSchema(briefDiscoverContextSchema, "brief_discover_context"), execute: async (args) => { return postJsonOrThrow("/api/v1/discover", { task: args.task, cold_start: args.cold_start }, "discover"); }, }, brief_plan_context: { description: "Plan which context to fetch", inputSchema: briefPlanContextSchema, inputJsonSchema: zodToJsonSchema(briefPlanContextSchema, "brief_plan_context"), execute: async (args) => { return postJsonOrThrow("/api/v1/plan", args, "plan"); }, }, brief_get_context: { description: "Fetch context by keys", inputSchema: briefGetContextSchema, inputJsonSchema: zodToJsonSchema(briefGetContextSchema, "brief_get_context"), execute: async (args) => { return postJsonOrThrow("/api/v1/context", { keys: args.keys }, "context"); }, }, brief_guard_approach: { description: "Check for conflicts against decisions", inputSchema: briefGuardApproachSchema, inputJsonSchema: zodToJsonSchema(briefGuardApproachSchema, "brief_guard_approach"), execute: async (args) => { return postJsonOrThrow("/api/v1/conflicts", { approach: args.approach }, "conflicts"); }, }, brief_record_decision: { description: "Record a decision", inputSchema: briefRecordDecisionSchema, inputJsonSchema: zodToJsonSchema(briefRecordDecisionSchema, "brief_record_decision"), execute: async (args) => { // Handle session decisions separately - they use ephemeral storage if (args.scope === "session") { return postJsonOrThrow("/api/v1/decisions/session", { session_id: `mcp-${Date.now()}-${Math.random().toString(36).substring(2)}`, decision: args.decision, rationale: args.rationale, context: { topic: args.topic, category: args.category, severity: args.severity, author: args.author, supersedes: args.supersedes }, expires_in_minutes: 60 // Default 1 hour TTL for MCP session decisions }, "session_record"); } // For permanent/temporary decisions, use the regular endpoint return postJsonOrThrow("/api/v1/decisions/record", args, "record"); }, }, brief_confirm_decision: { description: "Confirm a decision", inputSchema: briefConfirmDecisionSchema, inputJsonSchema: zodToJsonSchema(briefConfirmDecisionSchema, "brief_confirm_decision"), execute: async (args) => { return postJsonOrThrow("/api/v1/decisions/confirm", args, "confirm"); }, }, brief_archive_decision: { description: "Archive a decision (hide from active use but preserve for audit)", inputSchema: briefArchiveDecisionSchema, inputJsonSchema: zodToJsonSchema(briefArchiveDecisionSchema, "brief_archive_decision"), execute: async (args) => { return postJsonOrThrow("/api/v1/decisions/archive", args, "archive"); }, }, brief_search_documents: { description: "Search documents by content, title, or metadata", inputSchema: briefSearchDocumentsSchema, inputJsonSchema: zodToJsonSchema(briefSearchDocumentsSchema, "brief_search_documents"), execute: async (args) => { const parsed = briefSearchDocumentsSchema.parse(args); const params = new URLSearchParams(); params.set('query', parsed.query); if (parsed.folder_id) params.set('folder_id', parsed.folder_id); params.set('limit', parsed.limit.toString()); const res = await apiFetch(`/api/v1/search?${params}`, { method: "GET" }); 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(`search failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } const response = await res.json(); // Transform response to MCP format if (response && typeof response === 'object' && 'results' in response) { const typedResponse = response; if (Array.isArray(typedResponse.results)) { typedResponse.results = typedResponse.results.map((item) => ({ ...item, content: [{ type: "text", text: item.snippet || item.plaintext || item.title || "" }] })); } } return response; }, }, brief_browse_context: { description: "Browse documents and folders in the organization", inputSchema: briefBrowseContextSchema, inputJsonSchema: zodToJsonSchema(briefBrowseContextSchema, "brief_browse_context"), execute: async (args) => { const parsed = briefBrowseContextSchema.parse(args); const params = new URLSearchParams(); if (parsed.folder_id) params.set('folder_id', parsed.folder_id); params.set('limit', parsed.limit.toString()); if (parsed.include_subfolders) params.set('include_subfolders', 'true'); const res = await apiFetch(`/api/v1/browse?${params}`, { method: "GET" }); 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(`browse failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } return await res.json(); }, }, brief_retrieve_content: { description: "Retrieve full document content with smart token budgeting", inputSchema: briefRetrieveContentSchema, inputJsonSchema: zodToJsonSchema(briefRetrieveContentSchema, "brief_retrieve_content"), execute: async (args) => { const parsed = briefRetrieveContentSchema.parse(args); // Use the v1/retrieve endpoint const response = await postJsonOrThrow("/api/v1/retrieve", { query: parsed.query, document_ids: parsed.document_ids?.length ? parsed.document_ids : undefined, folder_id: parsed.folder_id, maxTokens: parsed.max_tokens, mode: parsed.mode, }, "retrieve"); // Transform response to match expected MCP format const transformedResponse = { items: response.items?.map((item) => ({ ...item, content: [{ type: "text", text: (() => { // API returns full content in snippet field for direct retrieval const v = item.snippet ?? item.content ?? item.plaintext ?? item.title ?? ""; return typeof v === "string" ? v : JSON.stringify(v); })() }] })) || [], metadata: response.metadata || { totalTokens: 0, searchTime: 0, truncated: false, strategy: "unknown", dataSources: [] }, // Preserve top-level fields expected by tests token_estimate: response.token_estimate, mode: response.mode, expanded: response.expanded, warnings: response.warnings, requested_documents: response.requested_documents, accessible_documents: response.accessible_documents }; return transformedResponse; }, }, brief_find_related: { description: "Find documents related to given content", inputSchema: briefFindRelatedSchema, inputJsonSchema: zodToJsonSchema(briefFindRelatedSchema, "brief_find_related"), execute: async (args) => { const parsed = briefFindRelatedSchema.parse(args); return postJsonOrThrow("/api/context/find-related", { document_id: parsed.document_id, query: parsed.query, limit: parsed.limit }, "find-related"); }, }, brief_get_recent: { description: "Get recently accessed or updated documents", inputSchema: briefGetRecentSchema, inputJsonSchema: zodToJsonSchema(briefGetRecentSchema, "brief_get_recent"), execute: async (args) => { const parsed = briefGetRecentSchema.parse(args); const params = new URLSearchParams(); params.set('limit', parsed.limit.toString()); if (parsed.folder_id) params.set('folder_id', parsed.folder_id); const res = await apiFetch(`/api/context/recent?${params}`, { method: "GET" }); 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(`recent failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } return await res.json(); }, }, // Session decision tools (ephemeral storage) brief_create_session_decision: { description: "Create an ephemeral session decision (not persisted permanently)", inputSchema: briefCreateSessionDecisionSchema, inputJsonSchema: zodToJsonSchema(briefCreateSessionDecisionSchema, "brief_create_session_decision"), execute: async (args) => { return postJsonOrThrow("/api/v1/decisions/session", args, "create_session_decision"); }, }, brief_get_session_decisions: { description: "Get ephemeral session decisions", inputSchema: briefGetSessionDecisionsSchema, inputJsonSchema: zodToJsonSchema(briefGetSessionDecisionsSchema, "brief_get_session_decisions"), execute: async (args) => { const params = new URLSearchParams(); if (args.session_id) params.set('session_id', args.session_id); const res = await apiFetch(`/api/v1/decisions/session?${params}`, { method: "GET" }); 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(`get_session_decisions failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } return await res.json(); }, }, brief_delete_session_decision: { description: "Delete an ephemeral session decision", inputSchema: briefDeleteSessionDecisionSchema, inputJsonSchema: zodToJsonSchema(briefDeleteSessionDecisionSchema, "brief_delete_session_decision"), execute: async (args) => { const params = new URLSearchParams(); params.set('session_id', args.session_id); const res = await apiFetch(`/api/v1/decisions/session?${params}`, { method: "DELETE" }); 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(`delete_session_decision failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } return await res.json(); }, }, // Decision proposal tools brief_propose_decision_from_context: { description: "Propose a decision based on current document/conversation context", inputSchema: briefProposeDecisionFromContextSchema, inputJsonSchema: zodToJsonSchema(briefProposeDecisionFromContextSchema, "brief_propose_decision_from_context"), execute: async (args) => { return postJsonOrThrow("/api/v1/decisions/propose", args, "propose_decision"); }, }, brief_batch_propose_decisions: { description: "Process multiple documents and propose decisions", inputSchema: briefBatchProposeDecisionsSchema, inputJsonSchema: zodToJsonSchema(briefBatchProposeDecisionsSchema, "brief_batch_propose_decisions"), execute: async (args) => { return postJsonOrThrow("/api/v1/decisions/batch-propose", args, "batch_propose_decisions"); }, }, brief_get_decision_proposals: { description: "Retrieve pending decision proposals for review", inputSchema: briefGetDecisionProposalsSchema, inputJsonSchema: zodToJsonSchema(briefGetDecisionProposalsSchema, "brief_get_decision_proposals"), execute: async (args) => { const params = new URLSearchParams(); if (args.status) params.set('status', args.status); if (args.min_confidence !== undefined) params.set('min_confidence', args.min_confidence.toString()); params.set('limit', (args.limit || 10).toString()); const res = await apiFetch(`/api/v1/decisions/proposals?${params}`, { method: "GET" }); 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(`get_decision_proposals failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } return await res.json(); }, }, brief_confirm_decision_proposal: { description: "Convert proposal to confirmed decision", inputSchema: briefConfirmDecisionProposalSchema, inputJsonSchema: zodToJsonSchema(briefConfirmDecisionProposalSchema, "brief_confirm_decision_proposal"), execute: async (args) => { return postJsonOrThrow("/api/v1/decisions/proposals/confirm", args, "confirm_decision_proposal"); }, }, }; 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) { // Guard against mocks that don't implement text() or headers.get const msg = typeof res.text === "function" ? await res.text().catch(() => "") : ""; const getHeader = (name) => { const headers = res.headers; if (!headers) return ""; try { return headers.get?.(name) ?? ""; } catch { return ""; } }; const reqId = getHeader("x-request-id") || getHeader("x-amzn-requestid") || ""; const suffix = reqId ? `, reqId=${reqId}` : ""; throw new Error(`${label} failed (${res.status} ${res.statusText}${suffix}): ${msg || "no body"}`); } // Prefer JSON; fall back to text if needed. Avoid clone() to keep mocks simple. try { if (typeof res.json === "function") { return (await res.json()); } } catch { // ignore and try text below } const text = typeof res.text === "function" ? await res.text().catch(() => "") : ""; return text; }