Skip to main content
Glama
main.py10.5 kB
# app/main.py from __future__ import annotations import logging import os from typing import Any, Optional, Dict, List from pydantic import BaseModel, Field, field_validator from mcp.server.fastmcp import FastMCP from .smartthings import SmartThingsClient, SmartThingsError, flatten_status, find_device_by_name # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) APP_NAME = "smartthings-mcp-server" # Single client for the process lifetime _client: SmartThingsClient | None = None async def get_client() -> SmartThingsClient: """Get or create the global SmartThings client.""" global _client if _client is None: logger.info("Initializing SmartThings client") _client = SmartThingsClient() return _client async def cleanup_client(): """Clean up the global SmartThings client.""" global _client if _client is not None: logger.info("Closing SmartThings client") await _client.close() _client = None class RoomsArgs(BaseModel): locationId: Optional[str] = None class DevicesArgs(BaseModel): locationId: Optional[str] = None roomId: Optional[str] = None class DeviceSelector(BaseModel): deviceId: Optional[str] = Field(None, description="The unique device ID") name: Optional[str] = Field(None, description="The device name/label to search for") @field_validator('deviceId', 'name') @classmethod def at_least_one_identifier(cls, v, info): """Ensure at least one identifier is provided.""" # This validator runs for each field, so we check if both are None # Note: In Pydantic V2, we need a model_validator for cross-field validation return v class SwitchArgs(DeviceSelector): command: str = Field(..., description="Switch command: 'on' or 'off'") component: str = Field("main", description="Device component (default: 'main')") @field_validator('command') @classmethod def validate_command(cls, v): """Validate switch command.""" if v not in {"on", "off"}: raise ValueError("command must be 'on' or 'off'") return v # Note: Lifespan/cleanup handler would go here if FastMCP supported it # For now, the HTTP client will be cleaned up when the process exits mcp = FastMCP(APP_NAME, json_response=True) @mcp.tool() async def list_locations() -> List[Dict[str, Any]]: """ List all SmartThings locations accessible with the current PAT. Returns: A list of location objects with locationId, name, and other metadata. """ logger.info("Listing locations") client = await get_client() locations = await client.locations() logger.info(f"Found {len(locations)} location(s)") return locations @mcp.tool() async def list_rooms(args: RoomsArgs) -> List[Dict[str, Any]]: """ List all rooms in a SmartThings location. Args: locationId: The location ID. If not provided, uses SMARTTHINGS_LOCATION_ID environment variable or the first available location. Returns: A list of room objects with roomId, name, and other metadata. """ location_id = args.locationId or os.environ.get("SMARTTHINGS_LOCATION_ID") client = await get_client() if not location_id: logger.info("No locationId provided, fetching first available location") locs = await client.locations() if not locs: raise SmartThingsError("No locations found for this token") location_id = locs[0]["locationId"] logger.info(f"Using location: {location_id}") logger.info(f"Listing rooms for location: {location_id}") rooms = await client.rooms(location_id) logger.info(f"Found {len(rooms)} room(s)") return rooms @mcp.tool() async def list_devices(args: DevicesArgs) -> List[Dict[str, Any]]: """ List all SmartThings devices, optionally filtered by location and/or room. Args: locationId: Optional location ID to filter devices. roomId: Optional room ID to filter devices. Returns: A list of device objects with deviceId, label, capabilities, and other metadata. """ logger.info(f"Listing devices (locationId={args.locationId}, roomId={args.roomId})") client = await get_client() devices = await client.devices(location_id=args.locationId) if args.roomId: devices = [d for d in devices if d.get("roomId") == args.roomId] logger.info(f"Found {len(devices)} device(s) in room {args.roomId}") else: logger.info(f"Found {len(devices)} device(s)") return devices @mcp.tool() async def device_status(args: DeviceSelector) -> Dict[str, Any]: """ Get the current status of a SmartThings device. Args: deviceId: The unique device ID (preferred for efficiency). name: The device name/label to search for (slower, searches all devices). Returns: Device information with current status and flattened summary. """ client = await get_client() device = None if args.deviceId: logger.info(f"Getting device by ID: {args.deviceId}") device = await client.get_device(args.deviceId) elif args.name: logger.info(f"Searching for device by name: {args.name}") device = await find_device_by_name(client, args.name) if not device: error_msg = f"Device not found (deviceId={args.deviceId}, name={args.name})" logger.error(error_msg) raise SmartThingsError(error_msg) logger.info(f"Getting status for device: {device['deviceId']}") status = await client.device_status(device["deviceId"]) return { "device": {"id": device["deviceId"], "label": device.get("label")}, "status": status, "summary": flatten_status(status) } @mcp.tool() async def switch_device(args: SwitchArgs) -> Dict[str, Any]: """ Turn a SmartThings switch device on or off. Args: deviceId: The unique device ID (preferred for efficiency). name: The device name/label to search for (slower, searches all devices). command: The switch command - either 'on' or 'off'. component: The device component (default: 'main'). Returns: Device information and command execution result. """ client = await get_client() device = None if args.deviceId: logger.info(f"Getting device by ID: {args.deviceId}") device = await client.get_device(args.deviceId) elif args.name: logger.info(f"Searching for device by name: {args.name}") device = await find_device_by_name(client, args.name) if not device: error_msg = f"Device not found (deviceId={args.deviceId}, name={args.name})" logger.error(error_msg) raise SmartThingsError(error_msg) logger.info(f"Sending '{args.command}' command to device {device['deviceId']} (component: {args.component})") res = await client.send_commands(device["deviceId"], [{ "component": args.component, "capability": "switch", "command": args.command }]) return { "device": {"id": device["deviceId"], "label": device.get("label")}, "result": res } @mcp.tool() async def fridge_status(args: DeviceSelector) -> Dict[str, Any]: """ Get detailed status of a SmartThings refrigerator/fridge device. Args: deviceId: The unique device ID. name: The device name/label to search for. If neither is provided, automatically searches for devices with 'fridge' or 'refrigerator' in the name. Returns: Device information with refrigerator-specific status summary and raw status data. """ client = await get_client() device = None if args.deviceId: logger.info(f"Getting fridge by ID: {args.deviceId}") device = await client.get_device(args.deviceId) elif args.name: logger.info(f"Searching for fridge by name: {args.name}") device = await find_device_by_name(client, args.name) # Auto-detect fridge if no identifier provided if not device: logger.info("No device specified, searching for refrigerator/fridge devices") devices = await client.devices() for d in devices: label = (d.get("label") or d.get("name") or "").lower() if any(k in label for k in ("fridge", "refrigerator")): device = d logger.info(f"Found fridge device: {label}") break if not device: error_msg = "Fridge not found. Specify deviceId or name, or ensure a device has 'fridge' or 'refrigerator' in its name." logger.error(error_msg) raise SmartThingsError(error_msg) logger.info(f"Getting status for fridge: {device['deviceId']}") status = await client.device_status(device["deviceId"]) flat = flatten_status(status) summary = { "power": flat.get("main.switch.switch") or flat.get("refrigerator.switch.switch"), "refrigeratorTemp": flat.get("refrigeratorTemperatureMeasurement.temperature") or flat.get("main.temperatureMeasurement.temperature"), "freezerTemp": flat.get("freezerTemperatureMeasurement.temperature"), "doorOpen": flat.get("doorControl.door") or flat.get("refrigeratorDoorState.door"), "iceMaker": flat.get("icemaker.switch.switch"), "defrost": flat.get("refrigerator.defrostMode.defrostMode"), "interesting": {k: v for k, v in flat.items() if any(s in k for s in ("temperature","door","ice","humidity","energy"))}, } return { "device": {"id": device["deviceId"], "label": device.get("label")}, "summary": summary, "raw": status } @mcp.tool() async def device_health(deviceId: str) -> Dict[str, Any]: """ Get the health status of a SmartThings device. Args: deviceId: The unique device ID. Returns: Device health information including online/offline state. """ logger.info(f"Getting health for device: {deviceId}") client = await get_client() health = await client.device_health(deviceId) return health if __name__ == "__main__": transport = os.environ.get("MCP_TRANSPORT", "stdio") logger.info(f"Starting MCP server with transport: {transport}") mcp.run(transport=transport)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bjornhovd/Samsung-SmartThings-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server