#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Setup helper for zotero-mcp.
This script provides utilities to automatically configure zotero-mcp
by finding the installed executable and updating Claude Desktop's config.
"""
import argparse
import getpass
import json
import os
import shutil
import sys
from pathlib import Path
def find_executable():
"""Find the full path to the zotero-mcp executable."""
# Try to find the executable in the PATH
exe_name = "zotero-mcp"
if sys.platform == "win32":
exe_name += ".exe"
exe_path = shutil.which(exe_name)
if exe_path:
print(f"Found zotero-mcp in PATH at: {exe_path}")
return exe_path
# If not found in PATH, try to find it in common installation directories
potential_paths = []
# User site-packages
import site
for site_path in site.getsitepackages():
potential_paths.append(Path(site_path) / "bin" / exe_name)
# User's home directory
potential_paths.append(Path.home() / ".local" / "bin" / exe_name)
# Virtual environment
if "VIRTUAL_ENV" in os.environ:
potential_paths.append(Path(os.environ["VIRTUAL_ENV"]) / "bin" / exe_name)
# Additional common locations
if sys.platform == "darwin": # macOS
potential_paths.append(Path("/usr/local/bin") / exe_name)
potential_paths.append(Path("/opt/homebrew/bin") / exe_name)
for path in potential_paths:
if path.exists() and os.access(path, os.X_OK):
print(f"Found zotero-mcp at: {path}")
return str(path)
# If still not found, search in common directories
print("Searching for zotero-mcp in common locations...")
try:
# On Unix-like systems, try using the 'find' command
if sys.platform != 'win32':
import subprocess
result = subprocess.run(
["find", os.path.expanduser("~"), "-name", "zotero-mcp", "-type", "f", "-executable"],
capture_output=True, text=True, timeout=10
)
paths = result.stdout.strip().split('\n')
if paths and paths[0]:
print(f"Found zotero-mcp at {paths[0]}")
return paths[0]
except Exception as e:
print(f"Error searching for zotero-mcp: {e}")
print("Warning: Could not find zotero-mcp executable.")
print("Make sure zotero-mcp is installed and in your PATH.")
return None
def find_claude_config():
"""Find Claude Desktop config file path."""
config_paths = []
# macOS
if sys.platform == "darwin":
# Try both old and new paths
config_paths.append(Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json")
config_paths.append(Path.home() / "Library" / "Application Support" / "Claude Desktop" / "claude_desktop_config.json")
# Windows
elif sys.platform == "win32":
appdata = os.environ.get("APPDATA")
if appdata:
config_paths.append(Path(appdata) / "Claude" / "claude_desktop_config.json")
config_paths.append(Path(appdata) / "Claude Desktop" / "claude_desktop_config.json")
# Linux
else:
config_home = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')
config_paths.append(Path(config_home) / "Claude" / "claude_desktop_config.json")
config_paths.append(Path(config_home) / "Claude Desktop" / "claude_desktop_config.json")
# Check all possible locations
for path in config_paths:
if path.exists():
print(f"Found Claude Desktop config at: {path}")
return path
# Return the default path for the platform if not found
# We'll use the newer "Claude Desktop" path as default
if sys.platform == "darwin": # macOS
default_path = Path.home() / "Library" / "Application Support" / "Claude Desktop" / "claude_desktop_config.json"
elif sys.platform == "win32": # Windows
appdata = os.environ.get("APPDATA", "")
default_path = Path(appdata) / "Claude Desktop" / "claude_desktop_config.json"
else: # Linux and others
config_home = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')
default_path = Path(config_home) / "Claude Desktop" / "claude_desktop_config.json"
print(f"Claude Desktop config not found. Using default path: {default_path}")
return default_path
def setup_database_config(existing_db_config: dict = None) -> dict:
"""Interactive setup for PostgreSQL database configuration."""
print("\n=== PostgreSQL Database Configuration ===")
if existing_db_config:
print("Found existing database configuration:")
print(f" - Host: {existing_db_config.get('host', 'unknown')}")
print(f" - Port: {existing_db_config.get('port', 'unknown')}")
print(f" - Database: {existing_db_config.get('database', 'unknown')}")
print(f" - Username: {existing_db_config.get('username', 'unknown')}")
print("Would you like to keep your existing database configuration? (y/n): ", end="")
if input().strip().lower() in ['y', 'yes']:
return existing_db_config
print("Configure PostgreSQL database connection for vector storage.")
print("Make sure PostgreSQL 15+ with pg-vector extension is installed and running.")
# Database connection settings
host = input("Database host [localhost]: ").strip() or "localhost"
port = input("Database port [5432]: ").strip() or "5432"
database = input("Database name [zotero_mcp]: ").strip() or "zotero_mcp"
username = input("Database username [zotero_user]: ").strip() or "zotero_user"
password = getpass.getpass("Database password: ").strip()
# Test connection
print("\nTesting database connection...")
try:
import psycopg2
conn = psycopg2.connect(
host=host,
port=int(port),
database=database,
user=username,
password=password
)
conn.close()
print("✅ Database connection successful!")
except Exception as e:
print(f"⚠️ Database connection failed: {e}")
print("Continuing with configuration, but please verify your database setup.")
return {
"host": host,
"port": int(port),
"database": database,
"username": username,
"password": password,
"schema": "public",
"pool_size": 5
}
def setup_embedding_provider(existing_embedding_config: dict = None) -> dict:
"""Interactive setup for embedding provider configuration."""
print("\n=== Embedding Provider Configuration ===")
if existing_embedding_config:
provider = existing_embedding_config.get("provider", "unknown")
print(f"Found existing embedding configuration: {provider}")
print("Would you like to keep your existing embedding configuration? (y/n): ", end="")
if input().strip().lower() in ['y', 'yes']:
return existing_embedding_config
print("Configure embedding provider for semantic search.")
print("Choose between OpenAI (cloud) or Ollama (local) for generating embeddings.")
# Choose embedding provider
print("\nAvailable embedding providers:")
print("1. OpenAI - High quality cloud embeddings (recommended)")
print("2. Ollama - Free local embeddings (requires Ollama installation)")
while True:
choice = input("\nChoose embedding provider (1-2): ").strip()
if choice in ["1", "2"]:
break
print("Please enter 1 or 2")
config = {}
if choice == "1":
config["provider"] = "openai"
# Choose OpenAI model
print("\nOpenAI embedding models:")
print("1. text-embedding-3-small (recommended, 1536 dimensions)")
print("2. text-embedding-3-large (higher quality, 3072 dimensions)")
print("3. text-embedding-ada-002 (legacy, 1536 dimensions)")
while True:
model_choice = input("Choose OpenAI model (1-3): ").strip()
if model_choice in ["1", "2", "3"]:
break
print("Please enter 1, 2, or 3")
model_map = {
"1": "text-embedding-3-small",
"2": "text-embedding-3-large",
"3": "text-embedding-ada-002"
}
config["openai"] = {
"model": model_map[model_choice],
"batch_size": 100,
"rate_limit_rpm": 3000
}
# Get API key
api_key = getpass.getpass("Enter your OpenAI API key (hidden): ").strip()
if api_key:
config["openai"]["api_key"] = api_key
else:
print("Warning: No API key provided. Set OPENAI_API_KEY environment variable.")
elif choice == "2":
config["provider"] = "ollama"
# Ollama configuration
host = input("Ollama host [http://localhost:11434]: ").strip() or "http://localhost:11434"
print("\nPopular Ollama embedding models:")
print("1. nomic-embed-text (recommended, general purpose)")
print("2. all-minilm (lightweight and fast)")
print("3. mxbai-embed-large (high quality)")
print("4. Custom model name")
while True:
model_choice = input("Choose Ollama model (1-4): ").strip()
if model_choice in ["1", "2", "3", "4"]:
break
print("Please enter 1, 2, 3, or 4")
if model_choice == "1":
model = "nomic-embed-text"
elif model_choice == "2":
model = "all-minilm"
elif model_choice == "3":
model = "mxbai-embed-large"
else:
model = input("Enter custom model name: ").strip()
config["ollama"] = {
"host": host,
"model": model,
"timeout": 60
}
# Test Ollama connection
print(f"\nTesting Ollama connection to {host}...")
try:
import requests
response = requests.get(f"{host}/api/tags", timeout=5)
if response.status_code == 200:
print("✅ Ollama connection successful!")
tags = response.json().get("models", [])
model_names = [tag["name"] for tag in tags]
if model in model_names:
print(f"✅ Model '{model}' is available!")
else:
print(f"⚠️ Model '{model}' not found. Available models: {model_names}")
print(f"Run: ollama pull {model}")
else:
print("⚠️ Ollama connection failed - server not responding")
except Exception as e:
print(f"⚠️ Ollama connection test failed: {e}")
print("Make sure Ollama is installed and running")
return config
def setup_semantic_search(existing_config: dict = None, semantic_config_only_arg: bool = False) -> dict:
"""Interactive setup for complete semantic search configuration."""
print("\n=== Semantic Search Configuration ===")
if existing_config:
provider = existing_config.get("embedding", {}).get("provider", "unknown")
print(f"Found existing configuration with {provider} embeddings")
print("Would you like to keep your existing configuration? (y/n): ", end="")
if input().strip().lower() in ['y', 'yes']:
return existing_config
print("Reconfiguring will require a database rebuild.")
config = {}
# Database configuration
existing_db_config = existing_config.get("database") if existing_config else None
config["database"] = setup_database_config(existing_db_config)
# Embedding provider configuration
existing_embedding_config = existing_config.get("embedding") if existing_config else None
config["embedding"] = setup_embedding_provider(existing_embedding_config)
# Chunking configuration
print("\n=== Text Processing Configuration ===")
chunk_size = input("Chunk size for text processing [1000]: ").strip() or "1000"
overlap = input("Chunk overlap [100]: ").strip() or "100"
config["chunking"] = {
"chunk_size": int(chunk_size),
"overlap": int(overlap),
"min_chunk_size": 100,
"max_chunks_per_item": 10,
"chunking_strategy": "sentences"
}
# Semantic search settings
config["semantic_search"] = {
"similarity_threshold": 0.7,
"max_results": 50,
"update_config": {
"auto_update": False,
"update_frequency": "manual",
"batch_size": 50,
"parallel_workers": 4
}
}
# Configure update frequency
print("\n=== Database Update Configuration ===")
print("Configure how often the semantic search database is updated:")
print("1. Manual - Update only when you run 'zotero-mcp update-db'")
print("2. Auto - Automatically update on server startup")
while True:
update_choice = input("\nChoose update frequency (1-2): ").strip()
if update_choice in ["1", "2"]:
break
print("Please enter 1 or 2")
if update_choice == "1":
config["semantic_search"]["update_config"]["auto_update"] = False
config["semantic_search"]["update_config"]["update_frequency"] = "manual"
print("Database will only be updated manually.")
elif update_choice == "2":
config["semantic_search"]["update_config"]["auto_update"] = True
config["semantic_search"]["update_config"]["update_frequency"] = "startup"
print("Database will be updated every time the server starts.")
return config
def save_config(config: dict, config_path: Path) -> bool:
"""Save configuration to file."""
try:
# Ensure config directory exists
config_dir = config_path.parent
config_dir.mkdir(parents=True, exist_ok=True)
# Write config
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print(f"Configuration saved to: {config_path}")
return True
except Exception as e:
print(f"Error saving configuration: {e}")
return False
def load_config(config_path: Path) -> dict:
"""Load existing configuration."""
if not config_path.exists():
return {}
try:
with open(config_path, 'r') as f:
return json.load(f)
except json.JSONDecodeError as e:
print(f"Warning: Could not parse config file as JSON: {e}")
return {}
except Exception as e:
print(f"Warning: Could not read config file: {e}")
return {}
def update_claude_config(config_path, zotero_mcp_path, local=True, api_key=None, library_id=None, library_type="user", full_config=None):
"""Update Claude Desktop config to add zotero-mcp."""
# Create directory if it doesn't exist
config_dir = config_path.parent
config_dir.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
if config_path.exists():
try:
with open(config_path, 'r') as f:
config = json.load(f)
print(f"Loaded existing config from: {config_path}")
except json.JSONDecodeError:
print(f"Error: Config file at {config_path} is not valid JSON. Creating new config.")
config = {}
else:
print(f"Creating new config file at: {config_path}")
config = {}
# Ensure mcpServers key exists
if "mcpServers" not in config:
config["mcpServers"] = {}
# Create environment settings based on local vs web API
env_settings = {
"ZOTERO_LOCAL": "true" if local else "false"
}
# Add API key and library settings for web API
if not local:
if api_key:
env_settings["ZOTERO_API_KEY"] = api_key
if library_id:
env_settings["ZOTERO_LIBRARY_ID"] = library_id
if library_type:
env_settings["ZOTERO_LIBRARY_TYPE"] = library_type
# Add PostgreSQL database settings if provided
if full_config:
db_config = full_config.get("database", {})
if db_config:
env_settings["ZOTERO_DB_HOST"] = db_config.get("host", "localhost")
env_settings["ZOTERO_DB_PORT"] = str(db_config.get("port", 5432))
env_settings["ZOTERO_DB_NAME"] = db_config.get("database", "zotero_mcp")
env_settings["ZOTERO_DB_USER"] = db_config.get("username", "zotero_user")
env_settings["ZOTERO_DB_PASSWORD"] = db_config.get("password", "")
# Add embedding provider settings
embedding_config = full_config.get("embedding", {})
if embedding_config:
provider = embedding_config.get("provider")
env_settings["ZOTERO_EMBEDDING_PROVIDER"] = provider
if provider == "openai":
openai_config = embedding_config.get("openai", {})
if api_key := openai_config.get("api_key"):
env_settings["OPENAI_API_KEY"] = api_key
if model := openai_config.get("model"):
env_settings["OPENAI_MODEL"] = model
elif provider == "ollama":
ollama_config = embedding_config.get("ollama", {})
if host := ollama_config.get("host"):
env_settings["OLLAMA_HOST"] = host
if model := ollama_config.get("model"):
env_settings["OLLAMA_MODEL"] = model
# Add or update zotero config
config["mcpServers"]["zotero"] = {
"command": zotero_mcp_path,
"env": env_settings
}
# Write updated config
try:
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print(f"\nSuccessfully wrote config to: {config_path}")
except Exception as e:
print(f"Error writing config file: {str(e)}")
return False
return config_path
def main(cli_args=None):
"""Main function to run the setup helper."""
parser = argparse.ArgumentParser(description="Configure zotero-mcp for Claude Desktop")
parser.add_argument("--no-local", action="store_true", help="Configure for Zotero Web API instead of local API")
parser.add_argument("--api-key", help="Zotero API key (only needed with --no-local)")
parser.add_argument("--library-id", help="Zotero library ID (only needed with --no-local)")
parser.add_argument("--library-type", choices=["user", "group"], default="user",
help="Zotero library type (only needed with --no-local)")
parser.add_argument("--config-path", help="Path to Claude Desktop config file")
parser.add_argument("--skip-semantic-search", action="store_true",
help="Skip semantic search configuration")
parser.add_argument("--semantic-config-only", action="store_true",
help="Only configure semantic search, skip Zotero setup")
parser.add_argument("--fix-dimensions", action="store_true",
help="Fix embedding dimension mismatch between database and provider")
# If this is being called from CLI with existing args
if cli_args is not None and hasattr(cli_args, 'no_local'):
args = cli_args
print("Using arguments passed from command line")
else:
# Otherwise parse from command line
args = parser.parse_args()
print("Parsed arguments from command line")
# Handle dimension fix mode
if args.fix_dimensions:
print("🔧 Fixing embedding dimension mismatch...")
try:
from zotero_mcp.fix_dimension_mismatch import fix_dimension_mismatch
import asyncio
success = asyncio.run(fix_dimension_mismatch())
return 0 if success else 1
except ImportError as e:
print(f"Error: Could not import dimension fix utility: {e}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
# Determine config path
config_dir = Path.home() / ".config" / "zotero-mcp"
config_path = config_dir / "config.json"
existing_config = load_config(config_path)
config_changed = False
# Handle semantic search only configuration
if args.semantic_config_only:
print("Configuring semantic search only...")
new_config = setup_semantic_search(existing_config)
config_changed = existing_config != new_config
# only save if config changed
if config_changed:
if save_config(new_config, config_path):
print("\nSemantic search configuration complete!")
print(f"Configuration saved to: {config_path}")
print("\nTo initialize the database, run: zotero-mcp update-db")
return 0
else:
print("\nSemantic search configuration failed.")
return 1
else:
print("\nSemantic search configuration left unchanged.")
return 0
# Find zotero-mcp executable
exe_path = find_executable()
if not exe_path:
print("Error: Could not find zotero-mcp executable.")
return 1
print(f"Using zotero-mcp at: {exe_path}")
# Find Claude Desktop config
claude_config_path = args.config_path
if not claude_config_path:
claude_config_path = find_claude_config()
else:
print(f"Using specified config path: {claude_config_path}")
claude_config_path = Path(claude_config_path)
if not claude_config_path:
print("Error: Could not determine Claude Desktop config path.")
return 1
# Update config
use_local = not args.no_local
api_key = args.api_key
library_id = args.library_id
library_type = args.library_type
# Configure semantic search if not skipped
if not args.skip_semantic_search:
# if there is already a configuration in the config file:
if existing_config:
provider = existing_config.get("embedding", {}).get("provider", "none")
print(f"\nFound existing configuration with {provider} embeddings.")
print("Would you like to reconfigure semantic search? (y/n): ", end="")
# if otherwise, slightly different message...
else:
print("\nWould you like to configure semantic search? (y/n): ", end="")
# Either way:
if input().strip().lower() in ['y', 'yes']:
new_config = setup_semantic_search(existing_config)
if existing_config != new_config:
config_changed = True
existing_config = new_config # Update the config to use
save_config(existing_config, config_path)
print("\nSetup with the following settings:")
print(f" Local API: {use_local}")
if not use_local:
print(f" API Key: {api_key or 'Not provided'}")
print(f" Library ID: {library_id or 'Not provided'}")
print(f" Library Type: {library_type}")
# Update Claude Desktop config
try:
updated_config_path = update_claude_config(
claude_config_path,
exe_path,
local=use_local,
api_key=api_key,
library_id=library_id,
library_type=library_type,
full_config=existing_config
)
if updated_config_path:
print("\nSetup complete!")
print("To use Zotero in Claude Desktop:")
print("1. Restart Claude Desktop if it's running")
print("2. In Claude, type: /tools zotero")
if config_changed and existing_config:
provider = existing_config.get("embedding", {}).get("provider", "none")
print(f"\nSemantic Search:")
print(f"- Configured with {provider} embeddings")
print("- To change the configuration, run: zotero-mcp setup --semantic-config-only")
print("- The config file is located at: ~/.config/zotero-mcp/config.json")
print("- Initialize the database: zotero-mcp update-db --force-rebuild")
elif existing_config:
print("\nSemantic Search:")
print("- To update the database, run: zotero-mcp update-db")
print("- Use zotero_semantic_search tool in Claude for AI-powered search")
if use_local:
print("\nNote: Make sure Zotero desktop is running and the local API is enabled in preferences.")
else:
missing = []
if not api_key:
missing.append("API key")
if not library_id:
missing.append("Library ID")
if missing:
print(f"\nWarning: The following required settings for Web API were not provided: {', '.join(missing)}")
print("You may need to set these as environment variables or reconfigure.")
return 0
else:
print("\nSetup failed. See errors above.")
return 1
except Exception as e:
print(f"\nSetup failed with error: {str(e)}")
return 1
if __name__ == "__main__":
sys.exit(main())