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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: ruff check .
- run: pytest
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,14 @@ curl -X POST http://<casaos-ip>:8742/entries \
-d '{"source": "finance-agent", "description": "Verdict - MSFT", "keywords": ["MSFT", "verdict"], "data": {}}'
```

Returns the created entry (`201`) or a `400` with an error message for a missing field or invalid JSON body.
Returns the created entry (`201`) or a `400` with an error message for a missing or wrongly-typed field or invalid JSON body.

## Development

```bash
pip install -r requirements.txt -r requirements-dev.txt
ruff check .
pytest
```

Tests run against an in-memory MongoDB (`mongomock-motor`) — no containers needed. CI runs the same two commands on every push and PR.
8 changes: 8 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from motor.motor_asyncio import AsyncIOMotorClient

from app.config import settings

_client: AsyncIOMotorClient | None = None
Expand All @@ -15,6 +16,13 @@ def get_db():
return get_client()[settings.mongodb_db]


async def ensure_indexes():
db = get_db()
await db.entries.create_index([("created_at", -1)])
await db.entries.create_index("source")
await db.entries.create_index("keywords")


async def close_client():
global _client
if _client is not None:
Expand Down
4 changes: 2 additions & 2 deletions app/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ def format_doc(doc: dict) -> dict:
def parse_id(entry_id: str) -> ObjectId:
try:
return ObjectId(entry_id)
except (InvalidId, Exception):
raise ValueError(f"Invalid entry ID: {entry_id}")
except (InvalidId, TypeError):
raise ValueError(f"Invalid entry ID: {entry_id}") from None
51 changes: 34 additions & 17 deletions app/server.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any

import uvicorn
from fastmcp import FastMCP
from pydantic import BaseModel, ValidationError
from starlette.requests import Request
from starlette.responses import JSONResponse

from app.config import settings
from app.database import close_client, get_db
from app.database import close_client, ensure_indexes, get_db
from app.formatting import format_doc, parse_id

MAX_LIMIT = 100


@asynccontextmanager
async def lifespan(server: FastMCP):
await ensure_indexes()
yield
await close_client()

Expand All @@ -31,7 +36,8 @@ async def __call__(self, scope, receive, send):
if scope["type"] == "http":
headers = dict(scope.get("headers", []))
auth = headers.get(b"authorization", b"").decode()
if not auth.startswith("Bearer ") or auth[7:] != settings.api_key:
token = auth[7:] if auth.startswith("Bearer ") else ""
if not secrets.compare_digest(token.encode(), settings.api_key.encode()):
response = JSONResponse({"error": "Unauthorized"}, status_code=401)
await response(scope, receive, send)
return
Expand All @@ -42,14 +48,14 @@ async def _create_entry(
source: str,
description: str,
data: dict[str, Any],
keywords: list[str] = [],
keywords: list[str] | None = None,
) -> dict:
db = get_db()
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
doc = {
"source": source,
"description": description,
"keywords": keywords,
"keywords": keywords or [],
"data": data,
"created_at": now,
"updated_at": now,
Expand All @@ -64,7 +70,7 @@ async def create_entry(
source: str,
description: str,
data: dict[str, Any],
keywords: list[str] = [],
keywords: list[str] | None = None,
) -> dict:
"""Store a new data entry produced by an AI agent."""
return await _create_entry(source, description, data, keywords)
Expand All @@ -78,6 +84,8 @@ async def list_entries(
skip: int = 0,
) -> dict:
"""List stored entries. Optionally filter by source agent or keywords."""
limit = max(1, min(limit, MAX_LIMIT))
skip = max(0, skip)
db = get_db()
query: dict = {}
if source:
Expand Down Expand Up @@ -121,7 +129,7 @@ async def update_entry(
if not updates:
raise ValueError("No fields provided to update")
db = get_db()
updates["updated_at"] = datetime.now(timezone.utc)
updates["updated_at"] = datetime.now(UTC)
result = await db.entries.find_one_and_update(
{"_id": parse_id(entry_id)},
{"$set": updates},
Expand All @@ -142,6 +150,13 @@ async def delete_entry(entry_id: str) -> dict:
return {"deleted": True, "id": entry_id}


class EntryPayload(BaseModel):
source: str
description: str
data: dict[str, Any]
keywords: list[str] = []


async def create_entry_endpoint(request: Request) -> JSONResponse:
"""REST equivalent of create_entry, for callers that can't speak MCP (e.g. hook scripts)."""
try:
Expand All @@ -150,17 +165,19 @@ async def create_entry_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)

try:
source = body["source"]
description = body["description"]
data = body["data"]
except KeyError as e:
return JSONResponse({"error": f"Missing required field: {e.args[0]}"}, status_code=400)
payload = EntryPayload.model_validate(body)
except ValidationError as e:
errors = "; ".join(
f"{'.'.join(str(loc) for loc in err['loc']) or 'body'}: {err['msg']}"
for err in e.errors()
)
return JSONResponse({"error": errors}, status_code=400)

entry = await _create_entry(
source=source,
description=description,
data=data,
keywords=body.get("keywords", []),
source=payload.source,
description=payload.description,
data=payload.data,
keywords=payload.keywords,
)
return JSONResponse(entry, status_code=201)

Expand Down
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP"]
ignore = ["E501"]

[tool.ruff.lint.isort]
known-first-party = ["app"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pytest==8.3.4
pytest-asyncio==0.25.2
mongomock-motor==0.0.36
httpx==0.28.1
ruff==0.9.4
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import os

os.environ.setdefault("API_KEY", "test-api-key")
os.environ.setdefault("UI_USERNAME", "admin")
os.environ.setdefault("UI_PASSWORD", "test-password")

import pytest
from mongomock_motor import AsyncMongoMockClient

from app import database


@pytest.fixture(autouse=True)
def mock_db(monkeypatch):
"""Back every test with a fresh in-memory MongoDB."""
client = AsyncMongoMockClient()
monkeypatch.setattr(database, "_client", client)
yield database.get_db()
11 changes: 11 additions & 0 deletions tests/test_database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from app import database


async def test_ensure_indexes_creates_expected_indexes(mock_db):
await database.ensure_indexes()

index_info = await mock_db.entries.index_information()
indexed_keys = {tuple(info["key"]) for info in index_info.values()}
assert (("created_at", -1),) in indexed_keys
assert (("source", 1),) in indexed_keys
assert (("keywords", 1),) in indexed_keys
40 changes: 40 additions & 0 deletions tests/test_formatting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from datetime import UTC, datetime

import pytest
from bson import ObjectId

from app.formatting import format_doc, parse_id


def test_format_doc_converts_id_and_timestamps():
oid = ObjectId()
now = datetime(2026, 8, 1, 12, 0, 0, tzinfo=UTC)
doc = {"_id": oid, "source": "x", "created_at": now, "updated_at": now}

out = format_doc(doc)

assert out["id"] == str(oid)
assert "_id" not in out
assert out["created_at"] == "2026-08-01T12:00:00+00:00"
assert out["updated_at"] == "2026-08-01T12:00:00+00:00"


def test_format_doc_does_not_mutate_input():
doc = {
"_id": ObjectId(),
"created_at": datetime.now(UTC),
"updated_at": datetime.now(UTC),
}
format_doc(doc)
assert "_id" in doc


def test_parse_id_roundtrip():
oid = ObjectId()
assert parse_id(str(oid)) == oid


@pytest.mark.parametrize("bad", ["not-an-id", "", "68571a2c", 123])
def test_parse_id_invalid_raises_value_error(bad):
with pytest.raises(ValueError, match="Invalid entry ID"):
parse_id(bad)
Loading
Loading