#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";
interface ServerConfig {
apiUrl: string;
apiKey?: string;
}
const DEFAULT_CONFIG: ServerConfig = {
apiUrl: process.env.OBSIDIAN_API_URL || "http://localhost:27124",
apiKey: process.env.OBSIDIAN_API_KEY,
};
class ObsidianTodosServer {
private server: Server;
private config: ServerConfig;
constructor(config: ServerConfig = DEFAULT_CONFIG) {
this.config = config;
this.server = new Server(
{
name: "obsidian-todos-mcp-server",
version: "1.2.5",
},
{
capabilities: {
tools: {},
},
}
);
this.setupHandlers();
}
private async fetchApi(endpoint: string, options: any = {}) {
const url = `${this.config.apiUrl}${endpoint}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string> || {}),
};
if (this.config.apiKey) {
headers["Authorization"] = this.config.apiKey;
}
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
return await response.json() as any;
}
private setupHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_todos",
description: "List all incomplete todos from Obsidian vault using Dataview",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
description: "The todo status (e.g. ' ', '!', '*'",
},
completed: {
type: "boolean",
description: "Do you want completed tasks only?"
},
path: {
type: "string",
description: "Only search beneath this path"
},
tag: {
type: "string",
description: "only match on these tags"
},
exclude: {
type: "string",
description: "A comma separated list of paths to exclude"
}
}
},
},
{
name: "add_todo",
description: "Add a new todo to today's daily note in Obsidian",
inputSchema: {
type: "object",
properties: {
text: {
type: "string",
description: "The todo text to add",
},
},
required: ["text"],
},
},
{
name: "update_todo",
description: "Update an existing todo in Obsidian (mark complete, change text, etc.)",
inputSchema: {
type: "object",
properties: {
file: {
type: "string",
description: "Path to the file containing the todo",
},
line: {
type: "number",
description: "Line number of the todo (0-indexed)",
},
text: {
type: "string",
description: "New text for the todo",
},
completed: {
type: "boolean",
description: "Whether the todo should be marked complete",
},
},
required: ["file", "line"],
},
},
{
name: "get_todo_stats",
description: "Get statistics about todos (total, by file, etc.)",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "list_due_dates",
description: "List all due dates from tables under # Due Dates headings in Obsidian",
inputSchema: {
type: "object",
properties: {
start: {
type: "string",
description: "Start date in YYYY-MM-DD format (optional)",
},
end: {
type: "string",
description: "End date in YYYY-MM-DD format (optional)",
},
query: {
type: "string",
description: "Query/tag filter (optional)",
},
},
},
},
],
}));
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "list_todos": {
const { status, completed, path, tag, exclude } = args as {
status?: string;
completed?: boolean;
path?: string;
tag?: string;
exclude?: string;
};
// Build query parameters
const params = new URLSearchParams();
if (status) params.append("status", status);
if (completed !== undefined) params.append("completed", completed.toString());
if (path) params.append("path", path);
if (tag) params.append("tag", tag);
if (exclude) params.append("exclude", exclude);
const queryString = params.toString();
const endpoint = queryString ? `/todos/?${queryString}` : "/todos/";
const result = await this.fetchApi(endpoint);
// Debug: Log the raw API response
console.error("Raw API response:", JSON.stringify(result, null, 2));
// Process and filter the results
const todos = result.todos || result || [];
console.error("Processing todos:", todos.length);
const processedTodos: any[] = [];
const seenIds = new Set(); // For deduplication
for (const todo of todos) {
// Debug: Log each todo being processed
console.error("Processing todo:", todo.text, "status:", todo.status, "completed:", todo.completed);
// Filter out completed tasks when completed=false
if (completed === false && todo.status === "x") {
console.error("Filtering out completed task:", todo.text);
continue;
}
// Extract only the required fields
const filteredTodo: any = {
text: todo.text || "",
path: todo.path || "",
line: todo.line || 0,
position: todo.position || 0,
status: todo.status || " ",
completed: todo.completed || false,
fullyCompleted: todo.fullyCompleted || false,
scheduled: todo.scheduled || null,
due: todo.due || null,
start: todo.start || null
};
// Add parent_id if it exists
if (todo.id || todo.parent_id) {
filteredTodo.parent_id = todo.parent_id || todo.id || null;
}
// Create a unique key for deduplication
const uniqueKey = `${todo.path}-${todo.line}-${todo.text}`;
console.error("Unique key:", uniqueKey);
if (!seenIds.has(uniqueKey)) {
seenIds.add(uniqueKey);
processedTodos.push(filteredTodo);
console.error("Added todo to processed list:", todo.text);
} else {
console.error("Duplicate detected, skipping:", todo.text);
}
// Process children if they exist
if (todo.children && todo.children.length > 0) {
console.error("Processing", todo.children.length, "children for:", todo.text);
for (const child of todo.children) {
const childUniqueKey = `${child.path}-${child.line}-${child.text}`;
if (!seenIds.has(childUniqueKey)) {
seenIds.add(childUniqueKey);
const processedChild = {
text: child.text || "",
path: child.path || "",
line: child.line || 0,
position: child.position || 0,
status: child.status || " ",
completed: child.completed || false,
fullyCompleted: child.fullyCompleted || false,
scheduled: child.scheduled || null,
due: child.due || null,
start: child.start || null,
parent_id: todo.id || todo.parent_id || `${todo.path}-${todo.line}-${todo.text}`
};
processedTodos.push(processedChild);
console.error("Added child todo:", child.text);
}
}
}
}
console.error("Final processed todos count:", processedTodos.length);
return {
content: [
{
type: "text",
text: JSON.stringify({ todos: processedTodos }, null, 2),
},
],
};
}
case "add_todo": {
const { text } = args as { text: string };
const result = await this.fetchApi("/todos/", {
method: "POST",
body: JSON.stringify({ text }),
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
case "update_todo": {
const { file, line, text, completed } = args as {
file: string;
line: number;
text?: string;
completed?: boolean;
};
const result = await this.fetchApi("/todos/", {
method: "PUT",
body: JSON.stringify({ file, line, text, completed }),
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
case "get_todo_stats": {
const todos = await this.fetchApi("/todos/");
const stats = {
total: todos.todos?.length || 0,
byFile: todos.todos?.reduce((acc: Record<string, number>, todo: any) => {
acc[todo.file] = (acc[todo.file] || 0) + 1;
return acc;
}, {}),
};
return {
content: [
{
type: "text",
text: JSON.stringify(stats, null, 2),
},
],
};
}
case "list_due_dates": {
const { start, end, query } = args as {
start?: string;
end?: string;
query?: string;
};
// Build query parameters
const params = new URLSearchParams();
if (start) params.append("start", start);
if (end) params.append("end", end);
if (query) params.append("query", query);
const queryString = params.toString();
const endpoint = queryString ? `/due-dates/?${queryString}` : "/due-dates/";
const result = await this.fetchApi(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
isError: true,
};
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Obsidian Todos MCP Server running on stdio");
}
}
// Start the server
const server = new ObsidianTodosServer();
server.run().catch(console.error);