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:
42
README.md
42
README.md
@@ -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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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}'
|
||||
|
||||
|
@@ -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 [
|
||||
|
Reference in New Issue
Block a user