#!/usr/bin/env node /** * Second Brain MCP Server * * MCP server to interact with the Second Brain RAG knowledge management system. * Provides tools for adding logs, uploading documents, searching, and chatting. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; // Configuration - can be overridden via environment variables const API_BASE_URL = process.env.SECOND_BRAIN_API_URL || "https://2brain.coer.nl/api"; const API_USERNAME = process.env.SECOND_BRAIN_USERNAME || ""; const API_PASSWORD = process.env.SECOND_BRAIN_PASSWORD || ""; // Build auth header if credentials provided function getAuthHeader() { if (API_USERNAME && API_PASSWORD) { const credentials = Buffer.from(`${API_USERNAME}:${API_PASSWORD}`).toString("base64"); return { Authorization: `Basic ${credentials}` }; } return {}; } // Helper to make API requests async function apiRequest(endpoint, options = {}) { const url = `${API_BASE_URL}${endpoint}`; const headers = { ...getAuthHeader(), ...options.headers, }; const response = await fetch(url, { ...options, headers, }); return response; } // Define available tools const TOOLS = [ { name: "add_log", description: "Add a quick log entry or note to the Second Brain. Entries are timestamped and stored in daily log files.", inputSchema: { type: "object", properties: { content: { type: "string", description: "The content of the log entry (markdown supported)", }, title: { type: "string", description: "Optional title for the log entry", }, tags: { type: "array", items: { type: "string" }, description: "Optional tags for categorization (without # prefix)", }, }, required: ["content"], }, }, { name: "upload_document", description: "Upload a document file to the Second Brain vault. Supports .md, .txt, .pdf, .json, .yaml, .csv files.", inputSchema: { type: "object", properties: { file_path: { type: "string", description: "Path to the file to upload", }, folder: { type: "string", description: "Target folder in vault (default: uploads)", }, tags: { type: "string", description: "Comma-separated tags for the document", }, }, required: ["file_path"], }, }, { name: "add_document", description: "Create a new markdown document in the Second Brain vault directly from content.", inputSchema: { type: "object", properties: { content: { type: "string", description: "The markdown content of the document", }, filename: { type: "string", description: "Filename for the document (with .md extension)", }, folder: { type: "string", description: "Target folder in vault (default: documents)", }, tags: { type: "string", description: "Comma-separated tags for the document", }, }, required: ["content", "filename"], }, }, { name: "search", description: "Search the Second Brain vault using semantic search. Returns relevant document chunks.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query", }, top_k: { type: "number", description: "Number of results to return (default: 5)", }, }, required: ["query"], }, }, { name: "chat", description: "Chat with the Second Brain AI. Uses RAG to answer questions based on your knowledge base.", inputSchema: { type: "object", properties: { message: { type: "string", description: "Your question or message", }, }, required: ["message"], }, }, { name: "list_logs", description: "List recent log entries from the Second Brain.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Number of logs to return (default: 10)", }, }, }, }, { name: "get_log", description: "Get the contents of a specific daily log file.", inputSchema: { type: "object", properties: { date: { type: "string", description: "Date in YYYY-MM-DD format", }, }, required: ["date"], }, }, { name: "list_documents", description: "List documents in the Second Brain vault.", inputSchema: { type: "object", properties: { folder: { type: "string", description: "Filter by folder", }, limit: { type: "number", description: "Number of documents to return (default: 20)", }, }, }, }, { name: "get_stats", description: "Get statistics about the Second Brain knowledge base.", inputSchema: { type: "object", properties: {}, }, }, { name: "reindex", description: "Trigger a full reindex of the vault. Use after bulk changes.", inputSchema: { type: "object", properties: {}, }, }, ]; // Tool implementations async function handleAddLog(args) { const response = await apiRequest("/v1/capture/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: args.content, title: args.title, tags: args.tags, }), }); const data = await response.json(); if (!response.ok) { throw new Error(`Failed to add log: ${data.detail || response.statusText}`); } return `✅ Log entry added!\n- File: ${data.file_path}\n- Timestamp: ${data.timestamp}`; } async function handleUploadDocument(args) { const filePath = path.resolve(args.file_path); if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const filename = path.basename(filePath); const fileContent = fs.readFileSync(filePath); const blob = new Blob([fileContent]); const formData = new FormData(); formData.append("file", blob, filename); if (args.folder) formData.append("folder", args.folder); if (args.tags) formData.append("tags", args.tags); const response = await apiRequest("/v1/capture/upload", { method: "POST", body: formData, }); const data = await response.json(); if (!response.ok) { throw new Error(`Failed to upload: ${data.detail || response.statusText}`); } return `✅ Document uploaded!\n- Path: ${data.file_path}\n- Size: ${data.size_bytes} bytes`; } async function handleAddDocument(args) { // Create a temporary file and upload it const tempDir = process.env.TMPDIR || "/tmp"; const tempPath = path.join(tempDir, args.filename); let content = args.content; // Add frontmatter if tags provided if (args.tags) { const tagList = args.tags.split(",").map((t) => t.trim()); const frontmatter = `---\ntags: [${tagList.join(", ")}]\ncreated: ${new Date().toISOString()}\n---\n\n`; content = frontmatter + content; } fs.writeFileSync(tempPath, content, "utf-8"); try { const result = await handleUploadDocument({ file_path: tempPath, folder: args.folder || "documents", }); return result; } finally { fs.unlinkSync(tempPath); } } async function handleSearch(args) { const params = new URLSearchParams({ q: args.query, top_k: String(args.top_k || 5), }); const response = await apiRequest(`/v1/search?${params}`); const data = await response.json(); if (!response.ok) { throw new Error(`Search failed: ${data.detail || response.statusText}`); } if (!data.results || data.results.length === 0) { return "No results found."; } let output = `Found ${data.results.length} results:\n\n`; for (const result of data.results) { output += `### ${result.title || result.path}\n`; output += `Score: ${(result.score * 100).toFixed(1)}%\n`; output += `${result.content?.substring(0, 200)}...\n\n`; } return output; } async function handleChat(args) { const response = await apiRequest("/v1/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: args.message }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(`Chat failed: ${errorData.detail || response.statusText}`); } // Handle SSE stream const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } let fullResponse = ""; let sources = []; const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); const lines = text.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { try { const data = JSON.parse(line.substring(6)); if (data.type === "token") { fullResponse += data.token; } else if (data.type === "sources" && data.sources) { sources = data.sources.map((s) => `- ${s.title || s.path}`); } } catch { // Ignore parse errors } } } } let output = fullResponse; if (sources.length > 0) { output += "\n\n**Sources:**\n" + sources.join("\n"); } return output; } async function handleListLogs(args) { const params = new URLSearchParams({ limit: String(args.limit || 10), }); const response = await apiRequest(`/v1/capture/logs?${params}`); const data = await response.json(); if (!response.ok) { throw new Error(`Failed to list logs: ${data.detail || response.statusText}`); } if (!data.logs || data.logs.length === 0) { return "No logs found."; } let output = `📝 Recent logs (${data.total}):\n\n`; for (const log of data.logs) { output += `- **${log.date}** (${log.size_bytes} bytes)\n`; } return output; } async function handleGetLog(args) { const response = await apiRequest(`/v1/capture/log/${args.date}`); const data = await response.json(); if (!response.ok) { throw new Error(`Failed to get log: ${data.detail || response.statusText}`); } return `# Log for ${args.date}\n\n${data.content}`; } async function handleListDocuments(args) { const params = new URLSearchParams({ limit: String(args.limit || 20), }); if (args.folder) params.append("folder", args.folder); const response = await apiRequest(`/v1/documents?${params}`); const data = await response.json(); if (!response.ok) { throw new Error(`Failed to list documents: ${data.detail || response.statusText}`); } if (!data.documents || data.documents.length === 0) { return "No documents found."; } let output = `📚 Documents (${data.total}):\n\n`; for (const doc of data.documents) { output += `- **${doc.title || doc.path}**`; if (doc.tags?.length) output += ` [${doc.tags.join(", ")}]`; output += "\n"; } return output; } async function handleGetStats() { const response = await apiRequest("/v1/stats"); const data = await response.json(); if (!response.ok) { throw new Error(`Failed to get stats: ${data.detail || response.statusText}`); } return `📊 Second Brain Stats: - Documents: ${data.document_count || 0} - Chunks: ${data.chunk_count || 0} - Vault size: ${data.vault_size_mb?.toFixed(2) || 0} MB - Last indexed: ${data.last_indexed || "Never"}`; } async function handleReindex() { const response = await apiRequest("/v1/index/reindex", { method: "POST", }); const data = await response.json(); if (!response.ok) { throw new Error(`Reindex failed: ${data.detail || response.statusText}`); } return `🔄 Reindex triggered!\n- Status: ${data.status || "started"}`; } // Create and configure the MCP server const server = new Server({ name: "second-brain-mcp", version: "1.0.0", }, { capabilities: { tools: {}, resources: {}, }, }); // Handle tool listing server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { let result; switch (name) { case "add_log": result = await handleAddLog(args); break; case "upload_document": result = await handleUploadDocument(args); break; case "add_document": result = await handleAddDocument(args); break; case "search": result = await handleSearch(args); break; case "chat": result = await handleChat(args); break; case "list_logs": result = await handleListLogs(args); break; case "get_log": result = await handleGetLog(args); break; case "list_documents": result = await handleListDocuments(args); break; case "get_stats": result = await handleGetStats(); break; case "reindex": result = await handleReindex(); break; default: throw new Error(`Unknown tool: ${name}`); } return { content: [{ type: "text", text: result }], }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, }; } }); // Handle resource listing (expose recent logs as resources) server.setRequestHandler(ListResourcesRequestSchema, async () => { try { const response = await apiRequest("/v1/capture/logs?limit=5"); const data = await response.json(); if (!response.ok || !data.logs) { return { resources: [] }; } return { resources: data.logs.map((log) => ({ uri: `secondbrain://logs/${log.date}`, name: `Log: ${log.date}`, mimeType: "text/markdown", })), }; } catch { return { resources: [] }; } }); // Handle resource reading server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; if (uri.startsWith("secondbrain://logs/")) { const date = uri.replace("secondbrain://logs/", ""); const response = await apiRequest(`/v1/capture/log/${date}`); const data = await response.json(); if (!response.ok) { throw new Error(`Log not found: ${date}`); } return { contents: [ { uri, mimeType: "text/markdown", text: data.content, }, ], }; } throw new Error(`Unknown resource: ${uri}`); }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Second Brain MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });