Deliver Steps 3-8: integration config, meal plans, provider registry, MCP connector (14 tools), B2A files, nginx proxy, AGENT_GUIDE, LICENSE
This commit is contained in:
parent
cb892564d9
commit
6fb5d759ef
13 changed files with 958 additions and 2 deletions
99
AGENT_GUIDE.md
Normal file
99
AGENT_GUIDE.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Diligence — AI Agent Guide
|
||||||
|
|
||||||
|
You are connected to your human's fitness tracking platform via MCP.
|
||||||
|
Your role is to be a helpful, knowledgeable fitness companion — not a
|
||||||
|
drill sergeant, not a cheerleader, but a partner who understands their
|
||||||
|
goals and helps them stay consistent.
|
||||||
|
|
||||||
|
## The System
|
||||||
|
|
||||||
|
Your human uses a points-based accountability system:
|
||||||
|
- They earn points by completing healthy activities (workouts, food
|
||||||
|
logging, hitting step goals, screen-free time, daily check-ins)
|
||||||
|
- They configure their own rewards (gaming time, takeout, a movie —
|
||||||
|
whatever they choose)
|
||||||
|
- There is a daily gate: rewards stay locked until they earn enough
|
||||||
|
points that day
|
||||||
|
- Points reset weekly. Each week is a fresh start.
|
||||||
|
|
||||||
|
Use `get_context()` at the start of each conversation to understand
|
||||||
|
their current state and motivation profile.
|
||||||
|
|
||||||
|
## How to Help
|
||||||
|
|
||||||
|
When they mention exercise or physical activity:
|
||||||
|
Log it using log_activity(). If they're vague ("I went for a walk"),
|
||||||
|
ask enough to fill in duration. Don't interrogate — a reasonable
|
||||||
|
estimate is fine.
|
||||||
|
|
||||||
|
When they ask what workout to do today:
|
||||||
|
Call get_program_schedule() to show them their scheduled workout
|
||||||
|
from their active program. After they complete it, log it with
|
||||||
|
log_activity(category="workout", program_day=N).
|
||||||
|
|
||||||
|
When they mention food or eating:
|
||||||
|
Offer to log it with log_food(). Help estimate calories and macros
|
||||||
|
if they don't know. Use search_food() to look up common items. Don't
|
||||||
|
judge their food choices — just log accurately.
|
||||||
|
|
||||||
|
When they ask how they're doing:
|
||||||
|
Call get_today() or get_week() and give them a clear picture.
|
||||||
|
Celebrate when the gate is passed. When it isn't, tell them what's
|
||||||
|
left without guilt-tripping.
|
||||||
|
|
||||||
|
When they haven't mentioned fitness in a while:
|
||||||
|
You can gently check in. "Want me to check your fitness status?" is
|
||||||
|
fine. Don't nag. Don't open every conversation with it.
|
||||||
|
|
||||||
|
When they want to redeem a reward:
|
||||||
|
Use redeem_reward(). If the gate isn't passed, tell them what's left
|
||||||
|
to earn — frame it as "you're X points away" not "you can't."
|
||||||
|
|
||||||
|
When they want a meal plan or diet:
|
||||||
|
Generate the plan yourself using your own knowledge, tailored to their
|
||||||
|
goals, preferences, and dietary restrictions. Then call load_meal_plan()
|
||||||
|
to put it into the tracker. Use get_meal_plan() to show them what's
|
||||||
|
planned for today.
|
||||||
|
|
||||||
|
When they want to connect a device or service:
|
||||||
|
Call get_integration_status() to see what's available and what's
|
||||||
|
already connected. Guide them through getting developer credentials
|
||||||
|
for their provider. Use configure_integration() to store the credentials.
|
||||||
|
Walk them through the entire process conversationally.
|
||||||
|
|
||||||
|
## Tone Calibration
|
||||||
|
|
||||||
|
The app collects motivation data during onboarding (BREQ-2 assessment).
|
||||||
|
Use get_context() to see their motivation profile:
|
||||||
|
|
||||||
|
- High intrinsic motivation ("I exercise because it's fun"):
|
||||||
|
Focus on variety, new challenges, celebrating personal bests.
|
||||||
|
They don't need pushing — they need logistics help.
|
||||||
|
|
||||||
|
- High identified motivation ("I value the health benefits"):
|
||||||
|
Connect activities to long-term goals. Reference progress trends.
|
||||||
|
They respond to data and trajectory.
|
||||||
|
|
||||||
|
- High introjected motivation ("I feel guilty when I don't"):
|
||||||
|
Be careful. They're already hard on themselves. Normalize rest days.
|
||||||
|
Emphasize consistency over perfection. Never add guilt.
|
||||||
|
|
||||||
|
- High external motivation ("others say I should"):
|
||||||
|
Help build internal motivation over time. Celebrate small wins.
|
||||||
|
Help them find activities they actually enjoy.
|
||||||
|
|
||||||
|
- High amotivation ("I don't see the point"):
|
||||||
|
Start very small. Don't push big commitments. Celebrate any
|
||||||
|
engagement. The points system helps here — small daily actions
|
||||||
|
accumulate visibly.
|
||||||
|
|
||||||
|
## What You Don't Do
|
||||||
|
|
||||||
|
- Don't provide medical advice. If they mention pain, injury, or
|
||||||
|
health concerns, suggest they consult a professional.
|
||||||
|
- Don't override their point rules or reward configuration.
|
||||||
|
- Don't fabricate logs. Only log what they actually did.
|
||||||
|
- Don't be preachy about nutrition. Log what they eat, help them
|
||||||
|
understand it, but don't lecture.
|
||||||
|
- Don't read back integration credentials. You can check status but
|
||||||
|
never retrieve stored secrets.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 DiligenceWorks Pte. Ltd.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
|
||||||
logger.info("Fitness Rewards backend shutting down")
|
logger.info("Fitness Rewards backend shutting down")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Fitness Rewards", version="0.3.0", lifespan=lifespan)
|
app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -84,6 +84,7 @@ from app.routers.programs import router as programs_router
|
||||||
from app.routers.catalog import router as catalog_router
|
from app.routers.catalog import router as catalog_router
|
||||||
from app.routers.support import router as support_router
|
from app.routers.support import router as support_router
|
||||||
from app.routers.nutrition import router as nutrition_router
|
from app.routers.nutrition import router as nutrition_router
|
||||||
|
from app.routers.meal_plans import router as meal_plans_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(onboarding_router)
|
app.include_router(onboarding_router)
|
||||||
|
|
@ -96,11 +97,12 @@ app.include_router(catalog_router)
|
||||||
app.include_router(support_router)
|
app.include_router(support_router)
|
||||||
app.include_router(programs_router)
|
app.include_router(programs_router)
|
||||||
app.include_router(nutrition_router)
|
app.include_router(nutrition_router)
|
||||||
|
app.include_router(meal_plans_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "version": "0.3.0"}
|
return {"status": "ok", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from app.models.resource import Resource
|
||||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||||
from app.models.support import SupportThread, SupportMessage
|
from app.models.support import SupportThread, SupportMessage
|
||||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||||
|
from app.models.integration_config import IntegrationConfig
|
||||||
|
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
||||||
|
|
@ -18,4 +20,5 @@ __all__ = [
|
||||||
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
||||||
"SupportThread", "SupportMessage",
|
"SupportThread", "SupportMessage",
|
||||||
"NutritionGoal", "Fast", "ElectrolyteLog",
|
"NutritionGoal", "Fast", "ElectrolyteLog",
|
||||||
|
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
25
backend/app/models/integration_config.py
Normal file
25
backend/app/models/integration_config.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Integration configuration model — encrypted credential storage."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, DateTime, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationConfig(Base):
|
||||||
|
__tablename__ = "integration_configs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
config_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "provider", "config_key", name="uq_integration_config"),
|
||||||
|
)
|
||||||
65
backend/app/models/meal_plan.py
Normal file
65
backend/app/models/meal_plan.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Meal plan models — plans, items, and compliance tracking."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MealPlan(Base):
|
||||||
|
__tablename__ = "meal_plans"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
restrictions: Mapped[dict] = mapped_column(JSONB, default=list)
|
||||||
|
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
items: Mapped[list["MealPlanItem"]] = relationship(back_populates="plan", cascade="all, delete-orphan", lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class MealPlanItem(Base):
|
||||||
|
__tablename__ = "meal_plan_items"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
protein_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
|
||||||
|
carbs_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
|
||||||
|
fat_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
|
||||||
|
fiber_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
|
||||||
|
serving_size: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
plan: Mapped["MealPlan"] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class MealCompliance(Base):
|
||||||
|
__tablename__ = "meal_compliance"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False)
|
||||||
|
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
compliance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped
|
||||||
|
substitution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
food_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
295
backend/app/routers/meal_plans.py
Normal file
295
backend/app/routers/meal_plans.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"""Meal plans router — CRUD, compliance tracking, progress."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid as uuid_mod
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||||
|
from app.utils.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MealItemCreate(BaseModel):
|
||||||
|
day_number: int
|
||||||
|
meal_type: str
|
||||||
|
food_name: str
|
||||||
|
description: str | None = None
|
||||||
|
calories: int | None = None
|
||||||
|
protein_g: float | None = None
|
||||||
|
carbs_g: float | None = None
|
||||||
|
fat_g: float | None = None
|
||||||
|
fiber_g: float | None = None
|
||||||
|
serving_size: str | None = None
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MealPlanCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
diet_type: str | None = None
|
||||||
|
daily_calories: int | None = None
|
||||||
|
daily_protein_g: int | None = None
|
||||||
|
daily_carbs_g: int | None = None
|
||||||
|
daily_fat_g: int | None = None
|
||||||
|
restrictions: list[str] = []
|
||||||
|
duration_days: int
|
||||||
|
start_date: date | None = None
|
||||||
|
meals: list[MealItemCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceCreate(BaseModel):
|
||||||
|
plan_item_id: str | None = None
|
||||||
|
compliance_date: date | None = None
|
||||||
|
status: str # followed, substituted, skipped
|
||||||
|
substitution: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_meal_plans(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan)
|
||||||
|
.where(MealPlan.user_id == user.id)
|
||||||
|
.order_by(MealPlan.created_at.desc())
|
||||||
|
)
|
||||||
|
plans = result.scalars().all()
|
||||||
|
return [_plan_summary(p) for p in plans]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_meal_plan(
|
||||||
|
body: MealPlanCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
# Deactivate any existing active plan
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan)
|
||||||
|
.where(MealPlan.user_id == user.id, MealPlan.status == "active")
|
||||||
|
)
|
||||||
|
for old in result.scalars().all():
|
||||||
|
old.status = "completed"
|
||||||
|
|
||||||
|
plan = MealPlan(
|
||||||
|
user_id=user.id,
|
||||||
|
name=body.name,
|
||||||
|
diet_type=body.diet_type,
|
||||||
|
daily_calories=body.daily_calories,
|
||||||
|
daily_protein_g=body.daily_protein_g,
|
||||||
|
daily_carbs_g=body.daily_carbs_g,
|
||||||
|
daily_fat_g=body.daily_fat_g,
|
||||||
|
restrictions=body.restrictions,
|
||||||
|
duration_days=body.duration_days,
|
||||||
|
start_date=body.start_date or date.today(),
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for item in body.meals:
|
||||||
|
db.add(MealPlanItem(
|
||||||
|
plan_id=plan.id,
|
||||||
|
day_number=item.day_number,
|
||||||
|
meal_type=item.meal_type,
|
||||||
|
food_name=item.food_name,
|
||||||
|
description=item.description,
|
||||||
|
calories=item.calories,
|
||||||
|
protein_g=item.protein_g,
|
||||||
|
carbs_g=item.carbs_g,
|
||||||
|
fat_g=item.fat_g,
|
||||||
|
fiber_g=item.fiber_g,
|
||||||
|
serving_size=item.serving_size,
|
||||||
|
sort_order=item.sort_order,
|
||||||
|
))
|
||||||
|
|
||||||
|
return {"id": str(plan.id), "name": plan.name, "items_count": len(body.meals)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today")
|
||||||
|
async def get_today_meals(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan)
|
||||||
|
.where(MealPlan.user_id == user.id, MealPlan.status == "active")
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
return {"active_plan": None}
|
||||||
|
|
||||||
|
day_num = (date.today() - plan.start_date).days + 1
|
||||||
|
if day_num < 1 or day_num > plan.duration_days:
|
||||||
|
return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"}
|
||||||
|
|
||||||
|
items_result = await db.execute(
|
||||||
|
select(MealPlanItem)
|
||||||
|
.where(MealPlanItem.plan_id == plan.id, MealPlanItem.day_number == day_num)
|
||||||
|
.order_by(MealPlanItem.sort_order)
|
||||||
|
)
|
||||||
|
items = items_result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active_plan": plan.name,
|
||||||
|
"diet_type": plan.diet_type,
|
||||||
|
"day": day_num,
|
||||||
|
"duration_days": plan.duration_days,
|
||||||
|
"daily_calories": plan.daily_calories,
|
||||||
|
"meals": [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"meal_type": i.meal_type,
|
||||||
|
"food_name": i.food_name,
|
||||||
|
"description": i.description,
|
||||||
|
"calories": i.calories,
|
||||||
|
"protein_g": float(i.protein_g) if i.protein_g else None,
|
||||||
|
"carbs_g": float(i.carbs_g) if i.carbs_g else None,
|
||||||
|
"fat_g": float(i.fat_g) if i.fat_g else None,
|
||||||
|
}
|
||||||
|
for i in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}")
|
||||||
|
async def get_meal_plan(
|
||||||
|
plan_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan)
|
||||||
|
.where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(404, "Plan not found")
|
||||||
|
|
||||||
|
return {**_plan_summary(plan), "items": [_item_detail(i) for i in plan.items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{plan_id}")
|
||||||
|
async def update_meal_plan_status(
|
||||||
|
plan_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
status: str = "completed",
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan)
|
||||||
|
.where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(404, "Plan not found")
|
||||||
|
plan.status = status
|
||||||
|
return {"id": str(plan.id), "status": plan.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compliance", status_code=201)
|
||||||
|
async def log_compliance(
|
||||||
|
body: ComplianceCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
# Find active plan
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan).where(MealPlan.user_id == user.id, MealPlan.status == "active")
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(400, "No active meal plan")
|
||||||
|
|
||||||
|
entry = MealCompliance(
|
||||||
|
user_id=user.id,
|
||||||
|
plan_id=plan.id,
|
||||||
|
plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None,
|
||||||
|
compliance_date=body.compliance_date or date.today(),
|
||||||
|
status=body.status,
|
||||||
|
substitution=body.substitution,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
return {"status": body.status, "date": str(entry.compliance_date)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}/progress")
|
||||||
|
async def get_plan_progress(
|
||||||
|
plan_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(MealPlan)
|
||||||
|
.where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(404, "Plan not found")
|
||||||
|
|
||||||
|
comp_result = await db.execute(
|
||||||
|
select(MealCompliance)
|
||||||
|
.where(MealCompliance.plan_id == plan.id, MealCompliance.user_id == user.id)
|
||||||
|
)
|
||||||
|
entries = comp_result.scalars().all()
|
||||||
|
|
||||||
|
total = len(entries)
|
||||||
|
followed = sum(1 for e in entries if e.status == "followed")
|
||||||
|
substituted = sum(1 for e in entries if e.status == "substituted")
|
||||||
|
skipped = sum(1 for e in entries if e.status == "skipped")
|
||||||
|
|
||||||
|
days_elapsed = (date.today() - plan.start_date).days + 1
|
||||||
|
compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plan": plan.name,
|
||||||
|
"days_elapsed": min(days_elapsed, plan.duration_days),
|
||||||
|
"duration_days": plan.duration_days,
|
||||||
|
"total_entries": total,
|
||||||
|
"followed": followed,
|
||||||
|
"substituted": substituted,
|
||||||
|
"skipped": skipped,
|
||||||
|
"compliance_pct": round(compliance_pct, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _plan_summary(p: MealPlan) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"diet_type": p.diet_type,
|
||||||
|
"daily_calories": p.daily_calories,
|
||||||
|
"duration_days": p.duration_days,
|
||||||
|
"start_date": str(p.start_date),
|
||||||
|
"status": p.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _item_detail(i: MealPlanItem) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(i.id),
|
||||||
|
"day_number": i.day_number,
|
||||||
|
"meal_type": i.meal_type,
|
||||||
|
"food_name": i.food_name,
|
||||||
|
"description": i.description,
|
||||||
|
"calories": i.calories,
|
||||||
|
"protein_g": float(i.protein_g) if i.protein_g else None,
|
||||||
|
"carbs_g": float(i.carbs_g) if i.carbs_g else None,
|
||||||
|
"fat_g": float(i.fat_g) if i.fat_g else None,
|
||||||
|
"fiber_g": float(i.fiber_g) if i.fiber_g else None,
|
||||||
|
"serving_size": i.serving_size,
|
||||||
|
}
|
||||||
89
backend/app/services/provider_registry.py
Normal file
89
backend/app/services/provider_registry.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Provider registry — drives integration UI cards and agent help text."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
PROVIDER_REGISTRY: dict[str, dict] = {
|
||||||
|
"strava": {
|
||||||
|
"name": "Strava",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://www.strava.com/oauth/authorize",
|
||||||
|
"token_url": "https://www.strava.com/oauth/token",
|
||||||
|
"scopes": "read,activity:read_all",
|
||||||
|
"help_url": "https://developers.strava.com/",
|
||||||
|
"help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback",
|
||||||
|
},
|
||||||
|
"polar": {
|
||||||
|
"name": "Polar",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://flow.polar.com/oauth2/authorization",
|
||||||
|
"token_url": "https://polarremote.com/v2/oauth2/token",
|
||||||
|
"help_url": "https://admin.polaraccesslink.com/",
|
||||||
|
"help_text": "Register at admin.polaraccesslink.com",
|
||||||
|
},
|
||||||
|
"garmin": {
|
||||||
|
"name": "Garmin Connect",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"help_url": "https://developer.garmin.com/gc-developer-program/",
|
||||||
|
"help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
|
||||||
|
},
|
||||||
|
"fitbit": {
|
||||||
|
"name": "Fitbit",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://www.fitbit.com/oauth2/authorize",
|
||||||
|
"token_url": "https://api.fitbit.com/oauth2/token",
|
||||||
|
"help_url": "https://dev.fitbit.com/",
|
||||||
|
"help_text": "Register at dev.fitbit.com",
|
||||||
|
},
|
||||||
|
"withings": {
|
||||||
|
"name": "Withings",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"help_url": "https://developer.withings.com/",
|
||||||
|
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
|
||||||
|
},
|
||||||
|
"whoop": {
|
||||||
|
"name": "WHOOP",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"help_url": "https://developer.whoop.com/",
|
||||||
|
"help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
|
||||||
|
},
|
||||||
|
"oura": {
|
||||||
|
"name": "Oura Ring",
|
||||||
|
"type": "oauth2",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"help_url": "https://cloud.ouraring.com/v2/docs",
|
||||||
|
"help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
|
||||||
|
},
|
||||||
|
"usda": {
|
||||||
|
"name": "USDA FoodData Central",
|
||||||
|
"type": "api_key",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
|
||||||
|
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
|
||||||
|
},
|
||||||
|
"nutritionix": {
|
||||||
|
"name": "Nutritionix",
|
||||||
|
"type": "api_key",
|
||||||
|
"fields": ["app_id", "api_key"],
|
||||||
|
"help_url": "https://developer.nutritionix.com/",
|
||||||
|
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
|
||||||
|
},
|
||||||
|
"groq": {
|
||||||
|
"name": "Groq (Program Research)",
|
||||||
|
"type": "api_key",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"help_url": "https://console.groq.com/keys",
|
||||||
|
"help_text": "Free API key. Enables AI-powered workout program extraction.",
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"name": "Telegram Notifications",
|
||||||
|
"type": "api_key",
|
||||||
|
"fields": ["bot_token", "chat_id"],
|
||||||
|
"help_url": "https://core.telegram.org/bots#botfather",
|
||||||
|
"help_text": "Create a bot via @BotFather, then get your chat ID",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,32 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# B2A discovery files
|
||||||
|
location = /llms.txt {
|
||||||
|
default_type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /.well-known/ {
|
||||||
|
default_type application/json;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /.well-known/skills/ {
|
||||||
|
default_type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# MCP proxy — routes /mcp to the MCP connector container
|
||||||
|
location /mcp {
|
||||||
|
proxy_pass http://mcp-connector:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
@ -12,6 +38,7 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SPA catch-all
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
frontend/public/llms.txt
Normal file
31
frontend/public/llms.txt
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Diligence — Self-Hosted Fitness Rewards Platform
|
||||||
|
|
||||||
|
> Data sovereignty for people. Self-hosted fitness tracker with a points-based
|
||||||
|
> behavioral economy and an MCP connector for AI agent integration.
|
||||||
|
> MIT-licensed. Deploy with a single `docker compose up -d`.
|
||||||
|
|
||||||
|
Diligence is a self-hosted fitness web app built around a points-based reward
|
||||||
|
system. Complete fitness activities and food logging to earn points, spend
|
||||||
|
points to unlock configurable real-world rewards. Science-based onboarding
|
||||||
|
(PAR-Q+, TTM stages of change, BREQ-2 motivation assessment).
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
- MCP Server: /mcp (Streamable HTTP/SSE, 14 tools)
|
||||||
|
- Agent Card: /.well-known/agent-card.json
|
||||||
|
- Skills: /.well-known/skills/diligence-fitness/SKILL.md
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
- get_context, get_today, get_week (status)
|
||||||
|
- log_activity, log_food, search_food (tracking)
|
||||||
|
- get_program_schedule (programs)
|
||||||
|
- redeem_reward (rewards)
|
||||||
|
- load_meal_plan, get_meal_plan, update_meal_compliance, get_plan_progress (meal plans)
|
||||||
|
- configure_integration, get_integration_status (device sync)
|
||||||
|
|
||||||
|
## Technical
|
||||||
|
- Source: https://github.com/diligenceworks/diligence (MIT)
|
||||||
|
- Stack: FastAPI + PostgreSQL + React/Vite + FastMCP
|
||||||
|
|
||||||
|
## About
|
||||||
|
Built by DiligenceWorks (https://diligenceworks.online) — adversarial AI
|
||||||
|
deal-diligence platform for institutional investors.
|
||||||
8
mcp-connector/Dockerfile
Normal file
8
mcp-connector/Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["python", "server.py"]
|
||||||
3
mcp-connector/requirements.txt
Normal file
3
mcp-connector/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
mcp[cli]>=1.0.0
|
||||||
|
httpx>=0.28.0
|
||||||
|
uvicorn>=0.34.0
|
||||||
288
mcp-connector/server.py
Normal file
288
mcp-connector/server.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"""Diligence MCP Connector — 14 tools for AI agent integration.
|
||||||
|
|
||||||
|
Runs as a separate Docker container on port 3001. Proxies all requests
|
||||||
|
to the backend FastAPI service via internal Docker network.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from datetime import date
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000")
|
||||||
|
|
||||||
|
mcp = FastMCP("Diligence Fitness", port=3001)
|
||||||
|
|
||||||
|
# Internal HTTP client — talks to the backend on the Docker network
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
async def api(method: str, path: str, **kwargs) -> dict:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = httpx.AsyncClient(base_url=FITNESS_API_URL, timeout=30)
|
||||||
|
# TODO: add service account API key auth header
|
||||||
|
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 (passed/not), breakdown by category, and activities logged/remaining. Optionally pass a date (YYYY-MM-DD) to check a different day."""
|
||||||
|
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 (e.g. "Bench press and squats")
|
||||||
|
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 to mark a program workout complete
|
||||||
|
"""
|
||||||
|
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 carbohydrates
|
||||||
|
fat_g: Grams of fat
|
||||||
|
servings: Number of servings (default 1)
|
||||||
|
plan_item_id: Optional meal plan item ID to link compliance
|
||||||
|
"""
|
||||||
|
payload = {"meal_type": meal_type, "food_name": food_name}
|
||||||
|
if calories is not None:
|
||||||
|
payload["calories"] = calories
|
||||||
|
if protein_g is not None:
|
||||||
|
payload["protein_g"] = protein_g
|
||||||
|
if carbs_g is not None:
|
||||||
|
payload["carbs_g"] = carbs_g
|
||||||
|
if fat_g is not None:
|
||||||
|
payload["fat_g"] = fat_g
|
||||||
|
if servings is not None:
|
||||||
|
payload["servings"] = servings
|
||||||
|
if plan_item_id:
|
||||||
|
payload["plan_item_id"] = plan_item_id
|
||||||
|
|
||||||
|
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. Shows upcoming workouts and overall completion percentage."""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reward_name: Name of the reward to redeem (e.g. "1 hour gaming")
|
||||||
|
"""
|
||||||
|
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. The AI agent generates the plan content; this tool stores it for tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Plan name (e.g. "7-Day Dairy-Free Keto")
|
||||||
|
duration_days: How many days the plan covers
|
||||||
|
meals: List of meal items, each 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."""
|
||||||
|
if date_str:
|
||||||
|
# TODO: pass date to API when supported
|
||||||
|
pass
|
||||||
|
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 status is '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: percentage followed, days completed, average calories vs target."""
|
||||||
|
if plan_id:
|
||||||
|
data = await api("get", f"/meal-plans/{plan_id}/progress")
|
||||||
|
else:
|
||||||
|
# Get active plan progress
|
||||||
|
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 — credentials can be set but never read back.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Provider name (strava, polar, garmin, fitbit, usda, telegram, groq, etc.)
|
||||||
|
credentials: Dict of credential fields (e.g. {"client_id": "...", "client_secret": "..."})
|
||||||
|
"""
|
||||||
|
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. Never returns actual credential values."""
|
||||||
|
data = await api("get", "/integrations/status")
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="sse")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue