فهرست منبع

remove: unmaintained MCP server

room_cal/mcp/, its tests, docker wiring (compose service, entrypoint
branch, Dockerfile dep group), pyproject entry point/dependency group,
and README mentions. Unused since going into production.

Dropping fastmcp's transitive deps surfaced a real gap: room_cal.api._auth
imports jwt/cryptography directly but neither was an explicit dependency --
only present via fastmcp's dependency tree. Added pyjwt[crypto] to the api
group so the api service still has what it actually needs.
ZiB 5 روز پیش
والد
کامیت
8862f8d8fd
10فایلهای تغییر یافته به همراه121 افزوده شده و 1134 حذف شده
  1. 1 1
      README.md
  2. 1 1
      docker/Dockerfile
  3. 1 19
      docker/README.md
  4. 0 12
      docker/compose.yaml
  5. 0 14
      docker/entrypoint.sh
  6. 1 5
      pyproject.toml
  7. 0 304
      room_cal/mcp/__init__.py
  8. 0 5
      room_cal/mcp/_server.py
  9. 0 77
      tests/test_mcp.py
  10. 117 696
      uv.lock

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # room-cal
 
-A self-hosted calendar management tool for short-term rental hosts. It syncs pricing, availability, reservations, and discounts between a local CalDAV calendar and external booking platforms (Airbnb, Booking.com). A FastAPI HTTP server exposes the data to a Vue-based web calendar, and a FastMCP server exposes health checks to AI assistants.
+A self-hosted calendar management tool for short-term rental hosts. It syncs pricing, availability, reservations, and discounts between a local CalDAV calendar and external booking platforms (Airbnb, Booking.com). A FastAPI HTTP server exposes the data to a Vue-based web calendar.
 
 ---
 

+ 1 - 1
docker/Dockerfile

@@ -34,7 +34,7 @@ RUN chmod +x /entrypoint.sh
 COPY room_cal/ ./room_cal/
 RUN mkdir /root/.local && ln -s /config /root/.local/room_cal \
     && cd room_cal/vue && npm run build \
-    && uv sync --group api --group mcp --no-dev --frozen
+    && uv sync --group api --no-dev --frozen
 
 # Exec
 ENTRYPOINT ["/usr/bin/tini", "--"]

+ 1 - 19
docker/README.md

@@ -1,7 +1,7 @@
 # Docker
 
 ## podman-compose
-### API & MCP
+### API
 
 ```bash
 podman-compose -f room_cal/docker/compose.yaml up -d
@@ -50,14 +50,6 @@ podman run --rm -it -p 8000:8000 -v ./config:/config $IMG_NAME /entrypoint.sh ca
 
 Serves only the `/calc/*` endpoints and the `calc.html` UI at `/`. No Store, CalDAV, or service-API credentials needed — only the `services` and `global.taxes` sections of `config.json` are read.
 
-### MCP
-
-```bash
-podman run --rm -it -p 8001:8001 -v ./config:/config $IMG_NAME /entrypoint.sh mcp
-```
-
-The `./config` volume must contain `config.json` (start with a copy of `room_cal/config.json`). CalDAV credentials go in `config.json` under `store.caldav` — there is no separate `caldav.env`. Service API credential files (`airbnb.env`, `booking.env`) live there too, once generated, so it needs a writeable mount
-
 Placing a `uvicorn_log.json` in `./config` overrides the default log config
 
 ## Environment variables
@@ -88,13 +80,3 @@ Default entrypoint. Variable defaults are baked in; override with environment va
 ### Calc service
 
 Pass `calc` as the first argument to `entrypoint.sh`. Uses the same `API_HOST`, `API_PORT`, and `LOG_LEVEL` variables as the API service. Only `services` and `global.taxes` are read from `config.json` — no store, auth, or service-API config required.
-
-### MCP service
-
-Pass `mcp` as the first argument to `entrypoint.sh`. Variable defaults are baked in; override with environment variables if needed
-
-| Variable | Default | Notes |
-|----------|---------|-------|
-| `FASTMCP_HOST` | `0.0.0.0` | MCP server bind address |
-| `FASTMCP_PORT` | `8001` | MCP server bind port |
-| `FASTMCP_LOG_LEVEL` | `INFO` | MCP log level |

+ 0 - 12
docker/compose.yaml

@@ -30,15 +30,3 @@ services:
     environment:
       - TZ=${TZ:-}
       - API_PORT=${CALC_PORT:-8002}
-
-  mcp:
-    build:
-      context: ..
-      dockerfile: docker/Dockerfile
-    command: ["/entrypoint.sh", "mcp"]
-    ports:
-      - "${MCP_PORT:-8001}:${MCP_PORT:-8001}"
-    volumes:
-      - ./config:/config
-    environment:
-      - TZ=${TZ:-}

+ 0 - 14
docker/entrypoint.sh

@@ -19,20 +19,6 @@ export ROOM_CAL_API_URL
 
 SERVICE="${1:-api}"
 
-# MCP
-if [ "$SERVICE" = "mcp" ]; then
-  : "${FASTMCP_HOST:=0.0.0.0}"
-  : "${FASTMCP_PORT:=8001}"
-  : "${FASTMCP_LOG_LEVEL:=INFO}"
-  : "${FASTMCP_ENABLE_RICH_LOGGING:=false}"
-  : "${FASTMCP_ENABLE_RICH_TRACEBACKS:=false}"
-  export FASTMCP_TRANSPORT=http
-  export FASTMCP_HOST FASTMCP_PORT FASTMCP_LOG_LEVEL
-  export FASTMCP_ENABLE_RICH_LOGGING FASTMCP_ENABLE_RICH_TRACEBACKS
-
-  exec room-mcp
-fi
-
 # Shared API config
 : "${API_HOST:=0.0.0.0}"
 : "${API_PORT:=8000}"

+ 1 - 5
pyproject.toml

@@ -19,19 +19,15 @@ refresh-airbnb = "room_cal.service_api._scripts:refresh_airbnb_cli"
 refresh-booking = "room_cal.service_api._scripts:refresh_booking_cli"
 room-cal = "room_cal.actions._cli:main"
 room-api = "room_cal.api._server:main"
-room-mcp = "room_cal.mcp._server:main"
 
 [dependency-groups]
 api = [
   "fastapi>=0.115",
   "uvicorn[standard]>=0.30",
-]
-mcp = [
-  "fastmcp>=2.0",
+  "pyjwt[crypto]>=2.0",
 ]
 dev = [
   {include-group = "api"},
-  {include-group = "mcp"},
   "pytest",
   "pytest-asyncio",
   "pytest-xdist",

+ 0 - 304
room_cal/mcp/__init__.py

@@ -1,304 +0,0 @@
-import asyncio
-from datetime import date
-from fastmcp import FastMCP
-from room_cal._utils import services as _services, service_name as _service_name, safe_json_loads, iter_dates
-from room_cal._health import store_check, credentials_checks
-from room_cal.pricing import CalPrice
-from room_cal.pricing.calc import (
-  net_to_list, list_to_net, list_to_retail,
-  retail_to_list, net_to_retail, retail_to_net,
-)
-from room_cal.reservations import Reservation
-from room_cal.closed import Closed
-from room_cal.discount import Discount
-
-mcp = FastMCP('room_cal')
-
-_CALC_FROM = {
-  'net_to_list': 'net',   'net_to_retail': 'net',
-  'list_to_net': 'list',  'list_to_retail': 'list',
-  'retail_to_list': 'retail', 'retail_to_net': 'retail',
-}
-_API_SOURCE = {'domain': 'mcp', 'id': ''}
-
-
-@mcp.tool()
-async def health_check() -> dict:
-  """Check store connectivity and service credential validity. Returns {store_connected, credentials}."""
-  store_result, creds = await asyncio.gather(
-    store_check(),
-    credentials_checks(),
-  )
-  return {
-    'store_connected': store_result.to_dict(),
-    'credentials': {k: v.to_dict() for k, v in creds.items()},
-  }
-
-
-@mcp.tool()
-async def list_services() -> list[dict]:
-  return [{'key': s, 'name': _service_name(s)} for s in _services()]
-
-
-@mcp.tool()
-async def convert_price(transformation: str, service: str, amount: int) -> dict:
-  """Convert a price. transformation: net_to_list, list_to_net, list_to_retail, retail_to_list, net_to_retail, retail_to_net. Returns {net, list, retail}."""
-  from_type = _CALC_FROM.get(transformation)
-  if from_type is None:
-    raise ValueError(f'Unknown transformation: {transformation!r}')
-  if from_type == 'net':
-    return {'net': amount, 'list': net_to_list(service, amount), 'retail': net_to_retail(service, amount)}
-  if from_type == 'list':
-    return {'net': list_to_net(service, amount), 'list': amount, 'retail': list_to_retail(service, amount)}
-  return {'net': retail_to_net(service, amount), 'list': retail_to_list(service, amount), 'retail': amount}
-
-
-@mcp.tool()
-async def get_prices(start_date: str, end_date: str) -> dict:
-  """Get price dicts for a date range (YYYY-MM-DD). Returns a dict keyed by date string; None for dates with no price set."""
-  price_range = await CalPrice().get(date.fromisoformat(start_date), date.fromisoformat(end_date))
-  return {k: v.to_dict() if v else None for k, v in price_range.items()}
-
-
-@mcp.tool()
-async def set_price(target_date: str, value: float) -> None:
-  """Set the net price for a date (YYYY-MM-DD)."""
-  await CalPrice().set(date.fromisoformat(target_date), value)
-
-
-
-
-@mcp.tool()
-async def patch_reservation(uid: str, checkin_date: str, fields: dict[str, str] | None = None,
-                            checkin_time: str | None = None, checkout_time: str | None = None) -> dict:
-  """Patch a stored reservation. checkin_date (YYYY-MM-DD) is required to locate the event.
-
-  `fields` is merged directly into the reservation's stored data — wifi_key, wifi_ssid,
-  door_code, or any other custom field — except the sync-owned identity fields
-  (reservation_id, service, service_data, chat_id, guest_name), which are rejected.
-  `checkin_time`/`checkout_time` are 'HH:MM' strings that adjust only the time-of-day
-  of checkin/checkout; the date is never changed. Returns the updated reservation dict.
-  """
-  result = await Reservation().patch(
-    uid, date.fromisoformat(checkin_date),
-    fields=fields, checkin_time=checkin_time, checkout_time=checkout_time,
-  )
-  if result is None:
-    raise ValueError(f'reservation {uid!r} not found on {checkin_date}')
-  return result
-
-
-@mcp.tool()
-async def get_reservation(target_date: str) -> dict | None:
-  """Get the reservation covering a date (checkin ≤ date < checkout), or null."""
-  d = date.fromisoformat(target_date)
-  for ev in await Reservation().list(d, d):
-    if ev['start'].date() <= d < ev['end'].date():
-      xd = safe_json_loads(ev['x_data']) or {}
-      xd['checkin_date'] = ev['start'].date().isoformat()
-      xd['checkout_date'] = ev['end'].date().isoformat()
-      return xd
-  return None
-
-
-@mcp.tool()
-async def get_reservations_range(start_date: str, end_date: str) -> dict:
-  """Get reservations for each date in a range. Returns {date: dict or null}."""
-  start = date.fromisoformat(start_date)
-  end = date.fromisoformat(end_date)
-  events = await Reservation().list(start, end)
-  result: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
-  for ev in events:
-    checkin = ev['start'].date()
-    checkout = ev['end'].date()
-    xd = safe_json_loads(ev['x_data']) or {}
-    xd['checkin_date'] = checkin.isoformat()
-    xd['checkout_date'] = checkout.isoformat()
-    for d in iter_dates(start, end):
-      if checkin <= d < checkout:
-        result[str(d)] = xd
-  return result
-
-
-@mcp.tool()
-async def get_closed(target_date: str) -> list[str]:
-  """Get closed service keys for a date. Empty list means open."""
-  return await Closed().get(date.fromisoformat(target_date)) or []
-
-
-@mcp.tool()
-async def get_closed_range(start_date: str, end_date: str) -> dict:
-  """Get closed services for each date in a range. Returns {date: [service, ...]}."""
-  start = date.fromisoformat(start_date)
-  end = date.fromisoformat(end_date)
-  events = await Closed().list(start, end)
-  by_date: dict[str, list] = {str(d): [] for d in iter_dates(start, end)}
-  for ev in events:
-    d = ev['start'].date()
-    if start <= d <= end:
-      xd = safe_json_loads(ev['x_data'])
-      by_date[str(d)] = (xd or {}).get('closed_services', [])
-  return by_date
-
-
-@mcp.tool()
-async def set_closed(target_date: str, services: list[str]) -> None:
-  """Block a date for the given services. Empty list unblocks the date."""
-  await Closed().set(date.fromisoformat(target_date), services, source=_API_SOURCE)
-
-
-@mcp.tool()
-async def get_discount(target_date: str) -> dict | None:
-  """Get discount for a date, or null if none set."""
-  return await Discount().get(date.fromisoformat(target_date))
-
-
-@mcp.tool()
-async def get_discounts_range(start_date: str, end_date: str) -> dict:
-  """Get discounts for each date in a range. Returns {date: {type, value} or null}."""
-  start = date.fromisoformat(start_date)
-  end = date.fromisoformat(end_date)
-  events = await Discount().list(start, end)
-  by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
-  for ev in events:
-    d = ev['start'].date()
-    if start <= d <= end:
-      xd = safe_json_loads(ev['x_data'])
-      if xd:
-        by_date[str(d)] = {'type': xd.get('type'), 'value': xd.get('value')}
-  return by_date
-
-
-@mcp.tool()
-async def set_discount(target_date: str, discount_type: str, value: int) -> None:
-  """Set a discount. discount_type: 'percent' or 'flat'."""
-  await Discount().set(
-    date.fromisoformat(target_date), discount_type, value, source=_API_SOURCE
-  )
-
-
-@mcp.tool()
-async def delete_discount(target_date: str) -> None:
-  """Remove the discount on a date."""
-  await Discount().delete_on(date.fromisoformat(target_date))
-
-
-@mcp.tool()
-async def refresh_credentials(service: str | None = None) -> dict:
-  """Trigger automatic credential refresh via Playwright. service: optional key (e.g. 'booking', 'airbnb'); omit to refresh all. Booking.com includes messaging 2FA and may take 2+ minutes. Returns {refreshed: [...], errors: {key: reason}}."""
-  from room_cal.service_api import ServiceAPI
-  from room_cal.service_api.types import ServiceAuthError
-  keys = [service] if service else _services()
-  api = ServiceAPI.get()
-
-  async def _do(key: str) -> tuple[str, str | None]:
-    try:
-      await api.refresh_credentials(key)
-      return key, None
-    except (ServiceAuthError, Exception) as e:
-      return key, str(e)
-
-  results = await asyncio.gather(*[_do(k) for k in keys])
-  return {
-    'refreshed': [k for k, err in results if err is None],
-    'errors': {k: err for k, err in results if err is not None},
-  }
-
-
-@mcp.tool()
-async def reload_caches() -> dict:
-  """Clear all in-memory CalDAV event caches so the next request re-fetches from the server. Returns {cleared_calendars}."""
-  from room_cal.store import clear_caches
-  count = clear_caches()
-  return {'cleared_calendars': count}
-
-
-@mcp.tool()
-async def get_chat_thread(service_key: str, chat_id: str) -> dict:
-  """Fetch recent messages for a reservation thread. Returns {chat_id, messages: [{id, created_at, body, sender}], is_reply_needed?}."""
-  from room_cal.service_api import ServiceAPI
-  from room_cal.service_api.types import ServiceAuthError
-  api = ServiceAPI.get()
-  try:
-    client = api.chat_client(service_key)
-  except KeyError:
-    raise ValueError(f'unknown service: {service_key!r}')
-  except TypeError:
-    raise ValueError(f'{service_key!r} does not support chat')
-  try:
-    thread = await client.get_thread(chat_id)
-  except ServiceAuthError as e:
-    raise RuntimeError(f'auth error: {e}')
-  chat_id_out = getattr(thread, 'thread_id', None) or getattr(thread, 'reservation_id', None)
-  result: dict = {
-    'chat_id': chat_id_out,
-    'messages': [
-      {
-        'id': m.id,
-        'created_at': getattr(m, 'created_at_ms', None) or getattr(m, 'created_at', None),
-        'body': m.body,
-        'sender': getattr(m, 'sender_name', None) or getattr(m, 'sender', None),
-      }
-      for m in thread.messages
-    ],
-  }
-  if hasattr(thread, 'is_reply_needed'):
-    result['is_reply_needed'] = thread.is_reply_needed
-  return result
-
-
-@mcp.tool()
-async def send_chat_message(service_key: str, chat_id: str, body: str) -> None:
-  """Send a text message to a guest conversation thread."""
-  from room_cal.service_api import ServiceAPI
-  from room_cal.service_api.types import ServiceAuthError
-  api = ServiceAPI.get()
-  try:
-    client = api.chat_client(service_key)
-  except KeyError:
-    raise ValueError(f'unknown service: {service_key!r}')
-  except TypeError:
-    raise ValueError(f'{service_key!r} does not support chat')
-  try:
-    await client.send_message(chat_id, body)
-  except ServiceAuthError as e:
-    raise RuntimeError(f'auth error: {e}')
-
-
-import importlib
-import pkgutil
-import room_cal.actions as _actions_pkg
-
-_ACTIONS: dict[str, tuple[str, str, list[str]]] = {}
-for _info in pkgutil.iter_modules(_actions_pkg.__path__):
-  if _info.name.startswith('_'):
-    continue
-  _mod = importlib.import_module(f'room_cal.actions.{_info.name}')
-  _spec = getattr(_mod, 'ACTION_SPEC', None)
-  if _spec:
-    _route_name, _allowed_keys = _spec
-    _ACTIONS[_route_name] = (f'room_cal.actions.{_info.name}', _info.name, _allowed_keys)
-
-@mcp.tool()
-async def run_action(
-  action: str,
-  start_date: str,
-  end_date: str | None = None,
-  feed_name: str | None = None,
-  title: str | None = None,
-  service: str | None = None,
-  weekday: int | None = None,
-  weekend: int | None = None,
-) -> object:
-  """Run a room_cal action over a date range. action must be one of the names in _ACTIONS (auto-discovered from room_cal.actions). Optional kwargs: feed_name, title (fetch-feed); service (fetch-prices); weekday, weekend (fill-prices)."""
-  from datetime import date as _date
-  if action not in _ACTIONS:
-    raise ValueError(f'Unknown action: {action!r}')
-  module_path, fn_name, allowed_keys = _ACTIONS[action]
-  mod = importlib.import_module(module_path)
-  fn = getattr(mod, fn_name)
-  start = _date.fromisoformat(start_date)
-  end = _date.fromisoformat(end_date) if end_date else start
-  all_kwargs = {'feed_name': feed_name, 'title': title, 'service': service, 'weekday': weekday, 'weekend': weekend}
-  kwargs = {k: v for k, v in all_kwargs.items() if k in allowed_keys and v is not None}
-  return await fn(start, end, **kwargs)

+ 0 - 5
room_cal/mcp/_server.py

@@ -1,5 +0,0 @@
-from room_cal.mcp import mcp
-
-
-def main() -> None:
-  mcp.run()

+ 0 - 77
tests/test_mcp.py

@@ -1,77 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, MagicMock, patch
-from datetime import date
-
-from room_cal.mcp import mcp, convert_price, run_action
-
-
-def test_mcp_is_fastmcp():
-  from fastmcp import FastMCP
-  assert isinstance(mcp, FastMCP)
-
-
-@pytest.mark.asyncio
-async def test_convert_price_from_net():
-  result = await convert_price('net_to_list', 'airbnb', 100)
-  assert set(result.keys()) == {'net', 'list', 'retail'}
-  assert result['net'] == 100
-  assert result['list'] > result['net']
-
-
-@pytest.mark.asyncio
-async def test_convert_price_from_list():
-  result = await convert_price('list_to_net', 'airbnb', 150)
-  assert set(result.keys()) == {'net', 'list', 'retail'}
-  assert result['list'] == 150
-  assert result['net'] < result['list']
-
-
-@pytest.mark.asyncio
-async def test_convert_price_from_retail():
-  result = await convert_price('retail_to_list', 'airbnb', 200)
-  assert set(result.keys()) == {'net', 'list', 'retail'}
-  assert result['retail'] == 200
-
-
-@pytest.mark.asyncio
-async def test_convert_price_unknown_transformation():
-  with pytest.raises(ValueError, match='Unknown transformation'):
-    await convert_price('bad_transform', 'airbnb', 100)
-
-
-@pytest.mark.asyncio
-async def test_run_action_unknown_raises():
-  with pytest.raises(ValueError, match='Unknown action'):
-    await run_action('bad-action', '2026-01-01')
-
-
-@pytest.mark.asyncio
-async def test_run_action_end_date_defaults_to_start():
-  mock_fn = AsyncMock(return_value=[])
-  mock_mod = MagicMock()
-  mock_mod.fetch_prices = mock_fn
-  with patch('importlib.import_module', return_value=mock_mod):
-    await run_action('fetch-prices', '2026-01-15')
-  mock_fn.assert_called_once_with(date(2026, 1, 15), date(2026, 1, 15))
-
-
-@pytest.mark.asyncio
-async def test_run_action_kwargs_filtering_allows_allowed_keys():
-  mock_fn = AsyncMock(return_value=[])
-  mock_mod = MagicMock()
-  mock_mod.fetch_feed = mock_fn
-  with patch('importlib.import_module', return_value=mock_mod):
-    # fetch-feed allows feed_name and title, but not service
-    await run_action('fetch-feed', '2026-01-15', end_date='2026-01-20', feed_name='Fire', title='T', service='airbnb')
-  mock_fn.assert_called_once_with(date(2026, 1, 15), date(2026, 1, 20), feed_name='Fire', title='T')
-
-
-@pytest.mark.asyncio
-async def test_run_action_kwargs_filtering_excludes_none():
-  mock_fn = AsyncMock(return_value=[])
-  mock_mod = MagicMock()
-  mock_mod.fetch_feed = mock_fn
-  with patch('importlib.import_module', return_value=mock_mod):
-    # feed_name=None should be excluded even though it's in allowed_keys
-    await run_action('fetch-feed', '2026-01-15', feed_name=None)
-  mock_fn.assert_called_once_with(date(2026, 1, 15), date(2026, 1, 15))

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 117 - 696
uv.lock


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است