@briefhq/mcp-server
Version:
Brief MCP server and CLI – connect Cursor/Claude MCP to your Brief organization
484 lines (483 loc) • 24 kB
JavaScript
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;
}