import os import requests import logging from fastmcp import FastMCP # --- Configuration --- VIKUNJA_URL = os.getenv("VIKUNJA_URL") VIKUNJA_USERNAME = os.getenv("VIKUNJA_USERNAME") VIKUNJA_PASSWORD = os.getenv("VIKUNJA_PASSWORD") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") DEBUG_TASK_MATCHES = os.getenv("DEBUG_TASK_MATCHES", "false").lower() in ("1", "true", "yes") # --- MCP Application Setup --- mcp = FastMCP() session = requests.Session() # Configure basic logging. Level can be controlled with LOG_LEVEL env var. level = getattr(logging, LOG_LEVEL.upper(), logging.INFO) logging.basicConfig(level=level, format='%(asctime)s %(levelname)s %(message)s') logger = logging.getLogger(__name__) if DEBUG_TASK_MATCHES: logger.info("DEBUG_TASK_MATCHES is enabled: per-task match attempts will be logged at INFO level") # --- Input Validation --- if not all([VIKUNJA_URL, VIKUNJA_USERNAME, VIKUNJA_PASSWORD]): print("Error: Please set the VIKUNJA_URL, VIKUNJA_USERNAME, and VIKUNJA_PASSWORD environment variables.") exit(1) @mcp.tool() def login(): """ Authenticates with the Vikunja API to get a session token. """ global session try: response = session.post( f"{VIKUNJA_URL}/api/v1/login", json={"username": VIKUNJA_USERNAME, "password": VIKUNJA_PASSWORD} ) response.raise_for_status() token = response.json().get("token") if not token: return "Login failed: Token not found in response." session.headers.update({"Authorization": f"Bearer {token}"}) return "Login successful. Token stored for session." except requests.exceptions.RequestException as e: return f"Login failed: {e}" @mcp.tool() def search_tasks(query: str): """ Searches for tasks in Vikunja. :param query: The search string to use for finding tasks. """ if "Authorization" not in session.headers: return "Please run the 'login' command first." try: # Vikunja does not expose a /tasks/search endpoint in the public API. # Fetch all tasks and filter client-side by title/description. logger.info("search_tasks: fetching all tasks from %s", f"{VIKUNJA_URL}/api/v1/tasks/all") all_tasks = [] page = 1 per_page = 50 while True: response = session.get(f"{VIKUNJA_URL}/api/v1/tasks/all?page={page}&limit={per_page}") response.raise_for_status() data = response.json() tasks = data if isinstance(data, list) else data.get("tasks", []) if not tasks: break all_tasks.extend(tasks) page += 1 tasks = all_tasks q = (query or "").strip() logger.info("search_tasks: raw query='%s'", q) if not q: logger.info("search_tasks: empty query, returning %d tasks", len(tasks)) return tasks terms = [t.lower() for t in q.split() if t] logger.info("search_tasks: parsed terms=%s", terms) def matches(task: dict) -> bool: title = (task.get("title") or "") description = (task.get("description") or "") title_l = title.lower() desc_l = description.lower() for term in terms: if term in title_l or term in desc_l: logger.debug("search_tasks: task id=%s matched term='%s' (title=%r, description=%r)", task.get("id"), term, title, description) return True else: logger.debug("search_tasks: task id=%s did not match term='%s' (title=%r)", task.get("id"), term, title) return False filtered = [t for t in tasks if matches(t)] logger.info("search_tasks: fetched=%d filtered=%d", len(tasks), len(filtered)) return filtered except requests.exceptions.RequestException as e: logger.exception("search_tasks: request failed") return f"Error searching tasks: {e}" @mcp.tool() def add_task(project_id: int, title: str, description: str = ""): """ Adds a new task to a Vikunja project. :param project_id: The ID of the project to add the task to. :param title: The title of the new task. :param description: An optional description for the task. """ if "Authorization" not in session.headers: return "Please run the 'login' command first." task_payload = { "project_id": project_id, "title": title, "description": description } try: response = session.post( f"{VIKUNJA_URL}/api/v1/projects/{project_id}/tasks", json=task_payload ) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: return f"Error adding task: {e}" if __name__ == "__main__": print("--- Vikunja MCP Client ---") print("Available commands: login, search_tasks, add_task, help, exit") mcp.run()