fix: add full support for OBSIDIAN_HOST env var; URL parsing helpers; central API factory; README updates (merge of upstream PR #52)

This commit is contained in:
2025-08-16 18:52:25 +00:00
parent 72490a4db0
commit 8bca179ba5
3 changed files with 145 additions and 32 deletions

View File

@@ -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
{
@@ -44,26 +64,21 @@ There are two ways to configure the environment with the Obsidian REST API Key.
],
"env": {
"OBSIDIAN_API_KEY": "<your_api_key_here>",
"OBSIDIAN_HOST": "<your_obsidian_host>",
"OBSIDIAN_PORT": "<your_obsidian_port>"
"OBSIDIAN_HOST": "<your_obsidian_host>"
}
}
}
```
Sometimes Claude has issues detecting the location of uv / uvx. You can use `which uvx` to find and paste the full path in above config in such cases.
2. Create a `.env` file in the working directory with the following required variables:
#### 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
OBSIDIAN_PORT=your_obsidian_port
```
Note:
- You can find the API key in the Obsidian plugin config
- Default port is 27124 if not specified
- Default host is 127.0.0.1 if not specified
**Note:** You can find the API key in the Obsidian Local REST API plugin configuration.
## Quickstart
@@ -118,9 +133,8 @@ On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
"mcp-obsidian"
],
"env": {
"OBSIDIAN_API_KEY": "<YOUR_OBSIDIAN_API_KEY>",
"OBSIDIAN_HOST": "<your_obsidian_host>",
"OBSIDIAN_PORT": "<your_obsidian_port>"
"OBSIDIAN_API_KEY": "<your_api_key_here>",
"OBSIDIAN_HOST": "<your_obsidian_host>"
}
}
}

View File

@@ -1,7 +1,8 @@
import requests
import urllib.parse
import os
from typing import Any
from typing import Any, Tuple
from urllib.parse import urlparse
class Obsidian():
def __init__(
@@ -24,6 +25,72 @@ class Obsidian():
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}'

View File

@@ -9,12 +9,42 @@ import json
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"
@@ -44,8 +74,7 @@ class ListFilesInVaultToolHandler(ToolHandler):
)
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 [
@@ -80,8 +109,7 @@ class ListFilesInDirToolHandler(ToolHandler):
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 [
@@ -116,8 +144,7 @@ class GetFileContentsToolHandler(ToolHandler):
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 [
@@ -159,7 +186,7 @@ class SearchToolHandler(ToolHandler):
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 = []
@@ -218,7 +245,7 @@ class AppendContentToolHandler(ToolHandler):
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 [
@@ -271,7 +298,7 @@ class PatchContentToolHandler(ToolHandler):
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", ""),
@@ -360,7 +387,7 @@ class DeleteFileToolHandler(ToolHandler):
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 [
@@ -424,7 +451,7 @@ class ComplexSearchToolHandler(ToolHandler):
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 [
@@ -463,7 +490,7 @@ class BatchGetFileContentsToolHandler(ToolHandler):
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 [
@@ -514,8 +541,13 @@ class PeriodicNotesToolHandler(ToolHandler):
if type not in valid_types:
raise RuntimeError(f"Invalid type: {type}. Must be one of: {', '.join(valid_types)}")
api = obsidian.Obsidian(api_key=api_key, host=obsidian_host)
content = api.get_periodic_note(period,type)
<<<<<<< ours
api = create_obsidian_api()
content = api.get_periodic_note(period)
=======
api = create_obsidian_api()
content = api.get_periodic_note(period)
>>>>>>> theirs
return [
TextContent(
@@ -574,7 +606,7 @@ class RecentPeriodicNotesToolHandler(ToolHandler):
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 [
@@ -621,7 +653,7 @@ class RecentChangesToolHandler(ToolHandler):
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 [