Skip to content
Merged
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Repository Guidelines

## Project Structure & Module Organization
Source code lives in `src/`, with FastAPI entrypoints in `src/api.py` and domain layers grouped by concern (`controllers/`, `services/`, `repositories/`, `models/`, `routes/`, `views/`). Shared settings and helpers sit in `src/settings/` and `src/utils.py`. Tests mirror this layout under `tests/unit/` and `tests/integration/` for focused and end-to-end coverage. Deployment assets stay at the root (`Dockerfile`, `compose.yaml`, `Makefile`), while virtualenv tooling is kept in `infinity_env/`.

## Build, Test, and Development Commands
- `python3 -m pip install -r requirements.txt` prepares runtime dependencies; add `requirements-dev.txt` for local tooling.
- `make format` runs Black and Ruff autofixes across `src/` and `tests/`.
- `make lint` executes Flake8 and Pylint with the repository presets.
- `make test` wraps `pytest` with the configured `tests/` path.
- `make dev` starts Uvicorn on `http://localhost:3000` with hot reload.
- `docker-compose up --build -d` (from `compose.yaml`) builds and runs the API plus MongoDB in containers; use `make clean` to prune stacks.

## Coding Style & Naming Conventions
Target Python 3.12 and a 79-character line length (Black, Ruff, and Pylint enforce this). Prefer module-level functions in `snake_case`, classes in `PascalCase`, and constants in `UPPER_SNAKE_CASE`. Keep FastAPI routers named `<feature>_router` inside `src/routes/`, and align Pydantic models with `CamelCase` class names under `src/models/`. Run `make format` before opening a PR to avoid stylistic churn.

## Testing Guidelines
Pytest drives both unit and integration suites; name files `test_<feature>.py` and group fixtures near usage. Place pure-function tests in `tests/unit/` and API or database flows in `tests/integration/`. Execute `pytest tests/integration/test_environment.py -k simulate` to target scenarios while keeping `--import-mode=importlib` behavior intact. New features should include happy-path and failure-case coverage, plus integration smoke tests when touching MongoDB writes.

## Commit & Pull Request Guidelines
Git history favors concise, uppercase prefixes (`BUG:`, `ENH:`, `MNT:`) followed by a short imperative summary and optional issue reference, e.g. `ENH: streamline rocket encoders (#58)`. Squash commits that fix review feedback before merging. Pull requests should describe intent, list API or schema changes, link to tracking issues, and attach screenshots or sample responses when observable behavior shifts.

## Security & Configuration Tips
Never commit `.env` or credentials; instead, document required keys such as `MONGODB_CONNECTION_STRING` in the PR. Use `src/secrets.py` helpers for secret access rather than inlining values, and prefer Docker secrets or environment variables when deploying.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ $ touch .env && echo MONGODB_CONNECTION_STRING="$ConnectionString" > .env
- Dev: `python3 -m uvicorn src:app --reload --port 3000`
- Prod: `gunicorn -k uvicorn.workers.UvicornWorker src:app -b 0.0.0.0:3000`

## MCP Server
- Infinity API automatically serves an MCP bridge at `/mcp` alongside the REST endpoints.

## Project structure
```
├── README.md # this file
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
motor
dill
python-dotenv
fastapi
uvloop
pydantic
numpy==1.26.4
pymongo
pymongo>=4.15
jsonpickle
gunicorn
uvicorn
Expand All @@ -16,3 +15,4 @@ opentelemetry.instrumentation.requests
opentelemetry-api
opentelemetry-sdk
tenacity
fastmcp
7 changes: 6 additions & 1 deletion src/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@
from src import logger, parse_error
from src.routes import flight, environment, motor, rocket
from src.utils import RocketPyGZipMiddleware
from src.mcp.server import build_mcp

app = FastAPI(
title="Infinity API",
swagger_ui_parameters={
"defaultModelsExpandDepth": 0,
"syntaxHighlight.theme": "obsidian",
}
},
)
app.include_router(flight.router)
app.include_router(environment.router)
app.include_router(motor.router)
app.include_router(rocket.router)

_mcp_server = build_mcp(app)
app.mount('/mcp', _mcp_server.http_app())

FastAPIInstrumentor.instrument_app(app)
RequestsInstrumentor().instrument()

Expand Down
Empty file added src/mcp/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions src/mcp/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""FastMCP integration helpers for Infinity API."""

from __future__ import annotations

from fastapi import FastAPI
from fastmcp import FastMCP


def build_mcp(app: FastAPI) -> FastMCP:
"""
Create (or return cached) FastMCP server
that mirrors the FastAPI app.
"""
Comment thread
GabrielBarberini marked this conversation as resolved.

if hasattr(app.state, 'mcp'):
return app.state.mcp # type: ignore[attr-defined]

mcp = FastMCP.from_fastapi(app, name=app.title)
app.state.mcp = mcp # type: ignore[attr-defined]
return mcp
Comment thread
GabrielBarberini marked this conversation as resolved.
103 changes: 35 additions & 68 deletions src/repositories/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
wait_fixed,
retry,
)
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from pymongo.errors import PyMongoError
from pymongo.server_api import ServerApi
from motor.motor_asyncio import AsyncIOMotorClient
from pymongo import AsyncMongoClient

from fastapi import HTTPException, status
from bson import ObjectId

Expand Down Expand Up @@ -62,23 +63,6 @@ async def wrapper(self, *args, **kwargs):
return wrapper


class RepoInstances(BaseModel):
instance: object
prospecting: int = 0

def add_prospecting(self):
self.prospecting += 1

def remove_prospecting(self):
self.prospecting -= 1

def get_prospecting(self):
return self.prospecting

def get_instance(self):
return self.instance


class RepositoryInterface:
"""
Interface class for all repositories (singleton)
Expand All @@ -94,30 +78,14 @@ class RepositoryInterface:
"""

_global_instances = {}
_global_thread_lock = threading.RLock()
_global_async_lock = asyncio.Lock()
_global_thread_lock = threading.Lock()

def __new__(cls, *args, **kwargs):
with (
cls._global_thread_lock
): # Ensure thread safety for singleton instance creation
with cls._global_thread_lock:
if cls not in cls._global_instances:
instance = super(RepositoryInterface, cls).__new__(cls)
cls._global_instances[cls] = RepoInstances(instance=instance)
else:
cls._global_instances[cls].add_prospecting()
return cls._global_instances[cls].get_instance()

@classmethod
def _stop_prospecting(cls):
if cls in cls._global_instances:
cls._global_instances[cls].remove_prospecting()

@classmethod
def _get_instance_prospecting(cls):
if cls in cls._global_instances:
return cls._global_instances[cls].get_prospecting()
return 0
instance = super().__new__(cls)
cls._global_instances[cls] = instance
return cls._global_instances[cls]

def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3):
if not getattr(self, '_initialized', False):
Expand All @@ -128,37 +96,33 @@ def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3):

@retry(stop=stop_after_attempt(5), wait=wait_fixed(0.2))
async def _async_init(self):
async with (
self._global_async_lock
): # Hybrid safe locks for initialization
with self._global_thread_lock:
try:
self._initialize_connection()
self._initialized = True
except Exception as e:
logger.error("Initialization failed: %s", e, exc_info=True)
self._initialized = False

def _on_init_done(self, future):
try:
future.result()
finally:
if getattr(self, '_initialized', False):
return

if not hasattr(self, '_init_lock'):
self._init_lock = asyncio.Lock()
Comment thread
GabrielBarberini marked this conversation as resolved.

async with self._init_lock:
if getattr(self, '_initialized', False):
return

try:
self._initialize_connection()
except Exception as e:
logger.error("Initialization failed: %s", e, exc_info=True)
self._initialized = False
raise

self._initialized = True
self._initialized_event.set()

def _initialize(self):
if not asyncio.get_event_loop().is_running():
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(self._async_init())
else:
loop = asyncio.get_event_loop()
loop.create_task(self._async_init()).add_done_callback(
self._on_init_done
)

def __del__(self):
with self._global_thread_lock:
self._global_instances.pop(self.__class__, None)
self._initialized = False
self._stop_prospecting()
loop.create_task(self._async_init())

async def __aenter__(self):
await self._initialized_event.wait() # Ensure initialization is complete
Expand All @@ -172,7 +136,7 @@ def _initialize_connection(self):
self._connection_string = Secrets.get_secret(
"MONGODB_CONNECTION_STRING"
)
self._client = AsyncIOMotorClient(
self._client = AsyncMongoClient(
self._connection_string,
server_api=ServerApi("1"),
maxIdleTimeMS=30000,
Expand All @@ -181,7 +145,10 @@ def _initialize_connection(self):
serverSelectionTimeoutMS=60000,
)
self._collection = self._client.rocketpy[self.model.NAME]
logger.info("MongoDB client initialized for %s", self.__class__)
logger.info(
"AsyncMongoClient initialized for %s",
self.__class__,
)
except Exception as e:
logger.error(
f"Failed to initialize MongoDB client: {e}", exc_info=True
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/test_mcp/test_mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations


from unittest.mock import MagicMock, patch

import pytest

from fastmcp.client import Client
from fastapi.routing import APIRoute

from src.api import app
from src.mcp.server import build_mcp


@pytest.fixture(autouse=True)
def reset_mcp_state():
if hasattr(app.state, 'mcp'):
delattr(app.state, 'mcp')
yield
if hasattr(app.state, 'mcp'):
delattr(app.state, 'mcp')


def test_build_mcp_uses_fastapi_adapter():
mock_mcp = MagicMock()
with patch(
'src.mcp.server.FastMCP.from_fastapi', return_value=mock_mcp
) as mock_factory:
result = build_mcp(app)
assert result is mock_mcp
mock_factory.assert_called_once_with(app, name=app.title)
# Subsequent calls reuse cached server
again = build_mcp(app)
assert again is mock_mcp
mock_factory.assert_called_once()


@pytest.mark.asyncio
async def test_mcp_tools_cover_registered_routes():
mcp_server = build_mcp(app)

async with Client(mcp_server) as client:
tools = await client.list_tools()

tool_by_name = {tool.name: tool for tool in tools}

expected = {}
for route in app.routes:
if not isinstance(route, APIRoute) or not route.include_in_schema:
continue
Comment thread
GabrielBarberini marked this conversation as resolved.
tag = route.tags[0].lower()
tool_name = f"{route.name}_{tag}s"
expected[tool_name] = route
Comment thread
GabrielBarberini marked this conversation as resolved.

assert set(tool_by_name) == set(
expected
), "Every FastAPI route should be exported as an MCP tool"

for tool_name, route in expected.items():
schema = tool_by_name[tool_name].inputSchema or {}
required = set(schema.get('required', []))
path_params = {param.name for param in route.dependant.path_params}
Comment thread
GabrielBarberini marked this conversation as resolved.
# Path parameters must be represented as required MCP tool arguments
assert path_params.issubset(
required
), f"{tool_name} missing path params {path_params - required}"