Skip to main content
Glama

Advanced Obsidian MCP Server

by ToKiDoO
obsidian.py18.5 kB
import requests import urllib.parse import os import obsidiantools.api as otools from typing import Any from pathlib import Path class Obsidian(): def __init__( self, api_key: str = str(os.getenv('OBSIDIAN_API_KEY', '')), protocol: str = os.getenv('OBSIDIAN_PROTOCOL', 'https').lower(), host: str = str(os.getenv('OBSIDIAN_HOST', '127.0.0.1')), port: int = int(os.getenv('OBSIDIAN_PORT', '27124')), vault_path: str = os.getenv('OBSIDIAN_VAULT_PATH', ''), verify_ssl: bool = False, ): if protocol == 'http': self.protocol = 'http' else: self.protocol = 'https' # Default to https for any other value, including 'https' self.host = host self.port = port self.api_key = api_key self.vault_path = vault_path self.verify_ssl = verify_ssl self.timeout = (3, 6) def _check_credentials(self): if not self.vault_path: raise RuntimeError("OBSIDIAN_VAULT_PATH environment variable not set") if not self.api_key: raise RuntimeError("OBSIDIAN_API_KEY environment variable not set") def get_base_url(self) -> str: return f'{self.protocol}://{self.host}:{self.port}' def _get_headers(self) -> dict: self._check_credentials() headers = { 'Authorization': f'Bearer {self.api_key}' } return headers def _safe_call(self, f) -> Any: try: return f() except requests.HTTPError as e: error_data = e.response.json() if e.response.content else {} code = error_data.get('errorCode', -1) message = error_data.get('message', '<unknown>') raise Exception(f"Error {code}: {message}") except requests.exceptions.RequestException as e: raise Exception(f"Request failed: {str(e)}") def get_note_info (self, note_filepath: str) -> Any: vault = otools.Vault(Path(self.vault_path)).connect().gather() # Initialize obsidiantools vault note_name = os.path.splitext(os.path.basename(note_filepath))[0] # Extract note name from filepath (remove .md extension and path) # Check if note exists in vault if note_name not in vault.md_file_index: raise RuntimeError(f"Note '{note_filepath}' not found in vault") # Get note metadata all_metadata_df = vault.get_note_metadata() note_metadata = all_metadata_df.loc[note_name] # Extract metadata metadata = { "tags": vault.get_tags(note_name), "front_matter": vault.get_front_matter(note_name), "file_info": { "rel_filepath": str(note_metadata.get("rel_filepath")), "modified_time": str(note_metadata.get("modified_time")) }, "counts": { "n_backlinks": int(note_metadata.get("n_backlinks", 0)), "n_wikilinks": int(note_metadata.get("n_wikilinks", 0)), "n_embedded_files": int(note_metadata.get("n_embedded_files", 0)), "n_tags": int(note_metadata.get("n_tags", 0)) } } # Extract connections wikilinks = vault.get_wikilinks(note_name) embedded_files = vault.get_embedded_files(note_name) backlinks = vault.get_backlinks(note_name) # Combine direct links (wikilinks + embedded files) direct_links = list(set(wikilinks + embedded_files)) # Get non-existent links (links that don't exist in vault) non_existent_links = [] for link in wikilinks: if link in all_metadata_df.index: if not all_metadata_df.loc[link, "note_exists"]: non_existent_links.append(link) connections = { "direct_links": direct_links, "backlinks": backlinks, "non_existent_links": non_existent_links } # Construct final response note_info = { "metadata": metadata, "connections": connections, } return note_info def list_files_in_dir(self, dirpath: str) -> Any: encoded_dirpath = urllib.parse.quote(dirpath) url = f"{self.get_base_url()}/vault/{encoded_dirpath}/" def call_fn(): response = requests.get(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.json()['files'] return self._safe_call(call_fn) def get_file_contents(self, filepath: str) -> Any: """Tool removed because batch content can handle one or more .md files.. This is now a helper function for get_batch_file_contents that returns the content of a single file. Args: filepath: Relative path to .md note file to read. ONLY .md files are supported. Returns: Redable markdown content of a note. """ url = f"{self.get_base_url()}/vault/{filepath}" def call_fn(): response = requests.get(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.text return self._safe_call(call_fn) def get_batch_file_contents(self, filepaths: list[str]) -> list: """Get contents and info of multiple files using enhanced API approach. Args: filepaths: List of .md note file paths to read. ONLY .md files are supported. Returns: List of objects containing file metadata, connections, tags, front matter, file info, and content """ results = [] for filepath in filepaths: try: # Get content using the existing method content = self.get_file_contents(filepath) # Get enhanced metadata using REST API (similar to active note approach) url = f"{self.get_base_url()}/vault/{filepath}" headers = self._get_headers() | {'Accept': 'application/vnd.olrapi.note+json'} def call_fn(): response = requests.get(url, headers=headers, verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.json() file_data = self._safe_call(call_fn) # Extract data from the API response api_frontmatter = file_data.get('frontmatter', {}) api_stat = file_data.get('stat', {}) api_tags = file_data.get('tags', []) # Get additional note info using obsidiantools for connections/links try: note_info = self.get_note_info(filepath) # Replace the tags from get_note_info with the more accurate API tags note_info['metadata']['tags'] = api_tags # Also update frontmatter with API data for accuracy note_info['metadata']['front_matter'] = api_frontmatter # Update file info with API stat data note_info['metadata']['file_info'].update({ 'ctime': api_stat.get('ctime'), 'mtime': api_stat.get('mtime'), 'size': api_stat.get('size') }) except Exception as note_info_error: # Fallback: create basic note info from API data note_info = { 'metadata': { 'tags': api_tags, 'front_matter': api_frontmatter, 'file_info': { 'rel_filepath': filepath, 'ctime': api_stat.get('ctime'), 'mtime': api_stat.get('mtime'), 'size': api_stat.get('size') }, 'counts': { 'n_backlinks': 0, 'n_wikilinks': 0, 'n_embedded_files': 0, 'n_tags': len(api_tags) } }, 'connections': { 'direct_links': [], 'backlinks': [], 'non_existent_links': [] } } results.append({ "filepath": filepath, "note_info": note_info, "content": content }) except Exception as e: # Add error message but continue processing other files results.append({ "filepath": filepath, "error": f"Error reading file: {str(e)}" }) return results def get_active_note(self) -> Any: """Get content of the currently actively edited note. Returns: Redable markdown content of the currently actively edited note. """ url = f"{self.get_base_url()}/active/" def call_fn(): response = requests.get(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.text return self._safe_call(call_fn) def search(self, query: str, context_length: int = 300) -> Any: url = f"{self.get_base_url()}/search/simple/" params = { 'query': query, 'contextLength': context_length } def call_fn(): response = requests.post(url, headers=self._get_headers(), params=params, verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.json() return self._safe_call(call_fn) def append_content(self, filepath: str, content: str) -> Any: url = f"{self.get_base_url()}/vault/{filepath}" def call_fn(): response = requests.post( url, headers=self._get_headers() | {'Content-Type': 'text/markdown'}, data=content, verify=self.verify_ssl, timeout=self.timeout ) response.raise_for_status() return None return self._safe_call(call_fn) def patch_content(self, filepath: str, operation: str, target_type: str, target: str, content: str) -> Any: url = f"{self.get_base_url()}/vault/{filepath}" headers = self._get_headers() | { 'Content-Type': 'text/markdown', 'Operation': operation, 'Target-Type': target_type, 'Target': urllib.parse.quote(target) } def call_fn(): response = requests.patch(url, headers=headers, data=content, verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return None return self._safe_call(call_fn) def put_content(self, filepath: str, content: str) -> Any: url = f"{self.get_base_url()}/vault/{filepath}" def call_fn(): response = requests.put( url, headers=self._get_headers() | {'Content-Type': 'text/markdown'}, data=content, verify=self.verify_ssl, timeout=self.timeout ) response.raise_for_status() return None return self._safe_call(call_fn) def delete_file(self, filepath: str) -> Any: """Delete a file or directory from the vault. Args: filepath: Path to the file to delete (relative to vault root) Returns: None on success """ url = f"{self.get_base_url()}/vault/{filepath}" def call_fn(): response = requests.delete(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return None return self._safe_call(call_fn) def search_json(self, query: dict) -> Any: url = f"{self.get_base_url()}/search/" headers = self._get_headers() | { 'Content-Type': 'application/vnd.olrapi.jsonlogic+json' } def call_fn(): response = requests.post(url, headers=headers, json=query, verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.json() return self._safe_call(call_fn) def get_periodic_note(self, period: str, type: str = "content") -> Any: """Get current periodic note for the specified period. Args: period: The period type (daily, weekly, monthly, quarterly, yearly) type: Type of the data to get ('content' or 'metadata'). 'content' returns just the content in Markdown format. 'metadata' includes note metadata (including paths, tags, etc.) and the content.. Returns: Content of the periodic note """ url = f"{self.get_base_url()}/periodic/{period}/" def call_fn(): headers = self._get_headers() if type == "metadata": headers['Accept'] = 'application/vnd.olrapi.note+json' response = requests.get(url, headers=headers, verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.text return self._safe_call(call_fn) def get_recent_periodic_notes(self, period: str, limit: int = 5, include_content: bool = False) -> Any: """Get most recent periodic notes for the specified period type. Args: period: The period type (daily, weekly, monthly, quarterly, yearly) limit: Maximum number of notes to return (default: 5) include_content: Whether to include note content (default: False) Returns: List of recent periodic notes """ url = f"{self.get_base_url()}/periodic/{period}/recent" params = { "limit": limit, "includeContent": include_content } def call_fn(): response = requests.get( url, headers=self._get_headers(), params=params, verify=self.verify_ssl, timeout=self.timeout ) response.raise_for_status() return response.json() return self._safe_call(call_fn) def get_recent_changes(self, limit: int = 10, days: int = 90) -> Any: """Get recently modified files in the vault. Args: limit: Maximum number of files to return (default: 10) days: Only include files modified within this many days (default: 90) Returns: List of recently modified files with metadata """ # Build the DQL query query_lines = [ "TABLE file.mtime", f"WHERE file.mtime >= date(today) - dur({days} days)", "SORT file.mtime DESC", f"LIMIT {limit}" ] # Join with proper DQL line breaks dql_query = "\n".join(query_lines) # Make the request to search endpoint url = f"{self.get_base_url()}/search/" headers = self._get_headers() | { 'Content-Type': 'application/vnd.olrapi.dataview.dql+txt' } def call_fn(): response = requests.post( url, headers=headers, data=dql_query.encode('utf-8'), verify=self.verify_ssl, timeout=self.timeout ) response.raise_for_status() return response.json() return self._safe_call(call_fn) def open_file(self, filepath: str) -> Any: """Open a file in the vault. Args: filepath: Path to the file to open (relative to vault root) Returns: None on success. Opens the file in the default editor in a new leaf. """ url = f"{self.get_base_url()}/open/{filepath}" def call_fn(): response = requests.post(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return None return self._safe_call(call_fn) def list_commands(self) -> Any: """List all available commands you can run in obsidian interface. For commands on open notes, make sure to open a note first. Returns: List of available commands in obsidian. """ url = f"{self.get_base_url()}/commands/" def call_fn(): response = requests.get(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return response.json() return self._safe_call(call_fn) def execute_command(self, command: str) -> Any: """Execute a command in obsidian interface. For commands on open notes, make sure to open a note first. Args: command: Command to execute Returns: None on success. Executes the command in obsidian. """ url = f"{self.get_base_url()}/commands/{command}" def call_fn(): response = requests.post(url, headers=self._get_headers(), verify=self.verify_ssl, timeout=self.timeout) response.raise_for_status() return None return self._safe_call(call_fn)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ToKiDoO/mcp-obsidian-advanced'

If you have feedback or need assistance with the MCP directory API, please join our Discord server