feat: MCP server for pip install path

- diligence/mcp/server.py: 14 MCP tools (adapted from mcp-connector/)
- diligence/mcp/__init__.py: background thread startup
- CLI starts MCP on port 3001 automatically (--no-mcp to disable)
- Graceful fallback if mcp package not installed
- Agent config endpoint returns correct port for pip path
This commit is contained in:
Claude 2026-07-01 04:54:56 +00:00
parent df39a8dc08
commit 40c8485aa7
4 changed files with 294 additions and 4 deletions

View file

@ -30,6 +30,10 @@ def main():
"--no-mcp", action="store_true", "--no-mcp", action="store_true",
help="Disable the MCP connector", help="Disable the MCP connector",
) )
parser.add_argument(
"--mcp-port", type=int, default=3001,
help="Port for MCP SSE server (default: 3001)",
)
parser.add_argument( parser.add_argument(
"--data-dir", type=str, default=None, "--data-dir", type=str, default=None,
help="Data directory (default: ~/.diligence)", help="Data directory (default: ~/.diligence)",
@ -43,6 +47,7 @@ def main():
# Auto-generate secrets on first run # Auto-generate secrets on first run
env_file = data_dir / ".env" env_file = data_dir / ".env"
api_token = ""
if not env_file.exists(): if not env_file.exists():
secret_key = secrets.token_hex(32) secret_key = secrets.token_hex(32)
api_token = secrets.token_hex(32) api_token = secrets.token_hex(32)
@ -53,7 +58,6 @@ def main():
f"DATA_DIR={data_dir}\n" f"DATA_DIR={data_dir}\n"
) )
print(f"Created config at {env_file}") print(f"Created config at {env_file}")
print(f"MCP token: {api_token}")
# Point pydantic-settings at the env file # Point pydantic-settings at the env file
os.environ.setdefault("DATA_DIR", str(data_dir)) os.environ.setdefault("DATA_DIR", str(data_dir))
@ -65,10 +69,25 @@ def main():
if line and not line.startswith("#") and "=" in line: if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=") key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip()) os.environ.setdefault(key.strip(), value.strip())
if key.strip() == "API_TOKEN":
api_token = value.strip()
if args.no_mcp: if args.no_mcp:
os.environ["MCP_ENABLED"] = "false" os.environ["MCP_ENABLED"] = "false"
# Start MCP server in background
mcp_started = False
if not args.no_mcp:
try:
from diligence.mcp import start_mcp_background
mcp_started = start_mcp_background(
api_url=f"http://localhost:{args.port}",
api_token=api_token,
port=args.mcp_port,
)
except ImportError:
pass
# Open browser after a short delay # Open browser after a short delay
url = f"http://localhost:{args.port}" url = f"http://localhost:{args.port}"
if not args.no_browser: if not args.no_browser:
@ -83,6 +102,10 @@ def main():
print(f"\n Diligence running at {url}") print(f"\n Diligence running at {url}")
print(f" Data: {data_dir}") print(f" Data: {data_dir}")
if mcp_started:
print(f" MCP: http://localhost:{args.mcp_port}/sse")
elif not args.no_mcp:
print(f" MCP: not available (install with: pip install diligence[mcp])")
print(f" Press Ctrl+C to stop\n") print(f" Press Ctrl+C to stop\n")
# Run uvicorn # Run uvicorn

41
diligence/mcp/__init__.py Normal file
View file

@ -0,0 +1,41 @@
"""MCP server startup for the pip install path.
Starts FastMCP in a background thread so AI agents can connect
to the same machine running Diligence. Requires: pip install diligence[mcp]
"""
from __future__ import annotations
import threading
import logging
logger = logging.getLogger("diligence.mcp")
def start_mcp_background(api_url: str, api_token: str = "", port: int = 3001):
"""Start the MCP SSE server in a background thread.
Args:
api_url: Backend API base URL (e.g. http://localhost:8000)
api_token: Bearer token for API auth
port: Port for the MCP SSE server (default 3001)
"""
try:
from diligence.mcp.server import create_mcp_server
except ImportError:
logger.warning("MCP package not installed. Install with: pip install diligence[mcp]")
return False
def _run():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
mcp = create_mcp_server(api_url=api_url, api_token=api_token, port=port)
mcp.run(transport="sse")
except Exception as e:
logger.error(f"MCP server failed: {e}")
thread = threading.Thread(target=_run, daemon=True, name="mcp-server")
thread.start()
logger.info(f"MCP server starting on port {port}")
return True

223
diligence/mcp/server.py Normal file
View file

@ -0,0 +1,223 @@
"""Diligence MCP Server — 14 tools for AI agent integration.
For the pip install path, this runs in-process as a background thread.
For the Docker path, this runs as a separate container (mcp-connector/).
"""
from __future__ import annotations
import json
import httpx
from mcp.server.fastmcp import FastMCP
_client: httpx.AsyncClient | None = None
def create_mcp_server(api_url: str = "http://localhost:8000", api_token: str = "", port: int = 3001) -> FastMCP:
"""Create and configure the MCP server with all 14 tools."""
mcp = FastMCP("Diligence Fitness", port=port)
async def api(method: str, path: str, **kwargs) -> dict:
global _client
if _client is None:
headers = {}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
_client = httpx.AsyncClient(
base_url=api_url, timeout=30, headers=headers
)
resp = await getattr(_client, method)(f"/api{path}", **kwargs)
resp.raise_for_status()
return resp.json()
# --- CONTEXT & STATUS (3 tools) ---
@mcp.tool()
async def get_context() -> str:
"""Get full user profile, motivation type, program status, point rules, rewards, and integration status. Call this at the start of each conversation to understand the user's current state."""
data = await api("get", "/points/today")
profile = await api("get", "/onboarding/profile")
rewards = await api("get", "/points/rewards")
integrations = await api("get", "/integrations/status")
meal_plan = None
try:
meal_plan = await api("get", "/meal-plans/today")
except Exception:
pass
return json.dumps({
"user": profile, "today": data, "rewards": rewards,
"integrations": integrations, "active_meal_plan": meal_plan,
}, indent=2, default=str)
@mcp.tool()
async def get_today(date_str: str | None = None) -> str:
"""Get today's points earned, daily gate status, breakdown by category, and activities logged. Optionally pass a date (YYYY-MM-DD)."""
params = {"date": date_str} if date_str else {}
data = await api("get", "/points/today", params=params)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_week(start_date: str | None = None) -> str:
"""Get weekly summary including total points, active days count, target progress, and day-by-day breakdown."""
params = {"start_date": start_date} if start_date else {}
data = await api("get", "/points/week", params=params)
return json.dumps(data, indent=2, default=str)
# --- ACTIVITY LOGGING (1 tool) ---
@mcp.tool()
async def log_activity(
category: str, title: str, duration_minutes: int,
notes: str | None = None, date_str: str | None = None,
program_day: int | None = None,
) -> str:
"""Log a workout or activity. Returns points earned and daily total.
Args:
category: Type of activity (workout, cardio, yoga, walking, cycling, swimming, other)
title: Description of what was done
duration_minutes: How long the activity lasted
notes: Optional additional notes
date_str: Optional date (YYYY-MM-DD), defaults to today
program_day: Optional program day number
"""
payload = {"category": category, "title": title, "duration_minutes": duration_minutes}
if notes: payload["notes"] = notes
if date_str: payload["date"] = date_str
if program_day: payload["program_day"] = program_day
data = await api("post", "/activities/log", json=payload)
return json.dumps(data, indent=2, default=str)
# --- FOOD & NUTRITION (2 tools) ---
@mcp.tool()
async def log_food(
meal_type: str, food_name: str, calories: int | None = None,
protein_g: float | None = None, carbs_g: float | None = None,
fat_g: float | None = None, servings: float | None = None,
plan_item_id: str | None = None,
) -> str:
"""Log a food item with optional nutrition data.
Args:
meal_type: One of breakfast, lunch, dinner, snack
food_name: What was eaten
calories: Estimated calories
protein_g: Grams of protein
carbs_g: Grams of carbs
fat_g: Grams of fat
servings: Number of servings (default 1)
plan_item_id: Optional meal plan item ID for compliance
"""
payload = {"meal_type": meal_type, "food_name": food_name}
for k, v in [("calories", calories), ("protein_g", protein_g), ("carbs_g", carbs_g),
("fat_g", fat_g), ("servings", servings), ("plan_item_id", plan_item_id)]:
if v is not None: payload[k] = v
data = await api("post", "/food/log", json=payload)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def search_food(query: str) -> str:
"""Search for food items in Open Food Facts and USDA FoodData Central. Returns nutrition data for matching items."""
data = await api("get", "/food/search", params={"q": query})
return json.dumps(data, indent=2, default=str)
# --- PROGRAMS (1 tool) ---
@mcp.tool()
async def get_program_schedule(date_str: str | None = None) -> str:
"""Get today's scheduled workout from the active program, including exercises with sets, reps, and weight progression."""
params = {"date": date_str} if date_str else {}
data = await api("get", "/programs/schedule", params=params)
return json.dumps(data, indent=2, default=str)
# --- REWARDS (1 tool) ---
@mcp.tool()
async def redeem_reward(reward_name: str) -> str:
"""Spend points on a configured reward. Returns success/failure and remaining points."""
data = await api("post", "/points/rewards/redeem", json={"name": reward_name})
return json.dumps(data, indent=2, default=str)
# --- MEAL PLANS (4 tools) ---
@mcp.tool()
async def load_meal_plan(
name: str, duration_days: int, meals: list[dict],
diet_type: str | None = None, daily_calories: int | None = None,
restrictions: list[str] | None = None,
) -> str:
"""Create a complete meal plan with all items.
Args:
name: Plan name (e.g. "7-Day Dairy-Free Keto")
duration_days: How many days the plan covers
meals: List of meal items with day_number, meal_type, food_name, calories, protein_g, carbs_g, fat_g
diet_type: Optional diet type (keto, paleo, balanced, custom)
daily_calories: Optional daily calorie target
restrictions: Optional list of dietary restrictions
"""
payload = {"name": name, "duration_days": duration_days, "meals": meals}
if diet_type: payload["diet_type"] = diet_type
if daily_calories: payload["daily_calories"] = daily_calories
if restrictions: payload["restrictions"] = restrictions
data = await api("post", "/meal-plans", json=payload)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_meal_plan(date_str: str | None = None) -> str:
"""View the active meal plan's meals for today or a specified date."""
data = await api("get", "/meal-plans/today")
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def update_meal_compliance(
plan_item_id: str, status: str, substitution: str | None = None,
) -> str:
"""Mark a planned meal as followed, substituted, or skipped.
Args:
plan_item_id: The meal plan item ID
status: One of 'followed', 'substituted', 'skipped'
substitution: What was eaten instead (required if 'substituted')
"""
payload = {"plan_item_id": plan_item_id, "status": status}
if substitution: payload["substitution"] = substitution
data = await api("post", "/meal-plans/compliance", json=payload)
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_plan_progress(plan_id: str | None = None) -> str:
"""Get overall compliance stats for a meal plan."""
if plan_id:
data = await api("get", f"/meal-plans/{plan_id}/progress")
else:
plans = await api("get", "/meal-plans")
active = next((p for p in plans if p.get("status") == "active"), None)
if not active:
return json.dumps({"error": "No active meal plan"})
data = await api("get", f"/meal-plans/{active['id']}/progress")
return json.dumps(data, indent=2, default=str)
# --- INTEGRATIONS (2 tools) ---
@mcp.tool()
async def configure_integration(provider: str, credentials: dict) -> str:
"""Store encrypted integration credentials. Write-only.
Args:
provider: Provider name (strava, polar, garmin, fitbit, usda, telegram, groq, etc.)
credentials: Dict of credential fields
"""
data = await api("post", "/integrations/configure", json={
"provider": provider, "credentials": credentials,
})
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_integration_status() -> str:
"""Check connection status of all integration providers."""
data = await api("get", "/integrations/status")
return json.dumps(data, indent=2, default=str)
return mcp

View file

@ -13,11 +13,14 @@ async def agent_config(user=Depends(get_current_user)):
"""Return MCP connection details for the authenticated user.""" """Return MCP connection details for the authenticated user."""
settings = get_settings() settings = get_settings()
# Determine MCP URL based on deployment mode
base = settings.base_url.rstrip("/") base = settings.base_url.rstrip("/")
if settings.is_sqlite: if settings.is_sqlite:
# pip install path — MCP served from same process # pip install path — MCP runs on separate port (same machine)
mcp_url = f"{base}/mcp/sse" from urllib.parse import urlparse
parsed = urlparse(base)
host = parsed.hostname or "localhost"
mcp_url = f"http://{host}:{settings.mcp_port}/sse"
else: else:
# Docker path — MCP on separate container port 3001 # Docker path — MCP on separate container port 3001
from urllib.parse import urlparse from urllib.parse import urlparse