Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 529c75a175 | |||
| fc765fa255 | |||
| 77159ce1cd | |||
| f8635df1c5 | |||
| 1cc9b7c52f | |||
| fdf2b1f2be | |||
| 5630e7d202 | |||
| be705f0cb9 |
@@ -16,14 +16,21 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- image: ghcr.io/pouzor/homelable-backend
|
- image: ghcr.io/pouzor/homelable-backend
|
||||||
|
context: .
|
||||||
dockerfile: Dockerfile.backend
|
dockerfile: Dockerfile.backend
|
||||||
build_args: ""
|
build_args: ""
|
||||||
- image: ghcr.io/pouzor/homelable-frontend
|
- image: ghcr.io/pouzor/homelable-frontend
|
||||||
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
build_args: ""
|
build_args: ""
|
||||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||||
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
build_args: "VITE_STANDALONE=true"
|
build_args: "VITE_STANDALONE=true"
|
||||||
|
- image: ghcr.io/pouzor/homelable-mcp
|
||||||
|
context: ./mcp
|
||||||
|
dockerfile: Dockerfile.mcp
|
||||||
|
build_args: ""
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -55,8 +62,8 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -223,6 +223,12 @@ docker compose up -d mcp
|
|||||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
|
||||||
|
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
|
||||||
|
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
|
||||||
|
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
|
||||||
|
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
|
||||||
|
|
||||||
**3. Configure your AI client:**
|
**3. Configure your AI client:**
|
||||||
|
|
||||||
**Claude Code** — run this command in your terminal:
|
**Claude Code** — run this command in your terminal:
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ async def liveview_canvas(
|
|||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, 1)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
|
custom_style=custom_style,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
|||||||
assert nodes[0]["label"] == "Live Node"
|
assert nodes[0]["label"] == "Live Node"
|
||||||
|
|
||||||
|
|
||||||
|
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||||
|
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||||
|
settings.liveview_key = "test-key"
|
||||||
|
headers = await auth_headers()
|
||||||
|
payload = {
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||||
|
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/liveview?key=test-key")
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["viewport"].get("theme_id") == "matrix"
|
||||||
|
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||||
|
|
||||||
|
|
||||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- homelable
|
- homelable
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
image: ghcr.io/pouzor/homelable-mcp:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: "http://backend:8000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- homelable
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
|||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData, CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
|
|||||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const setTheme = useThemeStore((s) => s.setTheme)
|
||||||
|
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
// - standalone → always ready (localStorage, no key required)
|
// - standalone → always ready (localStorage, no key required)
|
||||||
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
|
|||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
.map((n: ApiNode) => [n.id, true])
|
||||||
)
|
)
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(
|
loadCanvas(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
|
|||||||
const detail: string = err.response.data?.detail ?? ''
|
const detail: string = err.response.data?.detail ?? ''
|
||||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitViewPending || nodes.length === 0) return
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||||
|
|
||||||
|
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
const nestedPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||||
|
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||||
|
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||||
|
expect(ctr?.parentId).toBe('host')
|
||||||
|
expect(ctr?.extent).toBe('parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||||
|
|
||||||
|
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
const styledPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||||
|
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||||
|
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||||
|
})
|
||||||
|
|
||||||
// ── No editing props passed ───────────────────────────────────────────────
|
// ── No editing props passed ───────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show any Access Denied when key is valid', async () => {
|
it('does not show any Access Denied when key is valid', async () => {
|
||||||
|
|||||||
Executable
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install / enable the Homelable MCP server as a systemd service.
|
||||||
|
#
|
||||||
|
# Run interactively as root, inside an LXC or any Debian/Ubuntu host.
|
||||||
|
# Typical Proxmox VE flow: create the LXC via the community-scripts/ProxmoxVE
|
||||||
|
# helper, then run this script inside that LXC.
|
||||||
|
#
|
||||||
|
# Idempotent: re-running is safe. If mcp/.env already exists, the script
|
||||||
|
# keeps it untouched and only refreshes the venv + systemd unit.
|
||||||
|
#
|
||||||
|
# Optional env vars (override defaults / skip the matching prompt):
|
||||||
|
# INSTALL_DIR repo root (default: /opt/homelable)
|
||||||
|
# REPO_URL clone URL if $INSTALL_DIR is empty (default: https://github.com/Pouzor/homelable.git)
|
||||||
|
# REPO_REF branch/tag/commit when cloning (default: main)
|
||||||
|
# SERVICE_USER systemd User= (default: homelable)
|
||||||
|
# MCP_PORT listen port (default: 8001)
|
||||||
|
# MCP_API_KEY client → MCP key (default: prompt, auto-gen on empty)
|
||||||
|
# MCP_SERVICE_KEY MCP → backend key (default: prompt, auto-gen on empty; must match backend .env)
|
||||||
|
# BACKEND_URL backend base URL (default: http://127.0.0.1:8000)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/homelable}"
|
||||||
|
REPO_URL="${REPO_URL:-https://github.com/Pouzor/homelable.git}"
|
||||||
|
REPO_REF="${REPO_REF:-main}"
|
||||||
|
SERVICE_USER="${SERVICE_USER:-homelable}"
|
||||||
|
SERVICE_NAME="homelable-mcp"
|
||||||
|
MCP_PORT="${MCP_PORT:-8001}"
|
||||||
|
DEFAULT_BACKEND_URL="http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||||
|
fail() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || fail "Run as root (sudo bash $0)."
|
||||||
|
|
||||||
|
log "Installing OS dependencies (git, python3-venv, curl)"
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||||
|
git python3 python3-venv python3-pip curl iproute2 >/dev/null
|
||||||
|
|
||||||
|
MCP_DIR="$INSTALL_DIR/mcp"
|
||||||
|
if [[ ! -d "$MCP_DIR" ]]; then
|
||||||
|
log "Cloning $REPO_URL ($REPO_REF) → $INSTALL_DIR"
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
[[ -f "$MCP_DIR/requirements.txt" ]] || fail "Missing $MCP_DIR/requirements.txt — repo layout unexpected."
|
||||||
|
|
||||||
|
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${MCP_PORT}$"; then
|
||||||
|
warn "Port $MCP_PORT already in use. If it's a previous $SERVICE_NAME instance this is fine; otherwise abort and free the port."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
log "Creating service user '$SERVICE_USER'"
|
||||||
|
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_FILE="$MCP_DIR/.env"
|
||||||
|
gen_key() { python3 -c "import secrets;print('$1' + secrets.token_hex(24))"; }
|
||||||
|
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
log ".env already present at $ENV_FILE — keeping existing values"
|
||||||
|
else
|
||||||
|
[[ -f "$MCP_DIR/.env.example" ]] || fail "Missing $MCP_DIR/.env.example"
|
||||||
|
log "No .env found — generating one (press Enter to accept defaults)"
|
||||||
|
|
||||||
|
api_key="${MCP_API_KEY:-}"
|
||||||
|
svc_key="${MCP_SERVICE_KEY:-}"
|
||||||
|
backend_url="${BACKEND_URL:-}"
|
||||||
|
|
||||||
|
if [[ -z "$api_key" ]]; then
|
||||||
|
default_api_key="$(gen_key mcp_sk_)"
|
||||||
|
read -rp "MCP_API_KEY (client → MCP) [default: auto-generate]: " api_key
|
||||||
|
api_key="${api_key:-$default_api_key}"
|
||||||
|
fi
|
||||||
|
if [[ -z "$svc_key" ]]; then
|
||||||
|
default_svc_key="$(gen_key svc_)"
|
||||||
|
read -rp "MCP_SERVICE_KEY (MCP → backend, must match backend .env) [default: auto-generate]: " svc_key
|
||||||
|
svc_key="${svc_key:-$default_svc_key}"
|
||||||
|
fi
|
||||||
|
if [[ -z "$backend_url" ]]; then
|
||||||
|
read -rp "BACKEND_URL [$DEFAULT_BACKEND_URL]: " backend_url
|
||||||
|
backend_url="${backend_url:-$DEFAULT_BACKEND_URL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
cat >"$ENV_FILE" <<EOF
|
||||||
|
MCP_API_KEY=$api_key
|
||||||
|
MCP_SERVICE_KEY=$svc_key
|
||||||
|
BACKEND_URL=$backend_url
|
||||||
|
EOF
|
||||||
|
log "Wrote $ENV_FILE (mode 600)"
|
||||||
|
warn "If the backend runs elsewhere, set the SAME MCP_SERVICE_KEY in its .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
VENV="$MCP_DIR/.venv"
|
||||||
|
if [[ ! -d "$VENV" ]]; then
|
||||||
|
log "Creating venv at $VENV"
|
||||||
|
python3 -m venv "$VENV"
|
||||||
|
fi
|
||||||
|
log "Installing Python deps"
|
||||||
|
"$VENV/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$VENV/bin/pip" install --quiet -r "$MCP_DIR/requirements.txt"
|
||||||
|
|
||||||
|
chown -R "$SERVICE_USER":"$SERVICE_USER" "$MCP_DIR"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
log "Writing $UNIT"
|
||||||
|
cat >"$UNIT" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Homelable MCP server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$SERVICE_USER
|
||||||
|
WorkingDirectory=$MCP_DIR
|
||||||
|
EnvironmentFile=$ENV_FILE
|
||||||
|
ExecStart=$VENV/bin/uvicorn app.main:app --host 0.0.0.0 --port $MCP_PORT
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now "$SERVICE_NAME"
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "Waiting for MCP to come up on :$MCP_PORT"
|
||||||
|
ok=0
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if curl -fsS "http://127.0.0.1:${MCP_PORT}/health" >/dev/null 2>&1; then
|
||||||
|
ok=1; break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [[ "$ok" -ne 1 ]]; then
|
||||||
|
warn "MCP did not respond on /health within 10s. Check: journalctl -u $SERVICE_NAME -n 50"
|
||||||
|
else
|
||||||
|
log "MCP server is up."
|
||||||
|
fi
|
||||||
|
|
||||||
|
LXC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
API_KEY_VALUE="$(grep -E '^MCP_API_KEY=' "$ENV_FILE" | cut -d= -f2-)"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
MCP server installed.
|
||||||
|
|
||||||
|
Service: $SERVICE_NAME (systemctl status $SERVICE_NAME)
|
||||||
|
Listen: http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp
|
||||||
|
Env file: $ENV_FILE
|
||||||
|
Logs: journalctl -u $SERVICE_NAME -f
|
||||||
|
|
||||||
|
Claude Code client setup:
|
||||||
|
claude mcp add --transport sse homelable http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp \\
|
||||||
|
--header "X-API-Key: $API_KEY_VALUE"
|
||||||
|
----------------------------------------------------------------
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user