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
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -44,26 +64,21 @@ There are two ways to configure the environment with the Obsidian REST API Key.
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"OBSIDIAN_API_KEY": "<your_api_key_here>",
|
"OBSIDIAN_API_KEY": "<your_api_key_here>",
|
||||||
"OBSIDIAN_HOST": "<your_obsidian_host>",
|
"OBSIDIAN_HOST": "<your_obsidian_host>"
|
||||||
"OBSIDIAN_PORT": "<your_obsidian_port>"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
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.
|
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_API_KEY=your_api_key_here
|
||||||
OBSIDIAN_HOST=your_obsidian_host
|
OBSIDIAN_HOST=your_obsidian_host
|
||||||
OBSIDIAN_PORT=your_obsidian_port
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
**Note:** You can find the API key in the Obsidian Local REST API plugin configuration.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -118,9 +133,8 @@ On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
|
|||||||
"mcp-obsidian"
|
"mcp-obsidian"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"OBSIDIAN_API_KEY": "<YOUR_OBSIDIAN_API_KEY>",
|
"OBSIDIAN_API_KEY": "<your_api_key_here>",
|
||||||
"OBSIDIAN_HOST": "<your_obsidian_host>",
|
"OBSIDIAN_HOST": "<your_obsidian_host>"
|
||||||
"OBSIDIAN_PORT": "<your_obsidian_port>"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
import requests
|
import requests
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
class Obsidian():
|
class Obsidian():
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -24,6 +25,72 @@ class Obsidian():
|
|||||||
self.verify_ssl = verify_ssl
|
self.verify_ssl = verify_ssl
|
||||||
self.timeout = (3, 6)
|
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:
|
def get_base_url(self) -> str:
|
||||||
return f'{self.protocol}://{self.host}:{self.port}'
|
return f'{self.protocol}://{self.host}:{self.port}'
|
||||||
|
|
||||||
|
@@ -9,12 +9,42 @@ import json
|
|||||||
import os
|
import os
|
||||||
from . import obsidian
|
from . import obsidian
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
api_key = os.getenv("OBSIDIAN_API_KEY", "")
|
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 == "":
|
if api_key == "":
|
||||||
raise ValueError(f"OBSIDIAN_API_KEY environment variable required. Working directory: {os.getcwd()}")
|
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_VAULT = "obsidian_list_files_in_vault"
|
||||||
TOOL_LIST_FILES_IN_DIR = "obsidian_list_files_in_dir"
|
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]:
|
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()
|
files = api.list_files_in_vault()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -80,8 +109,7 @@ class ListFilesInDirToolHandler(ToolHandler):
|
|||||||
if "dirpath" not in args:
|
if "dirpath" not in args:
|
||||||
raise RuntimeError("dirpath argument missing in arguments")
|
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"])
|
files = api.list_files_in_dir(args["dirpath"])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -116,8 +144,7 @@ class GetFileContentsToolHandler(ToolHandler):
|
|||||||
if "filepath" not in args:
|
if "filepath" not in args:
|
||||||
raise RuntimeError("filepath argument missing in arguments")
|
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"])
|
content = api.get_file_contents(args["filepath"])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -159,7 +186,7 @@ class SearchToolHandler(ToolHandler):
|
|||||||
|
|
||||||
context_length = args.get("context_length", 100)
|
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)
|
results = api.search(args["query"], context_length)
|
||||||
|
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
@@ -218,7 +245,7 @@ class AppendContentToolHandler(ToolHandler):
|
|||||||
if "filepath" not in args or "content" not in args:
|
if "filepath" not in args or "content" not in args:
|
||||||
raise RuntimeError("filepath and content arguments required")
|
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"])
|
api.append_content(args.get("filepath", ""), args["content"])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -271,7 +298,7 @@ class PatchContentToolHandler(ToolHandler):
|
|||||||
if not all(k in args for k in ["filepath", "operation", "target_type", "target", "content"]):
|
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")
|
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(
|
api.patch_content(
|
||||||
args.get("filepath", ""),
|
args.get("filepath", ""),
|
||||||
args.get("operation", ""),
|
args.get("operation", ""),
|
||||||
@@ -360,7 +387,7 @@ class DeleteFileToolHandler(ToolHandler):
|
|||||||
if not args.get("confirm", False):
|
if not args.get("confirm", False):
|
||||||
raise RuntimeError("confirm must be set to true to delete a file")
|
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"])
|
api.delete_file(args["filepath"])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -424,7 +451,7 @@ class ComplexSearchToolHandler(ToolHandler):
|
|||||||
if "query" not in args:
|
if "query" not in args:
|
||||||
raise RuntimeError("query argument missing in arguments")
|
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", ""))
|
results = api.search_json(args.get("query", ""))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -463,7 +490,7 @@ class BatchGetFileContentsToolHandler(ToolHandler):
|
|||||||
if "filepaths" not in args:
|
if "filepaths" not in args:
|
||||||
raise RuntimeError("filepaths argument missing in arguments")
|
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"])
|
content = api.get_batch_file_contents(args["filepaths"])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -514,8 +541,13 @@ class PeriodicNotesToolHandler(ToolHandler):
|
|||||||
if type not in valid_types:
|
if type not in valid_types:
|
||||||
raise RuntimeError(f"Invalid type: {type}. Must be one of: {', '.join(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)
|
<<<<<<< ours
|
||||||
content = api.get_periodic_note(period,type)
|
api = create_obsidian_api()
|
||||||
|
content = api.get_periodic_note(period)
|
||||||
|
=======
|
||||||
|
api = create_obsidian_api()
|
||||||
|
content = api.get_periodic_note(period)
|
||||||
|
>>>>>>> theirs
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TextContent(
|
TextContent(
|
||||||
@@ -574,7 +606,7 @@ class RecentPeriodicNotesToolHandler(ToolHandler):
|
|||||||
if not isinstance(include_content, bool):
|
if not isinstance(include_content, bool):
|
||||||
raise RuntimeError(f"Invalid include_content: {include_content}. Must be a boolean")
|
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)
|
results = api.get_recent_periodic_notes(period, limit, include_content)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -621,7 +653,7 @@ class RecentChangesToolHandler(ToolHandler):
|
|||||||
if not isinstance(days, int) or days < 1:
|
if not isinstance(days, int) or days < 1:
|
||||||
raise RuntimeError(f"Invalid days: {days}. Must be a positive integer")
|
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)
|
results = api.get_recent_changes(limit, days)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
Reference in New Issue
Block a user