You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

524 lines
17 KiB

#!/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);
});

Powered by TurnKey Linux.