diff --git a/.claude/commands/add-compose-service.md b/.claude/commands/add-compose-service.md new file mode 100644 index 00000000..7572f288 --- /dev/null +++ b/.claude/commands/add-compose-service.md @@ -0,0 +1,22 @@ +# Add Docker Compose Service + +## Variables + +APPLICATION_NAME: $ARGUMENTS +APPLICATION_HOMEPAGE: $ARGUMENTS + +## Instructions + +Implement a container-based service by creating and configuring a Docker Compose file. +Follow the architectural pattern in the docker/guidelines.md file. + +Implementation steps: +- Visit the APPLICATION_NAME homepage at APPLICATION_HOMEPAGE and the application's GitHub page (if available). +- Search the homepage and docs for Docker Compose deployment examples. If none are found, fall back to plain Docker examples. +- Based on the application's type, determine which existing category (subfolder under docker/) the application belongs to. Do not create a new category; use "tools" as a fallback. +- Following docker/guidelines.md and the found examples, create the Docker Compose as "docker//.yaml". +- Ensure the compose file contains a brief description of the project and links to the homepage, GitHub page, and any Docker(-Compose) setup example (if available). +- If the installation guide mentions further improvements (e.g., using an optional external database instead of a built-in one, or enabling SSO), add TODOs for these in the head section of the compose file. +- Also add TODOs for any new environment variables required. If necessary, add them to the "config-example/docker/myhost/.env" file. +- After writing the compose file, run: `pre-commit run --files ` and fix any reported issues. +- Pull the container image(s) with the command `docker/labctl.py service pull /` and verify success. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..c5280c5f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 4aaf416f..47d630d6 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,10 +1,51 @@ // https://code.visualstudio.com/docs/copilot/chat/mcp-servers { + "inputs": [ + { + "type": "promptString", + "id": "github-personal-access-token", + "description": "GitHub personal access token", + "password": true + }, + { + "type": "promptString", + "id": "postgresql-database-url", + "description": "PostgreSQL database URL (e.g. postgres://user:password@localhost:5432/dbname)", + "password": true + } + ], "servers": { - // The "servers" section defines the MCP servers you want to use. "homelab-infra": { - "url": "http://127.0.0.1:9876/mcp/" + "type": "stdio", + "command": "uv", + "args": ["run", "--directory", "scripts/task-mcp", "server.py"] + }, + "context7": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + }, + "github": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github-personal-access-token}" + } + }, + "postgres": { + "type": "stdio", + "command": "docker", + "args": ["run", "--rm", "-i", "-e", "POSTGRES_URL", "docker.io/mcp/postgres:latest", "${input:postgresql-database-url}"], + "env": { + "POSTGRES_URL": "${input:postgresql-database-url}" + } + }, + "playwright": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"] } } } diff --git a/docker/guidelines.md b/docker/guidelines.md index 5d2e22dc..78670335 100644 --- a/docker/guidelines.md +++ b/docker/guidelines.md @@ -164,8 +164,8 @@ When creating a new service, use this template: # Brief description of the service # # 🏠 Home: https://service-homepage.com/ -# 📜 Source: https://github.com/vendor/service -# Documentation: https://docs.service.com/ +# 📦 Source: https://github.com/vendor/service +# 📜 Docs: https://docs.service.com/ --- name: service-name services: @@ -183,7 +183,6 @@ services: - proxy labels: traefik.enable: true - traefik.http.routers.service-name.rule: Host(`service.${MYDOMAIN}`) traefik.http.routers.service-name.middlewares: middleware-name@file traefik.http.services.service-name.loadbalancer.server.port: PORT homepage.group: Category diff --git a/scripts/task-mcp/find_app_icon.py b/scripts/task-mcp/find_app_icon.py new file mode 100644 index 00000000..0f480cf3 --- /dev/null +++ b/scripts/task-mcp/find_app_icon.py @@ -0,0 +1,141 @@ +import requests +import sys +import re +from bs4 import BeautifulSoup +from urllib.parse import urljoin + + +class AppIconFinder: + """ + A class for finding application icons from either the dashboard-icons repository + or by extracting favicons from an application's homepage. + """ + + def __init__(self): + """ + Initialize the AppIconFinder with default headers for HTTP requests. + """ + self.headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + def get_app_icon(self, app_name, homepage_url): + """ + Main function to get an application icon. + + Args: + app_name (str): The name of the application. + homepage_url (str): The URL of the application's homepage. + + Returns: + str: Either the normalized app_name (if found in dashboard-icons), + a favicon URL, or "default" if no icon is found. + """ + # First check if the icon exists in dashboard-icons + dashboard_icon = self._find_dashboard_icon(app_name) + if dashboard_icon: + return dashboard_icon + + # If not, try to find the favicon + favicon_url = self._find_favicon_url(homepage_url) + if favicon_url: + return favicon_url + + # Return default if no favicon found + return "default" + + def _find_dashboard_icon(self, app_name): + normalized_name = app_name.lower().replace(" ", "-") + url = f"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/{normalized_name}.png" + try: + response = requests.head( + url, + headers=self.headers, + timeout=10, + allow_redirects=True + ) + if response.status_code == 200: + return normalized_name + return None + except requests.RequestException: + return None + + def _find_favicon_url(self, homepage_url): + try: + if not homepage_url: + return None + + # Make sure URL has a scheme + homepage_url = homepage_url.strip() + if not homepage_url.startswith(('http://', 'https://')): + homepage_url = 'https://' + homepage_url + + # Fetch the homepage + response = requests.get(homepage_url, headers=self.headers, timeout=10) + response.raise_for_status() + + # Parse the HTML + soup = BeautifulSoup(response.text, 'html.parser') + + # Look for favicon in different ways + # 1. Check for link tags with rel="icon" or rel="shortcut icon" + icon_links = soup.find_all('link', rel=re.compile(r'(shortcut icon|icon|apple-touch-icon)', re.I)) + if icon_links: + # Sort by preference: apple-touch-icon > icon > shortcut icon + def get_priority(link): + rel_attr = link.get('rel', []) + if isinstance(rel_attr, str): + rel_attr = [rel_attr] + rel_lower = ' '.join(rel_attr).lower() + if 'apple-touch-icon' in rel_lower: + return 3 + elif 'icon' in rel_lower and 'shortcut' not in rel_lower: + return 2 + else: + return 1 + + icon_links = sorted(icon_links, key=get_priority, reverse=True) + for link in icon_links: + if 'href' in link.attrs: + # Make relative URLs absolute + favicon_url = urljoin(homepage_url, link['href']) + return favicon_url + + # 2. Check for the default location + default_favicon = urljoin(homepage_url, '/favicon.ico') + favicon_response = requests.head(default_favicon, headers=self.headers, timeout=5) + if favicon_response.status_code == 200: + return default_favicon + return None + except Exception as e: + print(f"Error finding favicon: {e}", file=sys.stderr) + return None + + +def main(): + """ + Test the AppIconFinder with some popular applications. + """ + # Create an instance of AppIconFinder + icon_finder = AppIconFinder() + + # Test cases - popular applications and their homepages + test_cases = [ + ("GitHub", "github.com"), + ("Gitea", "about.gitea.com"), + ("Plex", "plex.tv"), + ("Sonarr", "sonarr.tv"), + ("Radarr", "radarr.video"), + ("Grafana", "grafana.com"), + ("Jellyfin", "jellyfin.org"), + ("HUP", "hup.hu") + ] + + # Test each application + for app_name, homepage in test_cases: + icon_result = icon_finder.get_app_icon(app_name, homepage) + print(f"App: {app_name}, Homepage: {homepage}, Icon: {icon_result}") + + +if __name__ == "__main__": + main() diff --git a/scripts/task-mcp/requirements.txt b/scripts/task-mcp/requirements.txt index 9b2fa8ad..cc57364a 100644 --- a/scripts/task-mcp/requirements.txt +++ b/scripts/task-mcp/requirements.txt @@ -1 +1,3 @@ fastmcp>=2.10.0 +requests>=2.25.0 +beautifulsoup4>=4.10.0 diff --git a/scripts/task-mcp/server.py b/scripts/task-mcp/server.py index 4e90410f..306c5535 100755 --- a/scripts/task-mcp/server.py +++ b/scripts/task-mcp/server.py @@ -14,6 +14,7 @@ from fastmcp.tools import Tool from starlette.requests import Request from starlette.responses import PlainTextResponse +from find_app_icon import AppIconFinder # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -158,6 +159,26 @@ def control_container_service(operation: str, service_name: str) -> str: return f"Error running operation: {e.stderr or str(e)}" +@mcp.tool(name="find-app-icon") +def find_app_icon(app_name: str, homepage_url: str) -> str: + """ + Find an application icon from either the dashboard-icons repository or by extracting favicon from the app's homepage. + + Args: + app_name (str): The name of the application. + homepage_url (str): The URL of the application's homepage. + + Returns: + str: Either the name of the application icon or a favicon URL. + """ + icon_finder = AppIconFinder() + try: + return icon_finder.get_app_icon(app_name, homepage_url) + except Exception as e: + logger.exception("find-app-icon failed for app_name=%r homepage_url=%r: %s", app_name, homepage_url, e) + return "default" + + @mcp.custom_route("/healthz", methods=["GET"]) async def health_check(request: Request) -> PlainTextResponse: return PlainTextResponse("OK")