feat: initial corvid — standalone ticket service extracted from homelab-dashboard

Extracts the ticket/attachment system into a reusable pip-installable package.
Centralises homelab MCP server (tickets, attachments, system logs) here.

- corvid package: models, router (make_router factory), config, standalone main
- Alembic migration chain for fresh standalone deployments
- Docker Compose for standalone deployment (port 8090)
- mcp_server.py: moved from homelab-dashboard, CORVID_REPO_PATH configurable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoimhinr 2026-05-25 19:23:07 +02:00
commit 37b2c21720
27 changed files with 1072 additions and 0 deletions

87
CLAUDE.md Normal file
View file

@ -0,0 +1,87 @@
# corvid
Standalone ticket-management service for AI agents. FastAPI + Postgres + MCP server.
Extracted from `homelab-dashboard` — the `homelab-dashboard` app continues to use
corvid as a pip package (Option A integration).
## Package
```
corvid/
models.py — Ticket, TicketAttachment, Base (SQLAlchemy ORM)
router.py — make_router(api_key, get_db, require_user=None) → APIRouter
config.py — DATABASE_URL, TICKETS_API_KEY env vars
main.py — standalone FastAPI app (no web UI)
database.py — engine, get_db
alembic/ — migration chain for standalone deployments
mcp_server.py — FastMCP server; primary agent interface
```
## MCP server
`mcp_server.py` is the centralized homelab MCP server. It exposes:
- Ticket CRUD + attachments (via `HOMEDASH_URL` API)
- Homelab system logs (`/api/logs`)
Configure in `~/.claude/mcp.json`:
```json
"homelab-tickets": {
"command": "python3",
"args": ["/home/caoimhinr/Projects/corvid/mcp_server.py"],
"env": {
"HOMEDASH_URL": "https://homedash.welvaert.org",
"HOMEDASH_API_KEY": "<TICKETS_API_KEY>"
}
}
```
`TICKETS_API_KEY` value is in `pve/ansible/host_vars/hetzner.yml` (gitignored).
## Standalone deployment
```bash
cp .env.example .env # set DATABASE_URL and TICKETS_API_KEY
docker compose up -d
```
Default port: `8090` (maps to internal `8080`).
## homelab-dashboard integration
The dashboard depends on corvid as an editable pip install:
```bash
# Local dev
pip install -e ~/Projects/corvid
# VPS deploy — corvid is rsynced alongside the dashboard, then installed
rsync -az ~/Projects/corvid/ root@157.180.29.73:/opt/corvid/
ssh root@157.180.29.73 'pip install -e /opt/corvid'
```
The dashboard's `Dockerfile` copies corvid source before installing:
```dockerfile
COPY ../corvid/ /tmp/corvid/ # requires parent-dir build context
RUN pip install --no-cache-dir /tmp/corvid/
```
See `homelab-dashboard/CLAUDE.md` for the full deploy procedure.
## Local dev
```bash
pip install -e ".[dev,mcp]"
TICKETS_API_KEY="" DATABASE_URL="sqlite+aiosqlite:///dev.db" \
uvicorn corvid.main:app --reload --port 8090
```
## DB migrations
Alembic runs at startup (`entrypoint.sh`). Add new migrations in
`alembic/versions/` following the `NNNN_<slug>.py` naming convention.
The dashboard keeps its own separate migration chain — corvid's migrations
are only for fresh standalone deployments.

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir -e ".[mcp]"
COPY alembic.ini .
COPY alembic/ alembic/
COPY corvid/ corvid/
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
EXPOSE 8080
CMD ["./entrypoint.sh"]

41
alembic.ini Normal file
View file

@ -0,0 +1,41 @@
[alembic]
script_location = alembic
file_template = %%(version_num)s_%%(slug)s
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

33
alembic/env.py Normal file
View file

@ -0,0 +1,33 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
alembic_cfg = context.config
if alembic_cfg.config_file_name is not None:
fileConfig(alembic_cfg.config_file_name)
from corvid import config as app_config
from corvid.models import Base # noqa: E402 — registers models
target_metadata = Base.metadata
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
engine = create_async_engine(app_config.DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(do_run_migrations)
await engine.dispose()
run_migrations_online = lambda: asyncio.run(run_async_migrations())
run_migrations_online()

24
alembic/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,53 @@
"""tickets and ticket_attachments initial schema
Revision ID: 0001
Revises:
Create Date: 2026-05-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tickets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("guid", sa.String(36), nullable=False, unique=True),
sa.Column("user", sa.String(64), nullable=False, server_default="kevin"),
sa.Column("title", sa.String(256), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
sa.Column("type", sa.String(16), nullable=False, server_default="feature"),
sa.Column("parent_id", sa.Integer(),
sa.ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True),
sa.Column("effort", sa.Integer(), nullable=True),
sa.Column("startup_script", sa.Text(), nullable=True),
)
op.create_index("ix_tickets_parent_id", "tickets", ["parent_id"])
op.create_table(
"ticket_attachments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("ticket_id", sa.Integer(),
sa.ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False),
sa.Column("title", sa.String(256), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("created_by", sa.String(64), nullable=False, server_default="kevin"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_ticket_attachments_ticket_id", "ticket_attachments", ["ticket_id"])
def downgrade() -> None:
op.drop_index("ix_ticket_attachments_ticket_id", table_name="ticket_attachments")
op.drop_table("ticket_attachments")
op.drop_index("ix_tickets_parent_id", table_name="tickets")
op.drop_table("tickets")

18
corvid.egg-info/PKG-INFO Normal file
View file

@ -0,0 +1,18 @@
Metadata-Version: 2.4
Name: corvid
Version: 0.1.0
Summary: Standalone ticket management service for AI agents
Requires-Python: >=3.12
Requires-Dist: fastapi>=0.115
Requires-Dist: sqlalchemy[asyncio]>=2.0
Requires-Dist: asyncpg>=0.30
Requires-Dist: uvicorn[standard]>=0.32
Requires-Dist: alembic>=1.14
Requires-Dist: pydantic>=2.0
Provides-Extra: mcp
Requires-Dist: mcp[cli]>=1.0; extra == "mcp"
Requires-Dist: httpx>=0.28; extra == "mcp"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: aiosqlite>=0.20; extra == "dev"

View file

@ -0,0 +1,12 @@
pyproject.toml
corvid/__init__.py
corvid/config.py
corvid/database.py
corvid/main.py
corvid/models.py
corvid/router.py
corvid.egg-info/PKG-INFO
corvid.egg-info/SOURCES.txt
corvid.egg-info/dependency_links.txt
corvid.egg-info/requires.txt
corvid.egg-info/top_level.txt

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,15 @@
fastapi>=0.115
sqlalchemy[asyncio]>=2.0
asyncpg>=0.30
uvicorn[standard]>=0.32
alembic>=1.14
pydantic>=2.0
[dev]
pytest>=8.0
pytest-asyncio>=0.24
aiosqlite>=0.20
[mcp]
mcp[cli]>=1.0
httpx>=0.28

View file

@ -0,0 +1 @@
corvid

1
corvid/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
corvid/config.py Normal file
View file

@ -0,0 +1,4 @@
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://corvid:corvid@postgres:5432/corvid")
TICKETS_API_KEY = os.getenv("TICKETS_API_KEY", "")

12
corvid/database.py Normal file
View file

@ -0,0 +1,12 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from .config import DATABASE_URL
from .models import Base # noqa: F401 — re-exported for alembic
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
yield session

22
corvid/main.py Normal file
View file

@ -0,0 +1,22 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .config import TICKETS_API_KEY
from .database import get_db
from .router import make_router
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(title="Corvid", lifespan=lifespan)
app.include_router(make_router(api_key=TICKETS_API_KEY, get_db=get_db))
@app.get("/health")
async def health():
return {"status": "ok"}

53
corvid/models.py Normal file
View file

@ -0,0 +1,53 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class TicketAttachment(Base):
__tablename__ = "ticket_attachments"
id: Mapped[int] = mapped_column(primary_key=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
title: Mapped[str] = mapped_column(String(256), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc))
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="attachments")
class Ticket(Base):
__tablename__ = "tickets"
id: Mapped[int] = mapped_column(primary_key=True)
guid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False,
default=lambda: str(uuid.uuid4()))
user: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin")
title: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
type: Mapped[str] = mapped_column(String(16), nullable=False, default="feature")
parent_id: Mapped[int | None] = mapped_column(ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
effort: Mapped[int | None] = mapped_column(Integer, nullable=True)
startup_script: Mapped[str | None] = mapped_column(Text, nullable=True)
attachments: Mapped[list["TicketAttachment"]] = relationship(
"TicketAttachment",
back_populates="ticket",
cascade="all, delete-orphan",
order_by="TicketAttachment.created_at",
lazy="selectin",
)
@property
def human_id(self) -> str:
return f"HOME-{self.id:03d}"

304
corvid/router.py Normal file
View file

@ -0,0 +1,304 @@
import uuid
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Ticket, TicketAttachment
VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"}
VALID_TYPES = {"feature", "bug", "chore", "project"}
VALID_USERS = {"kevin", "claude"}
class TicketCreate(BaseModel):
user: str = "kevin"
title: str
description: str | None = None
status: str = "open"
type: str = "feature"
parent_id: int | None = None
effort: int | None = None
startup_script: str | None = None
class TicketUpdate(BaseModel):
user: str | None = None
title: str | None = None
description: str | None = None
status: str | None = None
type: str | None = None
parent_id: int | None = None
effort: int | None = None
startup_script: str | None = None
class AttachmentCreate(BaseModel):
title: str
content: str
created_by: str = "kevin"
class AttachmentUpdate(BaseModel):
title: str | None = None
content: str | None = None
async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
child_ids = (await db.execute(
select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id)
)).scalars().all()
return {
"id": t.id,
"human_id": t.human_id,
"guid": t.guid,
"user": t.user,
"title": t.title,
"description": t.description,
"submitted_at": t.submitted_at.isoformat() if t.submitted_at else None,
"status": t.status,
"type": t.type,
"parent_id": t.parent_id,
"child_ids": list(child_ids),
"attachment_count": len(t.attachments),
"effort": t.effort,
"startup_script": t.startup_script,
}
def _att_dict(a: TicketAttachment) -> dict:
return {
"id": a.id,
"ticket_id": a.ticket_id,
"title": a.title,
"content": a.content,
"created_by": a.created_by,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none()
if not t:
raise HTTPException(404, "ticket not found")
return t
def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
"""
Return a FastAPI APIRouter with all /api/tickets/* endpoints.
Args:
api_key: Value of TICKETS_API_KEY requests presenting this in
X-Api-Key are authenticated as the service account.
get_db: FastAPI dependency yielding an AsyncSession.
require_user: Optional FastAPI dependency / callable(request) that
returns a user dict or raises. When provided, web
sessions are accepted alongside the API key. When
omitted, only API-key auth is accepted.
"""
router = APIRouter()
def _api_key_ok(x_api_key: str | None) -> bool:
return bool(api_key and x_api_key == api_key)
def require_ticket_auth(
request: Request,
x_api_key: str | None = Header(default=None),
):
if _api_key_ok(x_api_key):
return {"username": "claude", "email": "claude@internal"}
if require_user is not None:
return require_user(request)
raise HTTPException(401, "unauthorized")
# ── Ticket endpoints ──────────────────────────────────────────────────────
@router.get("/api/tickets")
async def list_tickets(
status: list[str] = Query(default=[]),
type: list[str] = Query(default=[]),
q: str = Query(default=""),
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
stmt = select(Ticket).order_by(Ticket.submitted_at.desc())
if status:
stmt = stmt.where(Ticket.status.in_(status))
if type:
stmt = stmt.where(Ticket.type.in_(type))
if q:
term = f"%{q}%"
stmt = stmt.where(or_(Ticket.title.ilike(term), Ticket.description.ilike(term)))
rows = (await db.execute(stmt)).scalars().all()
return [await _ticket_dict(t, db) for t in rows]
@router.post("/api/tickets", status_code=201)
async def create_ticket(
body: TicketCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
if body.status not in VALID_STATUSES:
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
if body.type not in VALID_TYPES:
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
if body.user not in VALID_USERS:
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
if body.effort is not None and not (1 <= body.effort <= 10):
raise HTTPException(400, "effort must be between 1 and 10")
if body.parent_id is not None:
await _get_ticket_or_404(db, body.parent_id)
t = Ticket(
guid=str(uuid.uuid4()),
user=body.user,
title=body.title.strip(),
description=body.description,
status=body.status,
type=body.type,
parent_id=body.parent_id,
effort=body.effort,
startup_script=body.startup_script,
)
db.add(t)
await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, t.id), db)
@router.get("/api/tickets/{ticket_id}")
async def get_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
@router.patch("/api/tickets/{ticket_id}")
async def update_ticket(
ticket_id: int,
body: TicketUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
t = await _get_ticket_or_404(db, ticket_id)
if body.status is not None:
if body.status not in VALID_STATUSES:
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
t.status = body.status
if body.type is not None:
if body.type not in VALID_TYPES:
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
t.type = body.type
if body.user is not None:
if body.user not in VALID_USERS:
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
t.user = body.user
if body.title is not None:
t.title = body.title.strip()
if body.description is not None:
t.description = body.description
if "parent_id" in body.model_fields_set:
if body.parent_id is not None:
if body.parent_id == ticket_id:
raise HTTPException(400, "ticket cannot be its own parent")
await _get_ticket_or_404(db, body.parent_id)
t.parent_id = body.parent_id
if "effort" in body.model_fields_set:
if body.effort is not None and not (1 <= body.effort <= 10):
raise HTTPException(400, "effort must be between 1 and 10")
t.effort = body.effort
if "startup_script" in body.model_fields_set:
t.startup_script = body.startup_script
await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
@router.delete("/api/tickets/{ticket_id}")
async def delete_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
t = await _get_ticket_or_404(db, ticket_id)
await db.delete(t)
await db.commit()
return {"ok": True}
# ── Attachment endpoints ──────────────────────────────────────────────────
@router.get("/api/tickets/{ticket_id}/attachments")
async def list_attachments(
ticket_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
await _get_ticket_or_404(db, ticket_id)
rows = (await db.execute(
select(TicketAttachment)
.where(TicketAttachment.ticket_id == ticket_id)
.order_by(TicketAttachment.created_at)
)).scalars().all()
return [_att_dict(a) for a in rows]
@router.post("/api/tickets/{ticket_id}/attachments", status_code=201)
async def create_attachment(
ticket_id: int,
body: AttachmentCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
await _get_ticket_or_404(db, ticket_id)
if not body.title.strip():
raise HTTPException(400, "title is required")
if not body.content.strip():
raise HTTPException(400, "content is required")
if body.created_by not in VALID_USERS:
raise HTTPException(400, f"created_by must be one of {sorted(VALID_USERS)}")
a = TicketAttachment(
ticket_id=ticket_id,
title=body.title.strip(),
content=body.content,
created_by=body.created_by,
)
db.add(a)
await db.commit()
await db.refresh(a)
return _att_dict(a)
@router.patch("/api/tickets/{ticket_id}/attachments/{att_id}")
async def update_attachment(
ticket_id: int,
att_id: int,
body: AttachmentUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
a = await db.get(TicketAttachment, att_id)
if not a or a.ticket_id != ticket_id:
raise HTTPException(404, "attachment not found")
if body.title is not None:
if not body.title.strip():
raise HTTPException(400, "title cannot be empty")
a.title = body.title.strip()
if body.content is not None:
if not body.content.strip():
raise HTTPException(400, "content cannot be empty")
a.content = body.content
await db.commit()
await db.refresh(a)
return _att_dict(a)
@router.delete("/api/tickets/{ticket_id}/attachments/{att_id}")
async def delete_attachment(
ticket_id: int,
att_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
a = await db.get(TicketAttachment, att_id)
if not a or a.ticket_id != ticket_id:
raise HTTPException(404, "attachment not found")
await db.delete(a)
await db.commit()
return {"ok": True}
return router

28
docker-compose.yml Normal file
View file

@ -0,0 +1,28 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: corvid
POSTGRES_PASSWORD: corvid
POSTGRES_DB: corvid
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U corvid"]
interval: 5s
timeout: 5s
retries: 10
app:
build: .
ports:
- "8090:8080"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
volumes:
pgdata:

4
entrypoint.sh Normal file
View file

@ -0,0 +1,4 @@
#!/bin/sh
set -e
alembic upgrade head
exec uvicorn corvid.main:app --host 0.0.0.0 --port 8080

320
mcp_server.py Normal file
View file

@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Corvid MCP server homelab agent tools.
Provides ticket management and homelab monitoring tools for Claude Code agents.
Configure in ~/.claude/mcp.json:
"homelab-tickets": {
"command": "python3",
"args": ["/home/caoimhinr/Projects/corvid/mcp_server.py"],
"env": {
"HOMEDASH_URL": "https://homedash.welvaert.org",
"HOMEDASH_API_KEY": "<TICKETS_API_KEY value>"
}
}
"""
import os
import httpx
from mcp.server.fastmcp import FastMCP
HOMEDASH_URL = os.environ.get("HOMEDASH_URL", "http://localhost:8080").rstrip("/")
API_KEY = os.environ.get("HOMEDASH_API_KEY", "")
mcp = FastMCP("homelab-tickets")
_HEADERS = {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
# Repo root used in generated startup scripts (cd here before running claude)
_REPO_PATH = os.environ.get("CORVID_REPO_PATH",
os.path.expanduser("~/Projects/corvid"))
# MCP tool names always included so the agent can read and update its own ticket
_BASE_TOOLS = [
"Read",
"mcp__homelab-tickets__get_ticket",
"mcp__homelab-tickets__update_ticket",
"mcp__homelab-tickets__add_attachment",
"mcp__homelab-tickets__list_attachments",
]
_HA_KEYWORDS = (
"home assistant", " ha ", "climate", "temperature", "thermostat",
"light", "sensor", "entity", "automation", "scene",
)
_WEB_KEYWORDS = (
"research", "investigate", "look up", "documentation", "readme",
" api ", "spec ", "rfc",
)
def _generate_startup_script(ticket_id: int, human_id: str, title: str,
description: str | None, ticket_type: str) -> str:
text = f"{title} {description or ''}".lower()
tools = list(_BASE_TOOLS)
if ticket_type in ("feature", "bug", "chore"):
tools += ["Edit", "Write", "Bash"]
if any(kw in text for kw in _WEB_KEYWORDS):
tools += ["WebSearch", "WebFetch"]
if any(kw in text for kw in _HA_KEYWORDS):
tools += [
"mcp__homeassistant__GetLiveContext",
"mcp__homeassistant__HassTurnOn",
"mcp__homeassistant__HassTurnOff",
"mcp__homeassistant__HassLightSet",
"mcp__homeassistant__HassClimateSetTemperature",
]
tools_str = ",".join(tools)
prompt = (
f"complete ticket {human_id}: {title}. "
f"Start by calling mcp__homelab-tickets__get_ticket({ticket_id}) to read the full "
f"ticket details, then complete the work and mark the ticket as completed."
)
return (
f"#!/usr/bin/env bash\n"
f"# Startup script for {human_id}: {title}\n"
f"cd {_REPO_PATH}\n"
f'claude "{prompt}" \\\n'
f' --allowedTools "{tools_str}"\n'
)
def _client() -> httpx.Client:
return httpx.Client(base_url=HOMEDASH_URL, headers=_HEADERS, timeout=10.0)
def _check(r: httpx.Response) -> dict | list:
if not r.is_success:
raise RuntimeError(f"API error {r.status_code}: {r.text}")
return r.json()
# ── Ticket tools ──────────────────────────────────────────────────────────────
@mcp.tool()
def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]:
"""
List homelab tickets. Results include attachment_count so you know
which tickets have supporting documents without fetching them.
Args:
status: Filter by status open, ongoing, completed, abandoned.
Leave empty to list all statuses.
type: Filter by type feature, bug, chore, project.
Leave empty to list all types.
q: Full-text search query matched against ticket title and
description (case-insensitive). Leave empty to skip.
"""
with _client() as c:
params = {}
if status:
params["status"] = status
if type:
params["type"] = type
if q:
params["q"] = q
return _check(c.get("/api/tickets", params=params))
@mcp.tool()
def get_ticket(ticket_id: int) -> dict:
"""
Get a single ticket by its numeric ID, including attachment_count.
Use list_attachments to fetch the actual attachment content.
Args:
ticket_id: The integer primary key (e.g. 1 for HOME-001).
"""
with _client() as c:
return _check(c.get(f"/api/tickets/{ticket_id}"))
@mcp.tool()
def create_ticket(
title: str,
description: str = "",
status: str = "open",
type: str = "feature",
user: str = "claude",
parent_id: int = 0,
effort: int = 0,
) -> dict:
"""
Create a new homelab ticket. For longer supporting documents
(proposals, reports, specs) prefer add_attachment over stuffing
everything into description.
Args:
title: Short summary of the work item.
description: Markdown body with context, links, acceptance criteria.
status: open (default), ongoing, completed, abandoned.
type: feature (default), bug, chore, project.
user: Who is submitting kevin or claude (default: claude).
parent_id: ID of the parent ticket (omit or pass 0 for no parent).
effort: Estimated effort rating 110 (omit or pass 0 to leave unset).
"""
body: dict = {
"title": title,
"description": description or None,
"status": status,
"type": type,
"user": user,
}
if parent_id:
body["parent_id"] = parent_id
if effort:
body["effort"] = effort
with _client() as c:
ticket = _check(c.post("/api/tickets", json=body))
script = _generate_startup_script(
ticket["id"], ticket["human_id"], title, description, type
)
ticket = _check(c.patch(f"/api/tickets/{ticket['id']}", json={"startup_script": script}))
return ticket
@mcp.tool()
def delete_ticket(ticket_id: int) -> dict:
"""
Permanently delete a ticket and all its attachments.
Args:
ticket_id: The integer ID of the ticket to delete.
"""
with _client() as c:
return _check(c.delete(f"/api/tickets/{ticket_id}"))
@mcp.tool()
def update_ticket(
ticket_id: int,
title: str = "",
description: str = "",
status: str = "",
type: str = "",
user: str = "",
parent_id: int = -1,
effort: int = -1,
) -> dict:
"""
Update an existing homelab ticket. Only provided (non-empty) fields
are changed; omit a field to leave it unchanged.
Args:
ticket_id: The integer ID of the ticket to update.
title: New title.
description: New markdown description.
status: open, ongoing, completed, abandoned.
type: feature, bug, chore, project.
user: kevin or claude.
parent_id: Set parent ticket ID; pass 0 to clear the parent.
Omit (default -1) to leave unchanged.
effort: Effort rating 110; pass 0 to clear it.
Omit (default -1) to leave unchanged.
"""
body = {}
if title: body["title"] = title
if description: body["description"] = description
if status: body["status"] = status
if type: body["type"] = type
if user: body["user"] = user
if parent_id >= 0:
body["parent_id"] = parent_id if parent_id > 0 else None
if effort >= 0:
body["effort"] = effort if effort > 0 else None
if not body:
raise ValueError("Provide at least one field to update")
with _client() as c:
ticket = _check(c.patch(f"/api/tickets/{ticket_id}", json=body))
if title or description or type:
script = _generate_startup_script(
ticket["id"], ticket["human_id"],
ticket["title"], ticket["description"], ticket["type"],
)
ticket = _check(c.patch(f"/api/tickets/{ticket_id}", json={"startup_script": script}))
return ticket
# ── Attachment tools ──────────────────────────────────────────────────────────
@mcp.tool()
def list_attachments(ticket_id: int) -> list[dict]:
"""
List all markdown attachments on a ticket (title, created_by,
created_at, and full content).
Args:
ticket_id: The integer ID of the parent ticket.
"""
with _client() as c:
return _check(c.get(f"/api/tickets/{ticket_id}/attachments"))
@mcp.tool()
def add_attachment(
ticket_id: int,
title: str,
content: str,
created_by: str = "claude",
) -> dict:
"""
Attach a markdown document to a ticket. Use this for implementation
proposals, test plans, research notes, decision records, or any
supporting document that is too long for the ticket description.
Args:
ticket_id: The integer ID of the parent ticket.
title: Descriptive name, e.g. "Implementation Proposal v1".
content: Full markdown body of the document.
created_by: kevin or claude (default: claude).
"""
with _client() as c:
return _check(c.post(f"/api/tickets/{ticket_id}/attachments", json={
"title": title,
"content": content,
"created_by": created_by,
}))
@mcp.tool()
def delete_attachment(ticket_id: int, attachment_id: int) -> dict:
"""
Permanently delete an attachment from a ticket.
Args:
ticket_id: The integer ID of the parent ticket.
attachment_id: The integer ID of the attachment to delete.
"""
with _client() as c:
return _check(c.delete(f"/api/tickets/{ticket_id}/attachments/{attachment_id}"))
# ── System log tools ──────────────────────────────────────────────────────────
@mcp.tool()
def list_logs(
level: str = "",
source: str = "",
resolved: bool = False,
) -> list[dict]:
"""
List homelab system log entries (infrastructure alerts, camera issues, disk).
Args:
level: Filter by level ERR, WARN, INFO. Leave empty for all.
source: Filter by source homelab, hassgrab, disk. Leave empty for all.
resolved: Include resolved logs (default False = active only).
"""
with _client() as c:
params: dict = {"resolved": str(resolved).lower()}
if level:
params["level"] = level
if source:
params["source"] = source
return _check(c.get("/api/logs", params=params))
if __name__ == "__main__":
mcp.run(transport="stdio")

25
pyproject.toml Normal file
View file

@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "corvid"
version = "0.1.0"
description = "Standalone ticket management service for AI agents"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.30",
"uvicorn[standard]>=0.32",
"alembic>=1.14",
"pydantic>=2.0",
]
[project.optional-dependencies]
mcp = ["mcp[cli]>=1.0", "httpx>=0.28"]
dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "aiosqlite>=0.20"]
[tool.setuptools.packages.find]
where = ["."]
include = ["corvid*"]