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
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);
|
|
});
|