diff --git a/README.md b/README.md index 45021c1..28a9457 100644 --- a/README.md +++ b/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": "", - "OBSIDIAN_HOST": "", - "OBSIDIAN_PORT": "" + "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": "", - "OBSIDIAN_HOST": "", - "OBSIDIAN_PORT": "" + "OBSIDIAN_API_KEY": "", + "OBSIDIAN_HOST": "" } } } diff --git a/src/mcp_obsidian/obsidian.py b/src/mcp_obsidian/obsidian.py index 024145a..3133eb6 100644 --- a/src/mcp_obsidian/obsidian.py +++ b/src/mcp_obsidian/obsidian.py @@ -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}' diff --git a/src/mcp_obsidian/tools.py b/src/mcp_obsidian/tools.py index f942cae..a7b8818 100644 --- a/src/mcp_obsidian/tools.py +++ b/src/mcp_obsidian/tools.py @@ -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 [