Model Context Protocol (MCP) is an open standard created by Anthropic in November 2024 that enables LLM-powered software to connect with external services. They describe it as the "USB-C port for AI applications" - which is apt.
The most powerful advantage of MCP servers over traditional APIs is dynamic tool discovery and context-awareness. This means that if you adhere to the protocol, your AI assistant can automatically discover and utilize new capabilities without any additional integration work. Instead of hard-coding specific endpoints or behaviors, the AI dynamically adapts to whatever tools or data sources the MCP server makes available.
In this article, we'll build a lightweight MCP server for Obsidian (markdown note taking app) that allows Cursor and other MCP clients to read and write to your knowledge base. We’ll see that we only need a few simple tools to enable a lot of functionality. For example you could ask:
"Create a new note for standup tomorrow describing the code changes I've made today" (this should also run a git command if you’re using Cursor)
"Check what todos I have related to refactoring and implement them" (also relevant to Cursor)
“Draft a plan to help me prioritise my todos”
Protocol Basics
MCP consists of:
Servers: Lightweight programs that expose specific capabilities through the protocol
Clients: Applications like Cursor that maintain connect to servers and benefit from access to external services.
The protocol works through a standardized message exchange system using JSON-RPC 2.0, with different transport layers (like stdio or HTTP with SSE) handling the actual communication.
Setting Up Your Project
To get started, you'll need:
Node.js and npm/yarn
TypeScript
The MCP SDK (
@modelcontextprotocol/sdk
)Zod for schema validation
Create a new TypeScript project and install the dependencies:
1npm init -y
2npm install @modelcontextprotocol/sdk zod
3npm install --save-dev typescript @types/node
Server Implementation
Our MCP server will have two main components:
Read functionality to access vault files
Write functionality to create & update vault files
Note: since Obsidian is essentially a directory of Markdown files, your MCP server can directly interact with these notes at the file-system level, allowing immediate reading, editing, and creation of notes without needing to run or interface directly with the Obsidian application itself.
The core of our server is quite simple - we initialize an MCP server and set up a transport layer:
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4// Initialize the server
5export const server = new McpServer({
6 name: "obsidian",
7 version: "1.0.0",
8});
9
10// Get vault path from command line arguments
11export let vaultPath: string | undefined = process.argv[2];
12
13// Register tools and connect to transport
14async function main() {
15 const transport = new StdioServerTransport();
16 await server.connect(transport);
17 console.error("Obsidian MCP Server running on stdio");
18}
Creating MCP Tools
The power of MCP comes from the tools we create. Each tool has a name, description, schema, and handler function. The description is very important as it helps the LLM understand precisely what the tool does and when to use it.
One of the challenges in this use case is searching through the knowledge base to find information. Embedding / vector search is a bit too involved, but I found keyword search to be much too restrictive. Instead we will create a tool for listing all the file names in the vault and another tool that can read the contents of multiple files at once, given their file names. The assumption here is that the file names are indicative of their contents - which clearly isn’t always true but works pretty well.
Reading Operations
Our server implements three main tools that read the given Obsidian vault:
1// Tool for getting all filenames in the vault
2const getAllFilenamesTool = {
3 name: "getAllFilenames",
4 description: "Get a list of all filenames in the Obsidian vault. Useful for retrieving their contents later.",
5 schema: {},
6 handler: (args, extra) => {
7 // Implementation: Returns sorted list of all files in the vault
8 }
9};
10
11// Tool for reading multiple files by name
12export const readFiles = {
13 name: "readMultipleFiles",
14 description: "Retrieves the contents of specified files from the Obsidian vault.",
15 schema: {
16 filenames: z.array(z.string()),
17 },
18 handler: (args, extra) => {
19 // Implementation: Finds and reads files with flexible matching
20 }
21};
22
23// Tool for finding open TODOs
24export const getOpenTodos = {
25 name: "getOpenTodos",
26 description: "Retrieves all open TODO items from markdown files in the Obsidian vault.",
27 schema: {},
28 handler: (args, extra) => {
29 // Implementation: Scans files for "- [ ]" patterns and returns with file locations
30 }
31};
I use Obsidian to track todos so that last tool might not be relevant to you.
Writing Operations
For writing, we have a single powerful tool:
1// Tool for updating or creating files
2export const updateFileContent = {
3 name: "updateFileContent",
4 description: "Updates the content of a specified file in the Obsidian vault.",
5 schema: {
6 filePath: z.string(),
7 content: z.string(),
8 },
9 handler: (args, extra) => {
10 // Implementation: Creates directories if needed and writes content to file
11 }
12};
Registering Tools with the MCP Server
Once defined, we register our tools with the server:
1// In index.ts
2readTools.forEach((tool) => {
3 server.tool(tool.name, tool.description, tool.schema, tool.handler);
4});
5
6writeTools.forEach((tool) => {
7 server.tool(tool.name, tool.description, tool.schema, tool.handler);
8});
Design Considerations
Simplicity Over Complexity: We've focused on a few powerful tools rather than many specialised ones. You’ll find that by combining these tools you can get a lot done.
Flexible Matching: The reading functionality is forgiving, allowing clients to find files even with imperfect queries
Directory Creation: When writing files, the server automatically creates any necessary directories.
Using the Server
Add a build script to your package.json
:
1{
2 "scripts": {
3 "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\""
4 },
5 "bin": {
6 "obsidian-mcp-server": "./build/index.js"
7 }
8}
Testing
The easiest way to to test any MCP server is using the official visual MCP inspector tool. Which lets you debug MCP servers by running the tools in isolation.
To run the inspector:
1npx @modelcontextprotocol/inspector node build/index.js
Usage with Cursor & Claude Desktop
First make sure you’ve built the server:
1npm run build
To use your MCP server with Claude Desktop add it to your Claude configuration:
1{
2 "mcpServers": {
3 "obsidian": {
4 "command": "node",
5 "args": [
6 "obsidian-mcp-server/build/index.js",
7 "/path/to/your/vault"
8 ]
9 }
10 }
11}
For Cursor go to the MCP tab Cursor Settings
(command + shift + J). Add a server with this command:
1node obsidian-mcp-server/build/index.js /path/to/your/vault
Extending and Comparing
While our implementation is intentionally lightweight, you could extend it with:
Advanced search capabilities (to deal with poorly named files)
Support for Obsidian-specific features like links and tags
For comparison, the project jacksteamdev/obsidian-mcp-tools offers a more feature-rich approach as an Obsidian plugin. Our standalone server is simpler but has the advantage of direct filesystem access without requiring the Obsidian application to be running.