Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,31 @@ The use prompts like this:

## Configuration

### Obsidian REST API Key
### Environment Variables

There are two ways to configure the environment with the Obsidian REST API Key.
The MCP server requires the following environment variables:

1. Add to server config (preferred)
- `OBSIDIAN_API_KEY`: Your Obsidian Local REST API key (required)
- `OBSIDIAN_HOST`: The URL for your Obsidian Local REST API (optional, defaults to `https://127.0.0.1:27124`)

#### OBSIDIAN_HOST Format

The `OBSIDIAN_HOST` variable accepts full URLs with protocol, host, and port. It supports both `localhost` and `127.0.0.1` with either `http` or `https`:

```
http://127.0.0.1:27123
https://localhost:27124
http://localhost:27123
https://127.0.0.1:27124
```

**Note:** The server performs a health check on startup. If the connection fails, you'll get an immediate error message indicating the configuration issue.

### Configuration Methods

There are two ways to configure the environment variables:

#### 1. Add to server config (preferred)

```json
{
Expand All @@ -50,14 +70,14 @@ There are two ways to configure the environment with the Obsidian REST API Key.
}
```

2. Create a `.env` file in the working directory with the following required variable:
#### 2. Create a `.env` file in the working directory with the following required variable:

```
OBSIDIAN_API_KEY=your_api_key_here
OBSIDIAN_HOST=your_obsidian_host
```

Note: You can find the key in the Obsidian plugin config.
**Note:** You can find the API key in the Obsidian Local REST API plugin configuration.

## Quickstart

Expand Down Expand Up @@ -107,7 +127,8 @@ On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
"mcp-obsidian"
],
"env": {
"OBSIDIAN_API_KEY" : "<YOUR_OBSIDIAN_API_KEY>"
"OBSIDIAN_API_KEY": "<your_api_key_here>",
"OBSIDIAN_HOST": "<your_obsidian_host>"
}
}
}
Expand Down
69 changes: 68 additions & 1 deletion src/mcp_obsidian/obsidian.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import requests
import urllib.parse
from typing import Any
from typing import Any, Tuple
from urllib.parse import urlparse

class Obsidian():
def __init__(
Expand All @@ -18,6 +19,72 @@ def __init__(
self.verify_ssl = verify_ssl
self.timeout = (3, 6)

@classmethod
def from_url(cls, api_key: str, url: str, verify_ssl: bool = False) -> 'Obsidian':
"""Create Obsidian instance from a full URL.

Args:
api_key: The API key for authentication
url: Full URL like 'http://127.0.0.1:27123' or 'https://localhost:27124'
verify_ssl: Whether to verify SSL certificates

Returns:
Obsidian instance with parsed URL components

Raises:
ValueError: If URL is malformed or missing required components
"""
try:
parsed = urlparse(url)

if not parsed.scheme:
raise ValueError(f"URL must include protocol (http/https): {url}")

if not parsed.hostname:
raise ValueError(f"URL must include hostname: {url}")

protocol = parsed.scheme
host = parsed.hostname
port = parsed.port

# Set default ports based on protocol if not specified
if port is None:
port = 27124 if protocol == 'https' else 27123

return cls(
api_key=api_key,
protocol=protocol,
host=host,
port=port,
verify_ssl=verify_ssl
)
except Exception as e:
raise ValueError(f"Failed to parse OBSIDIAN_HOST URL '{url}': {str(e)}")

@staticmethod
def parse_host_config(host_config: str) -> Tuple[str, str, int]:
"""Parse host configuration string.

Args:
host_config: Either a full URL (http://host:port) or just hostname/IP

Returns:
Tuple of (protocol, host, port)
"""
if '://' in host_config:
# Full URL format
parsed = urlparse(host_config)
protocol = parsed.scheme or 'https'
host = parsed.hostname or '127.0.0.1'
port = parsed.port or (27124 if protocol == 'https' else 27123)
else:
# Legacy hostname/IP only format
protocol = 'https'
host = host_config
port = 27124

return protocol, host, port

def get_base_url(self) -> str:
return f'{self.protocol}://{self.host}:{self.port}'

Expand Down
59 changes: 43 additions & 16 deletions src/mcp_obsidian/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,42 @@
import os
from . import obsidian

# Load environment variables
api_key = os.getenv("OBSIDIAN_API_KEY", "")
obsidian_host = os.getenv("OBSIDIAN_HOST", "127.0.0.1")
obsidian_host = os.getenv("OBSIDIAN_HOST", "https://127.0.0.1:27124")

if api_key == "":
raise ValueError(f"OBSIDIAN_API_KEY environment variable required. Working directory: {os.getcwd()}")

# Parse the OBSIDIAN_HOST configuration at module level for validation
try:
protocol, host, port = obsidian.Obsidian.parse_host_config(obsidian_host)
except ValueError as e:
raise ValueError(f"Invalid OBSIDIAN_HOST configuration: {str(e)}")

def create_obsidian_api() -> obsidian.Obsidian:
"""Factory function to create Obsidian API instances.

Creates a new Obsidian API instance with parsed configuration from environment variables.
This centralizes the configuration logic and makes testing easier.

Returns:
Configured Obsidian API instance

Raises:
Exception: If configuration is invalid or instance creation fails
"""
try:
return obsidian.Obsidian(
api_key=api_key,
protocol=protocol,
host=host,
port=port,
verify_ssl=False # Default to False for local development
)
except Exception as e:
raise Exception(f"Failed to create Obsidian API instance: {str(e)}")

TOOL_LIST_FILES_IN_VAULT = "obsidian_list_files_in_vault"
TOOL_LIST_FILES_IN_DIR = "obsidian_list_files_in_dir"

Expand Down Expand Up @@ -44,8 +74,7 @@ def get_tool_description(self):
)

def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)

api = create_obsidian_api()
files = api.list_files_in_vault()

return [
Expand Down Expand Up @@ -80,8 +109,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if "dirpath" not in args:
raise RuntimeError("dirpath argument missing in arguments")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)

api = create_obsidian_api()
files = api.list_files_in_dir(args["dirpath"])

return [
Expand Down Expand Up @@ -116,8 +144,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if "filepath" not in args:
raise RuntimeError("filepath argument missing in arguments")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)

api = create_obsidian_api()
content = api.get_file_contents(args["filepath"])

return [
Expand Down Expand Up @@ -159,7 +186,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded

context_length = args.get("context_length", 100)

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
results = api.search(args["query"], context_length)

formatted_results = []
Expand Down Expand Up @@ -218,7 +245,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if "filepath" not in args or "content" not in args:
raise RuntimeError("filepath and content arguments required")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
api.append_content(args.get("filepath", ""), args["content"])

return [
Expand Down Expand Up @@ -271,7 +298,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if not all(k in args for k in ["filepath", "operation", "target_type", "target", "content"]):
raise RuntimeError("filepath, operation, target_type, target and content arguments required")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
api.patch_content(
args.get("filepath", ""),
args.get("operation", ""),
Expand Down Expand Up @@ -320,7 +347,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if not args.get("confirm", False):
raise RuntimeError("confirm must be set to true to delete a file")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
api.delete_file(args["filepath"])

return [
Expand Down Expand Up @@ -358,7 +385,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if "query" not in args:
raise RuntimeError("query argument missing in arguments")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
results = api.search_json(args.get("query", ""))

return [
Expand Down Expand Up @@ -397,7 +424,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if "filepaths" not in args:
raise RuntimeError("filepaths argument missing in arguments")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
content = api.get_batch_file_contents(args["filepaths"])

return [
Expand Down Expand Up @@ -437,7 +464,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if period not in valid_periods:
raise RuntimeError(f"Invalid period: {period}. Must be one of: {', '.join(valid_periods)}")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
content = api.get_periodic_note(period)

return [
Expand Down Expand Up @@ -497,7 +524,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if not isinstance(include_content, bool):
raise RuntimeError(f"Invalid include_content: {include_content}. Must be a boolean")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
results = api.get_recent_periodic_notes(period, limit, include_content)

return [
Expand Down Expand Up @@ -544,7 +571,7 @@ def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | Embedded
if not isinstance(days, int) or days < 1:
raise RuntimeError(f"Invalid days: {days}. Must be a positive integer")

api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
api = create_obsidian_api()
results = api.get_recent_changes(limit, days)

return [
Expand Down