commit
9b8559b2ce
@ -0,0 +1,103 @@
|
||||
# Second Brain MCP Server
|
||||
|
||||
MCP server to interact with your Second Brain knowledge management system from any MCP-compatible client (Claude Desktop, VS Code, etc.).
|
||||
|
||||
## Features
|
||||
|
||||
- **add_log** - Add quick notes/log entries with timestamps
|
||||
- **add_document** - Create new markdown documents directly
|
||||
- **upload_document** - Upload files (.md, .txt, .pdf, .json, .yaml, .csv)
|
||||
- **search** - Semantic search across your knowledge base
|
||||
- **chat** - Chat with AI using RAG on your documents
|
||||
- **list_logs** / **get_log** - Browse daily logs
|
||||
- **list_documents** - Browse vault contents
|
||||
- **get_stats** - Knowledge base statistics
|
||||
- **reindex** - Trigger vault reindexing
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone or copy this folder to your local machine
|
||||
cd second-brain-mcp
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Second Brain API URL
|
||||
export SECOND_BRAIN_API_URL="https://2brain.coer.nl/api"
|
||||
|
||||
# Optional: Basic auth credentials (if using access list)
|
||||
export SECOND_BRAIN_USERNAME="your-username"
|
||||
export SECOND_BRAIN_PASSWORD="your-password"
|
||||
```
|
||||
|
||||
## Usage with Claude Desktop
|
||||
|
||||
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on Mac or `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"second-brain": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/second-brain-mcp/dist/index.js"],
|
||||
"env": {
|
||||
"SECOND_BRAIN_API_URL": "https://2brain.coer.nl/api",
|
||||
"SECOND_BRAIN_USERNAME": "your-username",
|
||||
"SECOND_BRAIN_PASSWORD": "your-password"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage with VS Code (Copilot)
|
||||
|
||||
Add to your VS Code settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"github.copilot.chat.mcpServers": {
|
||||
"second-brain": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/second-brain-mcp/dist/index.js"],
|
||||
"env": {
|
||||
"SECOND_BRAIN_API_URL": "https://2brain.coer.nl/api"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
Once configured, you can use natural language:
|
||||
|
||||
- "Add a note about the meeting with John today"
|
||||
- "Search my knowledge base for homelab documentation"
|
||||
- "What do I know about Proxmox configuration?"
|
||||
- "Upload this requirements.txt file to my second brain"
|
||||
- "Show me my recent logs"
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@ -0,0 +1,8 @@
|
||||
#!/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.
|
||||
*/
|
||||
export {};
|
||||
@ -0,0 +1,523 @@
|
||||
#!/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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "second-brain-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server to interact with Second Brain knowledge management system",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"keywords": ["mcp", "second-brain", "rag", "knowledge-management"],
|
||||
"author": "Mark",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,654 @@
|
||||
#!/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,
|
||||
Tool,
|
||||
} 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(): Record<string, string> {
|
||||
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: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
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: Tool[] = [
|
||||
{
|
||||
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: {
|
||||
content: string;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
}): Promise<string> {
|
||||
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: {
|
||||
file_path: string;
|
||||
folder?: string;
|
||||
tags?: string;
|
||||
}): Promise<string> {
|
||||
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: {
|
||||
content: string;
|
||||
filename: string;
|
||||
folder?: string;
|
||||
tags?: string;
|
||||
}): Promise<string> {
|
||||
// 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: {
|
||||
query: string;
|
||||
top_k?: number;
|
||||
}): Promise<string> {
|
||||
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: { message: string }): Promise<string> {
|
||||
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: string[] = [];
|
||||
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: { title: string; path: string }) =>
|
||||
`- ${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: { limit?: number }): Promise<string> {
|
||||
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: { date: string }): Promise<string> {
|
||||
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: {
|
||||
folder?: string;
|
||||
limit?: number;
|
||||
}): Promise<string> {
|
||||
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(): Promise<string> {
|
||||
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(): Promise<string> {
|
||||
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: string;
|
||||
|
||||
switch (name) {
|
||||
case "add_log":
|
||||
result = await handleAddLog(args as Parameters<typeof handleAddLog>[0]);
|
||||
break;
|
||||
case "upload_document":
|
||||
result = await handleUploadDocument(
|
||||
args as Parameters<typeof handleUploadDocument>[0]
|
||||
);
|
||||
break;
|
||||
case "add_document":
|
||||
result = await handleAddDocument(
|
||||
args as Parameters<typeof handleAddDocument>[0]
|
||||
);
|
||||
break;
|
||||
case "search":
|
||||
result = await handleSearch(args as Parameters<typeof handleSearch>[0]);
|
||||
break;
|
||||
case "chat":
|
||||
result = await handleChat(args as Parameters<typeof handleChat>[0]);
|
||||
break;
|
||||
case "list_logs":
|
||||
result = await handleListLogs(
|
||||
args as Parameters<typeof handleListLogs>[0]
|
||||
);
|
||||
break;
|
||||
case "get_log":
|
||||
result = await handleGetLog(args as Parameters<typeof handleGetLog>[0]);
|
||||
break;
|
||||
case "list_documents":
|
||||
result = await handleListDocuments(
|
||||
args as Parameters<typeof handleListDocuments>[0]
|
||||
);
|
||||
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: { date: string; file_path: string }) => ({
|
||||
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);
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in new issue