Skip to content

Commit f5781bc

Browse files
committed
Add MCP connector support
- Introduced MCP connector for OAuth authorization and streamable HTTP server functionality. - Updated requirements to include the MCP package. - Enhanced main application logic to conditionally mount MCP routes based on the MCP_ENABLED environment variable. - Added new constants for MCP configuration in the utils module, including issuer and token TTL settings.
1 parent 26ee6b5 commit f5781bc

16 files changed

Lines changed: 2601 additions & 1 deletion

File tree

‎app/main.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi_sqlalchemy import DBSessionMiddleware
55
from sqlalchemy import create_engine
66

7-
from utils.consts import DATABASE_URL, DEVELOPMENT, APP_HOST
7+
from utils.consts import DATABASE_URL, DEVELOPMENT, APP_HOST, MCP_ENABLED
88
from utils.sentry import integration_options as sentry_integration_options
99
from api import user, resources, item, item_lifecycle, benchmark, trip, category, pack, kit, hiker_profile, webhook
1010

@@ -19,6 +19,9 @@
1919

2020
# if DEVELOPMENT:
2121
from models.base import Base
22+
# The OAuth tables for the MCP connector live in the API and register on the
23+
# shared Base when imported; import before create_all so they are created too.
24+
import oauth.models # noqa: F401
2225
Base.metadata.create_all(engine)
2326

2427
if not DEVELOPMENT:
@@ -130,3 +133,14 @@
130133
@app.get("/health-check")
131134
def health_check():
132135
return "Packstack API is available"
136+
137+
138+
# --- MCP connector (OAuth authorization server + Streamable HTTP MCP endpoint).
139+
# Everything is off unless MCP_ENABLED is set: no discovery documents, no
140+
# /oauth routes, no /mcp. See claude/mcp-server-spec.md.
141+
if MCP_ENABLED:
142+
from oauth.routes import route as oauth_route
143+
from mcp_server.server import mount_mcp
144+
145+
app.include_router(oauth_route, tags=["oauth"])
146+
mount_mcp(app)

‎app/mcp_server/__init__.py‎

Whitespace-only changes.

‎app/mcp_server/context.py‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Per-call helpers shared by every tool: who is calling, in what units,
2+
and how to run synchronous SQLAlchemy work without blocking the event loop.
3+
4+
Tools are `async def` and do their database work through `run_sync`, which
5+
hands the callable to anyio's worker thread pool. anyio copies the current
6+
context into the thread, so both fastapi_sqlalchemy's request-scoped
7+
`db.session` and the SDK's `auth_context_var` are visible inside.
8+
"""
9+
10+
import functools
11+
from dataclasses import dataclass
12+
from typing import Callable, TypeVar
13+
14+
import anyio
15+
from fastapi_sqlalchemy import db
16+
from mcp.server.auth.middleware.auth_context import get_access_token
17+
from mcp.server.mcpserver.exceptions import ToolError as _SDKToolError
18+
19+
from models.base import User
20+
from oauth import tokens as oauth_tokens
21+
from utils.weight import convert_weight
22+
23+
T = TypeVar("T")
24+
25+
GRAMS_PER_UNIT = {"g": 1.0, "kg": 1000.0, "oz": 28.3495, "lb": 453.592}
26+
27+
28+
class ToolError(_SDKToolError):
29+
"""Raised inside a tool; the SDK turns it into an isError tool result the
30+
model can read and relay. Keep messages actionable for the end user.
31+
32+
Must subclass the SDK's ToolError: any other exception type is reported
33+
to the client as an opaque "Error executing tool" with the message
34+
withheld (so crashes never leak internals), which is exactly wrong for
35+
the "trip not found, call list_trips" class of message.
36+
"""
37+
38+
39+
async def run_sync(fn: Callable[..., T], *args, **kwargs) -> T:
40+
return await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
41+
42+
43+
@dataclass
44+
class Caller:
45+
user: User
46+
scopes: list[str]
47+
client_id: str
48+
49+
@property
50+
def unit(self) -> str:
51+
"""Display unit for per-item weights, from the user's preference."""
52+
return "oz" if (self.user.unit_weight or "METRIC") == "IMPERIAL" else "g"
53+
54+
@property
55+
def big_unit(self) -> str:
56+
"""Display unit for totals."""
57+
return "lb" if self.unit == "oz" else "kg"
58+
59+
@property
60+
def can_write(self) -> bool:
61+
return oauth_tokens.SCOPE_WRITE in self.scopes
62+
63+
64+
def current_caller() -> Caller:
65+
"""Resolve the authenticated user for this tool call. Sync — call via
66+
run_sync or from inside another sync function."""
67+
token = get_access_token()
68+
if token is None or token.subject is None:
69+
raise ToolError("Not authenticated.")
70+
user = db.session.query(User).filter_by(id=int(token.subject)).first()
71+
if user is None or user.deactivated or user.banned:
72+
raise ToolError("This Packstack account is not available.")
73+
return Caller(user=user, scopes=list(token.scopes), client_id=token.client_id)
74+
75+
76+
# ---------------------------------------------------------------------------
77+
# Weight formatting. Every weight a tool returns carries grams (for arithmetic
78+
# the model can trust) and the user's display unit (for talking to the user).
79+
# ---------------------------------------------------------------------------
80+
81+
def to_grams(weight, unit) -> float:
82+
if weight is None:
83+
return 0.0
84+
return float(weight) * GRAMS_PER_UNIT.get(unit or "g", 1.0)
85+
86+
87+
def weight_fields(grams: float, caller: Caller, big: bool = False) -> dict:
88+
unit = caller.big_unit if big else caller.unit
89+
value = convert_weight(grams, "g", unit)
90+
return {
91+
"grams": round(grams, 1),
92+
"display": f"{value:,.2f} {unit}" if big else f"{value:,.1f} {unit}",
93+
}
94+
95+
96+
def item_summary(item, caller: Caller) -> dict:
97+
"""The shape every tool uses for a gear item. Never includes internal
98+
foreign keys, sort orders or removed/deleted flags."""
99+
brand = item.brand.name if item.brand else None
100+
product = item.product.name if item.product else None
101+
variant = item.product_variant.name if item.product_variant else None
102+
category = (item.category.category.name
103+
if item.category and item.category.category else None)
104+
grams = to_grams(item.weight, item.unit)
105+
return {
106+
"item_id": item.id,
107+
"name": item.name,
108+
"brand": brand,
109+
"product": " ".join(p for p in (product, variant) if p) or None,
110+
"category": category or "Uncategorized",
111+
"weight": weight_fields(grams, caller),
112+
"weight_entered": item.weight is not None,
113+
"consumable": bool(item.consumable),
114+
"calories": float(item.calories) if item.calories else None,
115+
"price": float(item.price) if item.price else None,
116+
"notes": item.notes or None,
117+
"product_url": item.product_url or None,
118+
"archived": bool(item.removed),
119+
"status": item.status or "active",
120+
}

‎app/mcp_server/prompts.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""MCP prompts: reusable templates the host can surface as slash commands.
2+
3+
A prompt is text handed to the model on the user's behalf; it tells the model
4+
which tools to call and how to shape the answer. Keep them opinionated — the
5+
point is to encode how experienced backpackers actually review a list.
6+
"""
7+
8+
from mcp.server.mcpserver import MCPServer
9+
10+
11+
def register_prompts(mcp: MCPServer) -> None:
12+
13+
@mcp.prompt(
14+
name="shakedown",
15+
title="Gear shakedown",
16+
description="Review one trip's gear list the way r/Ultralight does: heavy outliers, redundancies, missing safety items, and whether the kit fits the conditions.",
17+
)
18+
def shakedown(trip: str) -> str:
19+
return f"""Give my Packstack trip "{trip}" a gear shakedown.
20+
21+
Steps:
22+
1. Call get_me so you know my units and whether I'm subscribed.
23+
2. Call list_trips and pick the trip matching "{trip}" (by title or location). If several match, ask me which.
24+
3. Call get_trip with format="markdown" for the full list with precomputed totals. Use those totals as-is; do not re-add weights.
25+
26+
Then review the list against the trip details (dates, low temperature, distance per day, terrain, elevation) and report, in this order:
27+
- The headline numbers: base weight, worn, consumables, total, and how the base weight compares to common thresholds (ultralight ≤ 10 lb / 4.5 kg, lightweight ≤ 20 lb / 9 kg).
28+
- Heavy outliers: the 3–5 items contributing most to base weight, with a lighter alternative for each (use search_catalog to find real products with verified weights and cite the grams saved).
29+
- Redundancies: items that duplicate a function.
30+
- Missing items: check navigation, insulation for the forecast low, rain protection, first aid, repair, light, fire, water treatment and capacity, sun protection, and emergency shelter. Only flag what is genuinely absent from the list.
31+
- Fit for conditions: sleep system and shelter vs the low temperature; footwear and traction vs terrain; food and fuel vs nights.
32+
- Anything else you'd want to know before I go — ask me directly (budget, experience, resupply, water sources, non-negotiables).
33+
34+
Use my preferred units in prose. Be specific and skip generic advice."""
35+
36+
@mcp.prompt(
37+
name="plan_pack",
38+
title="Plan a pack from my gear closet",
39+
description="Propose a complete pack for a trip using only gear the user already owns, then ask before making changes.",
40+
)
41+
def plan_pack(trip: str) -> str:
42+
return f"""Help me build a pack for my Packstack trip "{trip}" from gear I already own.
43+
44+
Steps:
45+
1. Call get_me, then list_trips to find the trip "{trip}" and get_trip to see its details and what is already packed.
46+
2. Call search_gear with status="active" (page through with a higher limit if needed) so you know everything in my closet, and list_kits for bundles I keep together.
47+
3. Propose a full pack for the conditions (low temperature, nights, distance, terrain), organized by category, using only items from my closet. For each category say which item you chose and why; where I own several options, pick the lightest that suits the conditions and mention the alternative.
48+
4. List gaps — functions no item in my closet covers — and suggest catalog products via search_catalog.
49+
5. Show the projected base weight and total.
50+
51+
Do not change anything yet. End by asking whether I want you to apply it to the trip."""
52+
53+
@mcp.prompt(
54+
name="lighten",
55+
title="Lighten a pack",
56+
description="Find the biggest weight savings for a trip, ranked by grams saved, using real catalog alternatives.",
57+
)
58+
def lighten(trip: str, target_base_weight: str = "") -> str:
59+
target = f" My target base weight is {target_base_weight}." if target_base_weight else ""
60+
return f"""Help me lighten my Packstack trip "{trip}".{target}
61+
62+
Steps:
63+
1. Call get_me, list_trips (find "{trip}") and get_trip with format="json".
64+
2. Rank every non-worn, non-consumable item by weight. For the top 8, look for a lighter replacement with search_catalog and report grams saved, the replacement's weight, and a one-line tradeoff (durability, warmth, price if known).
65+
3. Separately list items you would simply leave behind for these conditions, with grams saved.
66+
4. Sum the savings and show the projected new base weight{" against my target" if target_base_weight else ""}.
67+
68+
Prefer fewer, larger savings over many tiny ones. Use my preferred units."""

0 commit comments

Comments
 (0)