Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claude/commands/add-compose-service.md
Original file line number Diff line number Diff line change
@@ -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/<category>/<application>.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 <docker-compose-filename>` and fix any reported issues.
- Pull the container image(s) with the command `docker/labctl.py service pull <category>/<application>` and verify success.
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp"
}
}
}
45 changes: 43 additions & 2 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -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}"
}
},
Comment thread
bubacoder marked this conversation as resolved.
"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}"
}
},
Comment on lines +37 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid leaking the database URL via process args; rely on env-only.

Passing the DSN as a positional argument makes it visible via ps, shell history, and certain logs. You already forward POSTGRES_URL into the container with -e POSTGRES_URL, so the positional arg is redundant. Remove it and keep the env-based wiring.

     "postgres": {
       "type": "stdio",
       "command": "docker",
-      "args": ["run", "--rm", "-i", "-e", "POSTGRES_URL", "docker.io/mcp/postgres:latest", "${input:postgresql-database-url}"],
+      "args": ["run", "--rm", "-i", "-e", "POSTGRES_URL", "docker.io/mcp/postgres:latest"],
       "env": {
         "POSTGRES_URL": "${input:postgresql-database-url}"
       }
     },

Optional hardening:

  • Pin the image to a tag or digest for reproducibility (e.g., docker.io/mcp/postgres:0.x.y or @sha256:...).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"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}"
}
},
"postgres": {
"type": "stdio",
"command": "docker",
"args": ["run", "--rm", "-i", "-e", "POSTGRES_URL", "docker.io/mcp/postgres:latest"],
"env": {
"POSTGRES_URL": "${input:postgresql-database-url}"
}
},
🤖 Prompt for AI Agents
In .vscode/mcp.json around lines 37 to 44, the PostgreSQL DSN is passed as a
positional docker argument (making it visible in process listings) despite also
being exported via the POSTGRES_URL env var; remove the positional
"${input:postgresql-database-url}" from the args array and rely solely on the
env mapping ("-e" / env field) to supply the DSN to the container, and
optionally pin the image to a fixed tag or digest (e.g.,
docker.io/mcp/postgres:0.x.y or @sha256:...) for reproducibility.

"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
Comment thread
bubacoder marked this conversation as resolved.
}
}
}
5 changes: 2 additions & 3 deletions docker/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
141 changes: 141 additions & 0 deletions scripts/task-mcp/find_app_icon.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions scripts/task-mcp/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
fastmcp>=2.10.0
requests>=2.25.0
beautifulsoup4>=4.10.0
21 changes: 21 additions & 0 deletions scripts/task-mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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")
Expand Down