|
6 | 6 | Auth: Bearer token using org-scoped MCP API keys. |
7 | 7 | """ |
8 | 8 |
|
| 9 | +import asyncio |
| 10 | +import base64 |
9 | 11 | import hashlib |
10 | 12 | import logging |
11 | 13 | from datetime import datetime, timedelta, timezone |
|
14 | 16 | from fastmcp import FastMCP |
15 | 17 | from fastmcp.server.dependencies import get_http_headers |
16 | 18 | from fastmcp.exceptions import ToolError |
| 19 | +from fastmcp.utilities.types import Image |
17 | 20 | from pydantic import Field |
18 | 21 | from sqlalchemy.orm import Session |
19 | 22 |
|
|
38 | 41 | "OpenSentry", |
39 | 42 | instructions=( |
40 | 43 | "You are connected to an OpenSentry Command Center organization. " |
41 | | - "You can list cameras, check node status, get stream URLs, manage " |
| 44 | + "You can SEE what cameras see via view_camera (returns a live JPEG " |
| 45 | + "snapshot), list cameras, check node status, get stream URLs, manage " |
42 | 46 | "recording settings, and view audit logs. All operations are scoped " |
43 | 47 | "to the authenticated organization." |
44 | 48 | ), |
@@ -166,6 +170,121 @@ def get_stream_url( |
166 | 170 | db.close() |
167 | 171 |
|
168 | 172 |
|
| 173 | +# --------------------------------------------------------------------------- |
| 174 | +# Visual Access Tools |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | + |
| 177 | +@mcp.tool( |
| 178 | + name="view_camera", |
| 179 | + description=( |
| 180 | + "See what a camera sees RIGHT NOW. Returns a live JPEG snapshot image " |
| 181 | + "from the camera. The camera node must be online and actively streaming. " |
| 182 | + "Use this to visually inspect a camera feed." |
| 183 | + ), |
| 184 | + annotations={"readOnlyHint": True}, |
| 185 | +) |
| 186 | +async def view_camera( |
| 187 | + camera_id: Annotated[str, "The camera_id to view (e.g. 'node1-video0')"], |
| 188 | +) -> Image: |
| 189 | + org_id, db = _auth() |
| 190 | + try: |
| 191 | + cam = ( |
| 192 | + db.query(Camera) |
| 193 | + .filter_by(org_id=org_id, camera_id=camera_id) |
| 194 | + .first() |
| 195 | + ) |
| 196 | + if not cam: |
| 197 | + raise ToolError(f"Camera '{camera_id}' not found") |
| 198 | + |
| 199 | + node = db.query(CameraNode).filter_by(id=cam.node_id).first() |
| 200 | + if not node: |
| 201 | + raise ToolError(f"Camera '{camera_id}' has no assigned node") |
| 202 | + |
| 203 | + node_id = node.node_id |
| 204 | + finally: |
| 205 | + db.close() |
| 206 | + |
| 207 | + # Send take_snapshot command to CloudNode via WebSocket |
| 208 | + from app.api.ws import manager |
| 209 | + |
| 210 | + if not manager.is_connected(node_id): |
| 211 | + raise ToolError(f"Node '{node_id}' is offline — cannot capture snapshot") |
| 212 | + |
| 213 | + try: |
| 214 | + result = await manager.send_command( |
| 215 | + node_id, "take_snapshot", {"camera_id": camera_id}, timeout=15.0, |
| 216 | + ) |
| 217 | + except TimeoutError: |
| 218 | + raise ToolError("Snapshot timed out — camera node did not respond in time") |
| 219 | + except ValueError as e: |
| 220 | + raise ToolError(str(e)) |
| 221 | + |
| 222 | + image_b64 = result.get("image_b64") |
| 223 | + if not image_b64: |
| 224 | + raise ToolError("Camera node did not return image data — update CloudNode to latest version") |
| 225 | + |
| 226 | + return Image(data=base64.b64decode(image_b64), format="jpeg") |
| 227 | + |
| 228 | + |
| 229 | +@mcp.tool( |
| 230 | + name="watch_camera", |
| 231 | + description=( |
| 232 | + "Take multiple snapshots from a camera over a time period to observe " |
| 233 | + "activity or changes. Returns a series of JPEG images. " |
| 234 | + "Useful for monitoring movement or verifying camera coverage." |
| 235 | + ), |
| 236 | + annotations={"readOnlyHint": True}, |
| 237 | +) |
| 238 | +async def watch_camera( |
| 239 | + camera_id: Annotated[str, "The camera_id to watch"], |
| 240 | + count: Annotated[int, Field(description="Number of snapshots to take", ge=2, le=10)] = 3, |
| 241 | + interval_seconds: Annotated[int, Field(description="Seconds between snapshots", ge=1, le=30)] = 5, |
| 242 | +) -> list: |
| 243 | + org_id, db = _auth() |
| 244 | + try: |
| 245 | + cam = ( |
| 246 | + db.query(Camera) |
| 247 | + .filter_by(org_id=org_id, camera_id=camera_id) |
| 248 | + .first() |
| 249 | + ) |
| 250 | + if not cam: |
| 251 | + raise ToolError(f"Camera '{camera_id}' not found") |
| 252 | + |
| 253 | + node = db.query(CameraNode).filter_by(id=cam.node_id).first() |
| 254 | + if not node: |
| 255 | + raise ToolError(f"Camera '{camera_id}' has no assigned node") |
| 256 | + |
| 257 | + node_id = node.node_id |
| 258 | + finally: |
| 259 | + db.close() |
| 260 | + |
| 261 | + from app.api.ws import manager |
| 262 | + |
| 263 | + if not manager.is_connected(node_id): |
| 264 | + raise ToolError(f"Node '{node_id}' is offline — cannot capture snapshots") |
| 265 | + |
| 266 | + results = [] |
| 267 | + for i in range(count): |
| 268 | + if i > 0: |
| 269 | + await asyncio.sleep(interval_seconds) |
| 270 | + try: |
| 271 | + result = await manager.send_command( |
| 272 | + node_id, "take_snapshot", {"camera_id": camera_id}, timeout=15.0, |
| 273 | + ) |
| 274 | + image_b64 = result.get("image_b64") |
| 275 | + if image_b64: |
| 276 | + results.append(Image(data=base64.b64decode(image_b64), format="jpeg")) |
| 277 | + else: |
| 278 | + results.append(f"[Frame {i+1}] No image data returned") |
| 279 | + except (TimeoutError, ValueError) as e: |
| 280 | + results.append(f"[Frame {i+1}] Failed: {e}") |
| 281 | + |
| 282 | + if not any(isinstance(r, Image) for r in results): |
| 283 | + raise ToolError("Failed to capture any snapshots — check node status") |
| 284 | + |
| 285 | + return results |
| 286 | + |
| 287 | + |
169 | 288 | # --------------------------------------------------------------------------- |
170 | 289 | # Camera Group Tools |
171 | 290 | # --------------------------------------------------------------------------- |
|
0 commit comments