Skip to content

Commit cdabeb1

Browse files
Sbussisoclaude
andcommitted
Add visual camera access via MCP: view_camera and watch_camera tools
New MCP tools that give AI agents direct visual access to cameras: - view_camera: captures a live JPEG snapshot via WebSocket and returns it as an MCP Image that the AI client can see and analyze - watch_camera: takes multiple snapshots over time (2-10 frames) for observing activity or changes Requires CloudNode update to include base64 image data in the take_snapshot WebSocket response. Frontend: updated MCP page, docs, and landing page with 15 tools (was 13), visual tool badges, and updated example prompts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ae992f7 commit cdabeb1

5 files changed

Lines changed: 168 additions & 11 deletions

File tree

backend/app/mcp/server.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
Auth: Bearer token using org-scoped MCP API keys.
77
"""
88

9+
import asyncio
10+
import base64
911
import hashlib
1012
import logging
1113
from datetime import datetime, timedelta, timezone
@@ -14,6 +16,7 @@
1416
from fastmcp import FastMCP
1517
from fastmcp.server.dependencies import get_http_headers
1618
from fastmcp.exceptions import ToolError
19+
from fastmcp.utilities.types import Image
1720
from pydantic import Field
1821
from sqlalchemy.orm import Session
1922

@@ -38,7 +41,8 @@
3841
"OpenSentry",
3942
instructions=(
4043
"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 "
4246
"recording settings, and view audit logs. All operations are scoped "
4347
"to the authenticated organization."
4448
),
@@ -166,6 +170,121 @@ def get_stream_url(
166170
db.close()
167171

168172

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+
169288
# ---------------------------------------------------------------------------
170289
# Camera Group Tools
171290
# ---------------------------------------------------------------------------

frontend/src/index.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2517,6 +2517,28 @@ body {
25172517
border-color: rgba(34, 197, 94, 0.15);
25182518
}
25192519

2520+
.mcp-tool-visual {
2521+
border-color: rgba(96, 165, 250, 0.4);
2522+
background: linear-gradient(135deg, var(--bg-secondary), rgba(96, 165, 250, 0.05));
2523+
}
2524+
2525+
.mcp-tool-visual code {
2526+
color: #60a5fa;
2527+
}
2528+
2529+
.mcp-tool-badge {
2530+
display: inline-block;
2531+
font-size: 0.6rem;
2532+
font-weight: 700;
2533+
letter-spacing: 0.05em;
2534+
color: #60a5fa;
2535+
background: rgba(96, 165, 250, 0.1);
2536+
border: 1px solid rgba(96, 165, 250, 0.25);
2537+
padding: 0.1rem 0.4rem;
2538+
border-radius: 4px;
2539+
width: fit-content;
2540+
}
2541+
25202542
/* ── MCP Locked / Gate Page ───────────────────────────────────── */
25212543

25222544
.mcp-locked-page {

frontend/src/pages/DocsPage.jsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,18 @@ function DocsPage() {
296296

297297
<h3>Available Tools</h3>
298298
<div className="docs-mcp-tools">
299+
<div className="docs-endpoint">
300+
<span className="docs-endpoint-method get">VISUAL</span>
301+
<span className="docs-endpoint-path">view_camera</span>
302+
</div>
303+
<p>See what a camera sees right now. Returns a live JPEG snapshot image that the AI can analyze.</p>
304+
305+
<div className="docs-endpoint">
306+
<span className="docs-endpoint-method get">VISUAL</span>
307+
<span className="docs-endpoint-path">watch_camera</span>
308+
</div>
309+
<p>Take multiple snapshots over time (2-10 frames, 1-30s interval). Useful for observing activity or changes.</p>
310+
299311
<div className="docs-endpoint">
300312
<span className="docs-endpoint-method get">READ</span>
301313
<span className="docs-endpoint-path">list_cameras</span>

frontend/src/pages/LandingPage.jsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ function LandingPage() {
143143
<div className="landing-feature-icon">{"</>"}</div>
144144
<h3>MCP Integration</h3>
145145
<p>
146-
Connect Claude Code or any AI tool directly to your cameras via the
147-
Model Context Protocol. Control everything through natural language.
146+
Give AI tools direct visual access to your cameras via the
147+
Model Context Protocol. See what cameras see and control everything through natural language.
148148
</p>
149149
<span className="landing-feature-badge">PRO</span>
150150
</div>
@@ -448,9 +448,9 @@ function LandingPage() {
448448
to their organization's cameras, nodes, and settings.
449449
</p>
450450
<div className="landing-mcp-examples">
451-
<div className="landing-mcp-example">"List all my cameras"</div>
452-
<div className="landing-mcp-example">"Get a stream URL for the garage"</div>
453-
<div className="landing-mcp-example">"Enable 24/7 recording"</div>
451+
<div className="landing-mcp-example">"Show me what the front door camera sees"</div>
452+
<div className="landing-mcp-example">"Watch the garage cam for 30 seconds"</div>
453+
<div className="landing-mcp-example">"Enable 24/7 recording on all nodes"</div>
454454
</div>
455455
<Link to="/sign-up" className="landing-btn landing-btn-primary">
456456
Try It Free
@@ -477,7 +477,7 @@ function LandingPage() {
477477
}`}</pre>
478478
</div>
479479
<div className="landing-mcp-tools-count">
480-
<span>13</span> tools available
480+
<span>15</span> tools available
481481
</div>
482482
</div>
483483
</div>

frontend/src/pages/McpPage.jsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import UpgradeModal from "../components/UpgradeModal.jsx"
77
const MCP_URL = `${window.location.origin}/mcp`
88

99
const TOOLS = [
10+
{ name: "view_camera", desc: "See what a camera sees — returns a live JPEG snapshot", highlight: true },
11+
{ name: "watch_camera", desc: "Take multiple snapshots over time to observe activity", highlight: true },
1012
{ name: "list_cameras", desc: "List all cameras with status and codec info" },
1113
{ name: "get_camera", desc: "Get details for a specific camera" },
1214
{ name: "get_stream_url", desc: "Get a temporary HLS stream URL" },
@@ -153,9 +155,9 @@ function McpPage() {
153155
</p>
154156

155157
<div className="mcp-locked-examples">
158+
<div className="mcp-example">"Show me what the front door camera sees"</div>
159+
<div className="mcp-example">"Watch the garage cam for 30 seconds"</div>
156160
<div className="mcp-example">"List all my cameras and their status"</div>
157-
<div className="mcp-example">"Get the stream URL for the garage cam"</div>
158-
<div className="mcp-example">"Enable 24/7 recording on all nodes"</div>
159161
</div>
160162

161163
<button className="mcp-upgrade-btn" onClick={() => setShowUpgrade(true)}>
@@ -168,9 +170,10 @@ function McpPage() {
168170
<h3><span>{TOOLS.length}</span> tools included with Pro</h3>
169171
<div className="mcp-tools-grid">
170172
{TOOLS.map((tool) => (
171-
<div key={tool.name} className="mcp-tool-card mcp-tool-locked">
173+
<div key={tool.name} className={`mcp-tool-card mcp-tool-locked${tool.highlight ? " mcp-tool-visual" : ""}`}>
172174
<code>{tool.name}</code>
173175
<span>{tool.desc}</span>
176+
{tool.highlight && <span className="mcp-tool-badge">VISUAL</span>}
174177
</div>
175178
))}
176179
</div>
@@ -337,9 +340,10 @@ function McpPage() {
337340
<h3>Available Tools ({TOOLS.length})</h3>
338341
<div className="mcp-tools-grid">
339342
{TOOLS.map((tool) => (
340-
<div key={tool.name} className="mcp-tool-card">
343+
<div key={tool.name} className={`mcp-tool-card${tool.highlight ? " mcp-tool-visual" : ""}`}>
341344
<code>{tool.name}</code>
342345
<span>{tool.desc}</span>
346+
{tool.highlight && <span className="mcp-tool-badge">VISUAL</span>}
343347
</div>
344348
))}
345349
</div>

0 commit comments

Comments
 (0)