onvif-mcp-stdio 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen Rhodes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: onvif-mcp-stdio
3
+ Version: 0.1.8
4
+ Summary: MCP server for ONVIF camera using stdio transport
5
+ Project-URL: Homepage, https://github.com/sr99622/onvif-mcp
6
+ Project-URL: Bug Reports, https://github.com/sr99622/onvif-mcp/issues
7
+ Requires-Python: >=3.10
8
+ License-File: LICENSE
9
+ Requires-Dist: mcp>=1.0.0
10
+ Requires-Dist: onvif-mcp-core
11
+ Requires-Dist: libonvif==4.0.24
12
+ Requires-Dist: niquests==3.20.1
13
+ Dynamic: license-file
@@ -0,0 +1,155 @@
1
+ <h2>ONVIF MCP Server</h2>
2
+
3
+ This MCP server is designed specifically to work with desktop AI agents. The server communicates with the agent over the STDIO interface, so no web server is needed. Explicit instructions are included here for working with
4
+
5
+ * Claude Desktop
6
+ * OpenClaw
7
+ * ChatGPT Desktop
8
+
9
+ Each of these platforms has different pros and cons. Claude Desktop has an integrated installer, so it is very easy to set up and get started, but is not as feature rich as the others. OpenClaw works best from the command line and supports ONVIF events, so built in camera detectors (people, cars, pets, etc.) can asynchronously notify OpenClaw and start a model analysis automatically. ChatGPT has a very evolved user interface and can display snapshot and live camera streams directly in the user interface.
10
+
11
+ This server uses the [uv](https://docs.astral.sh/uv/getting-started/installation/) runtime, so you will need that installed on your machine. If you are not familiar, uv is an advanced Python implementation that seamlessly handles package and project management for Python programs, and is widely used for AI projects.
12
+
13
+ If you want live streaming, you will need a stream server. This project is designed to work with [Cayenue](https://github.com/sr99622/Cayenue) for that service. Set `STREAM_SERVER_URL` to the client-facing web-player base URL, including the protocol and any path prefix, for example `https://camera.home.arpa/webrtc`. The ONVIF MCP server can operate without this setting, but live-stream URL tools will be unavailable.
14
+
15
+ <h3>Claude Desktop Installation</h3>
16
+
17
+ To install the server in Claude Destop, you can download the [installer](https://github.com/sr99622/onvif-mcp/releases/download/v0.0.70/onvif-mcp.mcpb) to your local hard drive. Open Claude Desktop and use the hamburger icon in the upper left corner to open the File -> Settings menu and select Extensions from the left side panel. Click the Advanced Settings then click the Install Extension button to open a file selection dialog. Navigate to the repository and select the simple-mcp-server.mcpb file and the MCP server installer will present a dialog to be filled out with site parameters.
18
+
19
+ The installer will ask for camera credentials and `STREAM_SERVER_URL`. If you are not using a stream server, you can leave the URL blank, but camera credentials are still required. Enable the MCP server and click Configure to review its settings and permissions.
20
+
21
+ To create the installer, you need the CLI tool from Anthropic
22
+
23
+ ```
24
+ npm install -g @anthropic-ai/mcpb
25
+ ```
26
+
27
+ Then run the command
28
+
29
+ ```
30
+ mcpb pack
31
+ ```
32
+
33
+ If you want to uninstall, you might need to click the uninstall button twice to get it to stick.
34
+
35
+ <h3>OpenClaw Installation</h3>
36
+
37
+ The server is installed to OpenClaw by downloading the source files for this project then editing the .openclaw/openclaw.json configuration file. You will need git installed on your machine to get the source code.
38
+
39
+ ```
40
+ git clone https://github.com/sr99622/onvif-mcp
41
+ ```
42
+
43
+ The default location for the .openclaw folder is in the users home directory. For best results, use the full path name to identify the uv installation location in the "command" parameter, you can find it using `which uv` on Mac OS and Linux. Adjust the path seen below the `--directory` arg for the location that you git cloned above. To enable event handling, include the "hooks" section as shown below. The ONVIF MCP server will work fine without hooks if you don't want that feature.
44
+
45
+ ```
46
+ "mcp": {
47
+ "sessionIdleTtlMs": 0,
48
+ "servers": {
49
+ "camera": {
50
+ "command": "/path/to/bin/uv",
51
+ "args": [
52
+ "--directory",
53
+ "/path/to/onvif-mcp/src",
54
+ "run",
55
+ "camera.py"
56
+ ],
57
+ "env": {
58
+ "CAMERA_USERNAME": "admin",
59
+ "CAMERA_PASSWORD": "admin123",
60
+ "STREAM_SERVER_URL": "https://camera.home.arpa/webrtc",
61
+ "OPENCLAW_HOOK_TOKEN": "shared-secret",
62
+ }
63
+ }
64
+ }
65
+ },
66
+ "hooks": {
67
+ "enabled": true,
68
+ "token": "shared-secret",
69
+ "path": "/hooks",
70
+ "mappings": [
71
+ {
72
+ "id": "camera-motion",
73
+ "match": { "path": "camera-motion" },
74
+ "action": "agent",
75
+ "wakeMode": "now",
76
+ "name": "Camera Motion",
77
+ "messageTemplate": "{{payload.message}}",
78
+ "allowUnsafeExternalContent": true
79
+ }
80
+ ]
81
+ },
82
+ ```
83
+
84
+ <h3>ChatGPT Desktop App Installation</h3>
85
+
86
+ The server is implemented in source code that you download with git.
87
+
88
+ ```
89
+ git clone https://github.com/sr99622/onvif-mcp
90
+ ```
91
+
92
+ The configuration is made by editing the file .codex/config.toml, add the following. You can ask ChatGPT to do this for you if you add your specific path to onvif-mcp folder that you just downloaded from git. Tell the model
93
+
94
+ ```
95
+ Please add the following MCP server configuration details to the $HOME/.codex/config.toml file.
96
+
97
+
98
+ [mcp_servers.camera]
99
+ command = "uv"
100
+ args = ["run", "camera.py"]
101
+ cwd = '/path/to/onvif-mcp/src'
102
+
103
+ [mcp_servers.camera.env]
104
+ CAMERA_USERNAME = "admin"
105
+ CAMERA_PASSWORD = "admin123"
106
+ STREAM_SERVER_URL = "https://camera.home.arpa/webrtc"
107
+ ```
108
+
109
+ You will need to re-start ChatGPT to get the server started.
110
+
111
+ <h2>Using the ONVIF MCP Server</h2>
112
+
113
+ Once installed, the tool will be available for the agent. A good starting point is to query the agent to tell you the server version.
114
+
115
+ ```
116
+ Please tell me the current version of the camera mcp server.
117
+ ```
118
+
119
+ On the first run, the model may not recognize the server right away. There may be some delay while the agent is asking permission to run the server. You might need to confirm the permission first before the agent is able to use the tool. If it struggles during the first run, ask again after you have granted permission.
120
+
121
+ To see cameras on your local network
122
+
123
+ ```
124
+ get cameras
125
+ ```
126
+
127
+ This should produce a list of cameras that are connected to your local network. If your cameras are remote, you can ask to see a camera using its IP address
128
+
129
+ ```
130
+ get camera at 10.1.1.77
131
+ ```
132
+
133
+ To see the camera snapshot in your browser
134
+
135
+ ```
136
+ please show the camera snapshot from 10.1.1.77 in the browser
137
+ ```
138
+
139
+ There is a default name assigned to the camera when it is discovered. This is not necessarily the best name to work with, so you can set your own name using the change hostname function.
140
+
141
+ ```
142
+ Please change the hostname on camera 10.1.1.77 to Driveway
143
+ ```
144
+
145
+ You will then be able to address the camera by that name.
146
+
147
+ You can introduce the camera snapshot into the model context by downloading to file
148
+
149
+ ```
150
+ Please download the Driveway snapshot to file.
151
+ ```
152
+
153
+ Depending on agent abilities, the snapshot can be displayed in the model chat, and the model can be asked to describe the image.
154
+
155
+ There are many other functions available to control and observe the camera, you can ask the agent about it's abilities or to explain a function, and it will be able to come up with an answer for you.
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "onvif-mcp-stdio"
3
+ version = "0.1.8"
4
+ description = "MCP server for ONVIF camera using stdio transport"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "mcp>=1.0.0",
8
+ "onvif-mcp-core",
9
+ "libonvif==4.0.24",
10
+ "niquests==3.20.1",
11
+ ]
12
+
13
+ [tool.uv.sources]
14
+ onvif-mcp-core = { workspace = true }
15
+
16
+ [project.urls]
17
+ "Homepage" = "https://github.com/sr99622/onvif-mcp"
18
+ "Bug Reports" = "https://github.com/sr99622/onvif-mcp/issues"
19
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,1025 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import time
7
+ from datetime import datetime
8
+ from importlib.metadata import version as get_installed_version
9
+ from pathlib import Path
10
+ from libonvif.utils.server import EventServer
11
+ from libonvif.utils.subscriber import SubscriptionManager
12
+ from libonvif.devices.camera import Camera, get_camera_by_ip, \
13
+ camera_from_json, refresh_camera
14
+ from mcp.server.fastmcp import FastMCP, Context
15
+ from mcp.server.elicitation import AcceptedElicitation, DeclinedElicitation, CancelledElicitation
16
+ from pydantic import BaseModel
17
+ import os
18
+ import sys
19
+ import webbrowser
20
+ import niquests as requests
21
+ from niquests.auth import HTTPDigestAuth
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ from typing import Any
26
+ from onvif_mcp_core.audio import (
27
+ set_camera_audio_encoding as set_camera_audio_encoding_core,
28
+ set_camera_audio_sample_rate as set_camera_audio_sample_rate_core,
29
+ )
30
+ from onvif_mcp_core.video import (
31
+ set_camera_video_bitrate as set_camera_video_bitrate_core,
32
+ set_camera_video_frame_rate as set_camera_video_frame_rate_core,
33
+ set_camera_video_gov_length as set_camera_video_gov_length_core,
34
+ set_camera_video_resolution as set_camera_video_resolution_core,
35
+ )
36
+ from onvif_mcp_core.ptz import (
37
+ create_camera_preset_tour as create_camera_preset_tour_core,
38
+ goto_camera_preset as goto_camera_preset_core,
39
+ pan_tilt_camera as pan_tilt_camera_core,
40
+ remove_camera_preset as remove_camera_preset_core,
41
+ remove_camera_preset_tour as remove_camera_preset_tour_core,
42
+ set_camera_preset as set_camera_preset_core,
43
+ set_camera_preset_tour as set_camera_preset_tour_core,
44
+ start_camera_preset_tour as start_camera_preset_tour_core,
45
+ stop_camera_pan_tilt as stop_camera_pan_tilt_core,
46
+ stop_camera_preset_tour as stop_camera_preset_tour_core,
47
+ stop_camera_zoom as stop_camera_zoom_core,
48
+ zoom_camera as zoom_camera_core,
49
+ )
50
+ from onvif_mcp_core.device import (
51
+ change_camera_hostname as change_camera_hostname_core,
52
+ reboot_camera as reboot_camera_core,
53
+ sync_camera_time as sync_camera_time_core,
54
+ )
55
+ from onvif_mcp_core.camera_queries import get_adapters as get_adapters_query
56
+ from onvif_mcp_core.credentials import get_camera_credentials
57
+ from onvif_mcp_core.guidance import TOOL_GUIDANCE
58
+ from onvif_mcp_core.streaming import get_web_player_url as get_web_player_url_core
59
+ from onvif_mcp_core.tools import (
60
+ register_audio_configuration_tools,
61
+ register_camera_query_tools,
62
+ register_device_management_tools,
63
+ register_ptz_tools,
64
+ register_streaming_tools,
65
+ register_video_configuration_tools,
66
+ )
67
+
68
+ LOG_FILE = Path(__file__).parent / "camera_events.log"
69
+
70
+ logging.basicConfig(
71
+ filename=LOG_FILE,
72
+ level=logging.WARNING,
73
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
74
+ )
75
+ logger = logging.getLogger(__name__)
76
+ logger.setLevel(logging.DEBUG)
77
+
78
+ mcp = FastMCP("onvif-mcp")
79
+ register_video_configuration_tools(mcp)
80
+ register_audio_configuration_tools(mcp)
81
+ register_ptz_tools(mcp)
82
+ register_device_management_tools(mcp)
83
+ register_camera_query_tools(mcp)
84
+ register_streaming_tools(mcp)
85
+
86
+ @mcp.tool(description=TOOL_GUIDANCE["get_adapters"])
87
+ async def get_adapters() -> str:
88
+ """Return a list of available active network adapters.
89
+
90
+ Returns:
91
+ A delimited string containing the IP address of each active adapter,
92
+ one per line, separated by "\n--\n".
93
+ """
94
+ return await get_adapters_query()
95
+
96
+ USER_AGENT = "onvif-mcp-app/1.0"
97
+
98
+ class TripTypeResponse(BaseModel):
99
+ value: str
100
+
101
+ # --- Event listener integration ---
102
+ # Bridges the standalone motion_watcher.py prototype (packages/sse) into
103
+ # this server, generalized to "event listener" since future work will
104
+ # subscribe to event topics beyond just motion. All cameras share ONE
105
+ # EventServer (ONVIF push events are just an HTTP POST to whatever URL a
106
+ # camera was told during Subscribe - nothing about the protocol requires
107
+ # a separate listener per camera), created on first use by whichever
108
+ # camera adds its first subscribed event. Each camera gets its own
109
+ # SubscriptionManager, since subscriptions (and their resubscribe
110
+ # timers) are inherently per-camera.
111
+
112
+ EVENT_SERVER_PORT = int(os.environ.get("EVENT_SERVER_PORT", "8856"))
113
+ SNAPSHOT_DIR = Path(__file__).parent / "snapshots"
114
+ OPENCLAW_HOOK_URL = os.environ.get("OPENCLAW_HOOK_URL", "http://127.0.0.1:18789/hooks/camera-motion")
115
+ OPENCLAW_HOOK_TOKEN = os.environ.get("OPENCLAW_HOOK_TOKEN", "")
116
+ # Home-relative subdirectory OpenClaw uses as its own workspace folder for
117
+ # camera snapshots/descriptions. Motion-event snapshots are now written
118
+ # directly here by _on_event_listener_event (see CAMERA_EVENTS_DIR below)
119
+ # instead of camera.py's own SNAPSHOT_DIR, so there is exactly one capture
120
+ # per event, taken at alarm time, and OpenClaw's `read` tool loads those
121
+ # same bytes rather than re-querying the camera itself several seconds
122
+ # later once its own reasoning gets around to a download step. OpenClaw's
123
+ # own tools already resolve "~" against this same machine's home
124
+ # directory (confirmed via trajectory review), so using "~" in both the
125
+ # path we write to and the path we tell OpenClaw to read needs no
126
+ # $WORKSPACE_DIR substitution or other coordination.
127
+ OPENCLAW_SNAPSHOT_SUBDIR = "onvif-events"
128
+ CAMERA_EVENTS_DIR = Path(os.path.expanduser(f"~/{OPENCLAW_SNAPSHOT_SUBDIR}"))
129
+
130
+ # The one shared EventServer instance, or None until the first camera
131
+ # adds a subscribed event. Created by _ensure_camera_subscription_entry.
132
+ _event_server = None
133
+
134
+ # Per-camera state, keyed by IP address: {"camera": Camera, "subscription_manager": SubscriptionManager}.
135
+ # Populated lazily, the first time a given camera's subscriptions are
136
+ # touched. The Camera object here is queried once and then reused
137
+ # across resyncs (its subscription_references list is what actually
138
+ # tracks live ONVIF subscriptions) - it is NOT refreshed automatically,
139
+ # so if a camera's IP/credentials/xaddr genuinely change, its entry here
140
+ # would need to be rebuilt (not handled yet - a later concern).
141
+ _camera_subscriptions: dict[str, dict] = {}
142
+
143
+ # In-memory store, keyed by camera IP address, for the set of event
144
+ # topics the user wants that camera marked for observation on. Kept
145
+ # deliberately separate from _event_server/_camera_subscriptions above:
146
+ # those track live ONVIF subscription state (built lazily, in memory
147
+ # only), while this needs to hold user preferences for potentially many
148
+ OPENCLAW_CHAT_SESSION_KEY = "agent:main:main"
149
+
150
+ @mcp.tool()
151
+ async def send_message_to_openclaw_chat(
152
+ message: str,
153
+ session_key: str = OPENCLAW_CHAT_SESSION_KEY,
154
+ ) -> str:
155
+ """
156
+ Inject an assistant message into an OpenClaw WebChat session.
157
+
158
+ Args:
159
+ message:
160
+ Text to display in the OpenClaw chat.
161
+
162
+ session_key:
163
+ OpenClaw session receiving the message. The normal main-agent
164
+ session is commonly "agent:main:main".
165
+
166
+ Returns:
167
+ A status message describing the result.
168
+
169
+ Raises:
170
+ ValueError:
171
+ If message or session_key is empty.
172
+
173
+ RuntimeError:
174
+ If the OpenClaw CLI cannot be found or the RPC call fails.
175
+ """
176
+ message = message.strip()
177
+ session_key = session_key.strip()
178
+
179
+ if not message:
180
+ raise ValueError("message cannot be empty")
181
+
182
+ if not session_key:
183
+ raise ValueError("session_key cannot be empty")
184
+
185
+ openclaw_executable = shutil.which("openclaw")
186
+
187
+ if openclaw_executable is None:
188
+ raise RuntimeError("The openclaw executable was not found in PATH")
189
+
190
+ params = json.dumps(
191
+ {
192
+ "sessionKey": session_key,
193
+ "message": message,
194
+ }
195
+ )
196
+
197
+ command = [
198
+ openclaw_executable,
199
+ "gateway",
200
+ "call",
201
+ "chat.inject",
202
+ "--params",
203
+ params,
204
+ "--json",
205
+ ]
206
+
207
+ try:
208
+ completed = subprocess.run(
209
+ command,
210
+ capture_output=True,
211
+ text=True,
212
+ timeout=15,
213
+ check=False,
214
+ )
215
+ except subprocess.TimeoutExpired as exc:
216
+ raise RuntimeError("OpenClaw chat.inject timed out") from exc
217
+ except OSError as exc:
218
+ raise RuntimeError(
219
+ f"Unable to run the OpenClaw CLI: {exc}"
220
+ ) from exc
221
+
222
+ if completed.returncode != 0:
223
+ stderr = completed.stderr.strip()
224
+ stdout = completed.stdout.strip()
225
+
226
+ details = stderr or stdout or "no diagnostic output"
227
+
228
+ raise RuntimeError(
229
+ f"OpenClaw chat.inject failed with exit code "
230
+ f"{completed.returncode}: {details}"
231
+ )
232
+
233
+ try:
234
+ response: Any = json.loads(completed.stdout)
235
+ except json.JSONDecodeError:
236
+ response = completed.stdout.strip()
237
+
238
+ return (
239
+ f"Message injected into OpenClaw session "
240
+ f"{session_key}: {response}"
241
+ )
242
+
243
+ def _build_snapshot_filename(camera_ip: str, event_type: str) -> str:
244
+ """
245
+ Shared naming scheme for every snapshot/marker file the event
246
+ listener produces, so files can be found by camera, event type, and
247
+ time without needing to open them:
248
+
249
+ {camera_ip with dashes instead of dots}_{event_type}_{timestamp}.jpg
250
+
251
+ e.g. "10-1-1-77_motion_true_20260718T215035.jpg"
252
+ """
253
+ safe_ip = camera_ip.replace(".", "-")
254
+ timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
255
+ return f"{safe_ip}_{event_type}_{timestamp}.jpg"
256
+
257
+
258
+ def _fetch_motion_snapshot(camera: Camera, filename: str, directory: Path = SNAPSHOT_DIR):
259
+ """
260
+ Download the given camera's current snapshot as a JPEG file, saved
261
+ under the given filename in the given directory (SNAPSHOT_DIR by
262
+ default). Returns the saved Path, or None on failure.
263
+
264
+ Motion-event calls from _on_event_listener_event pass
265
+ directory=CAMERA_EVENTS_DIR instead, so the one fetch this function
266
+ performs lands directly where OpenClaw will read it from - see the
267
+ comment on CAMERA_EVENTS_DIR above for why that matters.
268
+ """
269
+ snapshot_uri = camera.profiles[0].snapshot_uri
270
+ try:
271
+ credentials = get_camera_credentials()
272
+ response = requests.get(
273
+ snapshot_uri,
274
+ auth=HTTPDigestAuth(credentials.username, credentials.password),
275
+ timeout=10,
276
+ )
277
+ response.raise_for_status()
278
+ except Exception as e:
279
+ logger.error(f"Failed to fetch motion snapshot: {e}")
280
+ return None
281
+
282
+ directory.mkdir(exist_ok=True, parents=True)
283
+ path = directory / filename
284
+ path.write_bytes(response.content)
285
+ logger.debug(f"Saved motion snapshot to {path}")
286
+ return path
287
+
288
+
289
+ def _save_empty_motion_marker(filename: str):
290
+ """
291
+ Save a 0-byte marker file recording a motion-ended (State: false)
292
+ event without fetching a real image for it.
293
+ """
294
+ SNAPSHOT_DIR.mkdir(exist_ok=True)
295
+ path = SNAPSHOT_DIR / filename
296
+ path.touch()
297
+ logger.debug(f"Recorded empty motion marker at {path}")
298
+ return path
299
+
300
+
301
+ def _notify_openclaw_of_motion(camera_ip: str, filename: str) -> None:
302
+ """
303
+ POST to OpenClaw's /hooks/camera-motion endpoint (a named hook mapping
304
+ configured in openclaw.json, NOT the generic /hooks/agent path),
305
+ telling the agent exactly what to do and where to find the snapshot
306
+ and description - naming the exact tools and paths up front, rather
307
+ than leaving the agent to rediscover a working sequence through
308
+ trial and error on every motion event (get_snapshot_image_base64_encoded
309
+ and the browser tool both proved unusable to it in earlier testing).
310
+
311
+ Does NOT ask OpenClaw to fetch the snapshot itself. The caller
312
+ (_on_event_listener_event) already wrote it directly to
313
+ CAMERA_EVENTS_DIR (a real "~/onvif-events" path on this same
314
+ machine) at alarm time, via _fetch_motion_snapshot. Trajectory review
315
+ (2026-07-22, 5 clean runs) showed OpenClaw's own
316
+ camera__download_snapshot_to_file step re-queries the camera's live
317
+ snapshot URL again, ~4.5s+ after the alarm on average once its model
318
+ reasoning gets around to calling it - since that endpoint returns
319
+ whatever the camera sees at request time, not a buffered frame from
320
+ the alarm, that second fetch can be a materially different moment
321
+ than the one that actually triggered the event (confirmed visually:
322
+ consecutive live snapshots 5s apart showed a clearly different pose).
323
+ Telling OpenClaw the file already exists and to just `read` it
324
+ removes that second fetch entirely, so the image it describes is the
325
+ same one captured at alarm time. This also removes one full tool
326
+ round-trip (and its ~4.5s model reasoning step) from every
327
+ notification, on top of the earlier camera__get_camera removal.
328
+
329
+ Requires an openclaw.json hooks.mappings entry like:
330
+
331
+ {
332
+ "id": "camera-motion",
333
+ "match": { "path": "camera-motion" },
334
+ "action": "agent",
335
+ "wakeMode": "now",
336
+ "name": "Camera Motion",
337
+ "messageTemplate": "{{payload.message}}",
338
+ "allowUnsafeExternalContent": true
339
+ }
340
+
341
+ allowUnsafeExternalContent must live in this mapping config, NOT in
342
+ the JSON body we send: normalizeAgentPayload() (the generic
343
+ /hooks/agent request parser) doesn't recognize that field at all, so
344
+ sending it directly in our payload was silently dropped - confirmed
345
+ by trajectory review showing the SECURITY NOTICE wrapper still
346
+ present after we started sending it. Worse, /hooks/agent and
347
+ /hooks/wake are special-cased in the request router and always
348
+ return before hooks.mappings is ever consulted, so no mapping -
349
+ including one matched by source rather than path - can apply to
350
+ those two paths regardless of config. A mapped path is the only way
351
+ to reach allowUnsafeExternalContent at all.
352
+
353
+ Without it, OpenClaw wraps this message in a SECURITY NOTICE +
354
+ EXTERNAL_UNTRUSTED_CONTENT boundary (since hook requests default to
355
+ externalContentSource: "webhook"), telling the model not to follow
356
+ instructions embedded in it - directly undermining the explicit
357
+ numbered steps below. We control both the sender (this script) and
358
+ the content (our own instructions), so this isn't actually untrusted
359
+ third-party content; it's just labeled that way by default. Batch
360
+ trajectory review showed the majority of sampled motion-event runs
361
+ called get_snapshot_image_base64_encoded anyway - the exact tool
362
+ step 1 explicitly says not to use - despite that instruction being
363
+ present in every one of those runs, so this is a hypothesis to test
364
+ against fresh, controlled events, not a confirmed fix.
365
+ """
366
+ file_path = f"~/{OPENCLAW_SNAPSHOT_SUBDIR}/{filename}"
367
+ description_path = f"~/{OPENCLAW_SNAPSHOT_SUBDIR}/{Path(filename).stem}.txt"
368
+ payload = {
369
+ "message": (
370
+ f"Motion detected on the camera at {camera_ip}. A snapshot has "
371
+ f"already been saved to exactly \"{file_path}\" - it was captured "
372
+ f"at the moment of the alarm, so use it as-is. Do the following:\n"
373
+ f"1. Call read on \"{file_path}\" to view the image.\n"
374
+ f"2. Write a brief description of what you see using the write "
375
+ f"tool, saving it to exactly \"{description_path}\" as plain "
376
+ f"text (just the description itself, no extra formatting).\n"
377
+ f"3. Call camera__send_message_to_openclaw_chat with message set to "
378
+ f"exactly that same description.\n"
379
+ "Do not call camera__get_camera, camera__download_snapshot_to_file, "
380
+ "or any other camera tool to fetch or look up the snapshot - it is "
381
+ "already saved at the path above. Do not use "
382
+ "get_snapshot_image_base64_encoded or the browser tool for this - "
383
+ "go directly to read."
384
+ ),
385
+ }
386
+ try:
387
+ response = requests.post(
388
+ OPENCLAW_HOOK_URL,
389
+ json=payload,
390
+ headers={"Authorization": f"Bearer {OPENCLAW_HOOK_TOKEN}"},
391
+ timeout=10,
392
+ )
393
+ response.raise_for_status()
394
+ logger.debug(f"Notified OpenClaw: {response.json()}")
395
+ except Exception as e:
396
+ logger.error(f"Failed to notify OpenClaw: {e}")
397
+
398
+
399
+ def _ensure_camera_subscription_entry(ip_address: str) -> dict:
400
+ """
401
+ Ensure the shared EventServer exists (starting it on the very first
402
+ call across any camera) and this camera's own SubscriptionManager
403
+ exists (creating it, and querying the camera fresh, the first time
404
+ this particular camera is touched). Returns the _camera_subscriptions
405
+ entry for this camera.
406
+ """
407
+ global _event_server
408
+
409
+ if _event_server is None:
410
+ _event_server = EventServer("0.0.0.0", EVENT_SERVER_PORT, _on_event_listener_event)
411
+ _event_server.start()
412
+
413
+ if ip_address not in _camera_subscriptions:
414
+ credentials = get_camera_credentials()
415
+ camera = get_camera_by_ip(
416
+ ip_address,
417
+ credentials.username,
418
+ credentials.password,
419
+ )
420
+ _camera_subscriptions[ip_address] = {
421
+ "camera": camera,
422
+ "subscription_manager": SubscriptionManager(camera),
423
+ }
424
+
425
+ return _camera_subscriptions[ip_address]
426
+
427
+
428
+ def _subscribe_camera_event_topic(ip_address: str, event_topic: str) -> None:
429
+ """
430
+ Subscribe a camera to a single ONVIF event topic, mirroring the real
431
+ ONVIF Subscribe operation directly - it does not touch any of the
432
+ camera's other active push subscriptions.
433
+ """
434
+ entry = _ensure_camera_subscription_entry(ip_address)
435
+ camera = entry["camera"]
436
+ subscription_manager = entry["subscription_manager"]
437
+
438
+ subscription_manager.subscribe_push_event(camera, "0.0.0.0", EVENT_SERVER_PORT, event_topic)
439
+
440
+
441
+ def _unsubscribe_camera_events(ip_address: str) -> None:
442
+ """
443
+ Unsubscribe a camera from ALL of its ONVIF push subscriptions,
444
+ mirroring the real ONVIF Unsubscribe operation directly - ONVIF has
445
+ no operation to target a single topic while leaving others active,
446
+ so this always clears everything for the camera at once.
447
+ """
448
+ entry = _ensure_camera_subscription_entry(ip_address)
449
+ camera = entry["camera"]
450
+ subscription_manager = entry["subscription_manager"]
451
+
452
+ subscription_manager.unsubscribe_events(camera)
453
+
454
+
455
+ def _on_event_listener_event(alarms: list[dict]) -> None:
456
+ """
457
+ Callback invoked by EventServer's background thread on every incoming
458
+ ONVIF event, from any camera - all cameras share this one EventServer,
459
+ so this looks up which camera an event actually came from via its
460
+ ip_address field (present on every parsed alarm) against
461
+ _camera_subscriptions, rather than assuming a single fixed camera.
462
+
463
+ Still only ACTS on VideoSource/MotionAlarm for now, even though a
464
+ camera may genuinely be subscribed to other topics too (subscribing
465
+ itself is already fully general via _subscribe_camera_event_topic
466
+ above) - generalizing this handling logic to other topics is a
467
+ separate, later step. Events on any other topic are received here
468
+ but currently just ignored.
469
+
470
+ State: "true" (real motion) saves a real local snapshot and notifies
471
+ OpenClaw. State: "false" (motion ended) only records a 0-byte local
472
+ marker file - no OpenClaw notification, since spending an agent run
473
+ on "motion stopped" would reintroduce noise.
474
+ """
475
+ for alarm in alarms:
476
+ logger.debug(f"Event listener event: {alarm}")
477
+
478
+ camera_ip = alarm.get("ip_address")
479
+ entry = _camera_subscriptions.get(camera_ip)
480
+ if not entry:
481
+ logger.error(f"Received event for camera at {camera_ip}, which has no tracked subscription; ignoring.")
482
+ continue
483
+
484
+ topic = alarm.get("topic")
485
+ state = alarm.get("data", {}).get("State")
486
+ logger.info(f"Alarm received: camera={camera_ip} topic={topic} state={state}")
487
+
488
+ if topic != "VideoSource/MotionAlarm":
489
+ continue
490
+
491
+ camera = entry["camera"]
492
+ is_motion = str(state).lower() == "true"
493
+ event_type = "motion_true" if is_motion else "motion_false"
494
+ filename = _build_snapshot_filename(camera_ip, event_type)
495
+
496
+ if is_motion:
497
+ pull_start = time.perf_counter()
498
+ _fetch_motion_snapshot(camera, filename, directory=CAMERA_EVENTS_DIR)
499
+ pull_elapsed_s = time.perf_counter() - pull_start
500
+ logger.info(
501
+ f"Snapshot pull timing: camera={camera_ip} filename={filename} "
502
+ f"elapsed={pull_elapsed_s:.3f}s"
503
+ )
504
+
505
+ notify_start = time.perf_counter()
506
+ _notify_openclaw_of_motion(camera_ip, filename)
507
+ notify_elapsed_s = time.perf_counter() - notify_start
508
+ logger.info(
509
+ f"OpenClaw notify timing: camera={camera_ip} filename={filename} "
510
+ f"elapsed={notify_elapsed_s:.3f}s"
511
+ )
512
+ else:
513
+ _save_empty_motion_marker(filename)
514
+
515
+ def list_files(directory):
516
+ """Recursively list all files in a directory."""
517
+ for root, _, files in os.walk(directory):
518
+ for file in files:
519
+ yield os.path.join(root, file)
520
+
521
+ @mcp.tool()
522
+ def grep_search(pattern, directory, fileExtension=None):
523
+ """Search for a regex pattern in files under a directory."""
524
+ results = []
525
+
526
+ # Validate directory
527
+ if not os.path.isdir(directory):
528
+ return {"error": f"Directory not found: {directory}"}
529
+
530
+ try:
531
+ regex = re.compile(pattern, re.IGNORECASE)
532
+ except re.error as e:
533
+ return {"error": f"Invalid regex: {e}"}
534
+
535
+ try:
536
+ for file_path in list_files(directory):
537
+ if fileExtension and not file_path.endswith(fileExtension):
538
+ continue
539
+
540
+ try:
541
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
542
+ for line_num, line in enumerate(f, start=1):
543
+ if regex.search(line):
544
+ results.append({
545
+ "file": file_path,
546
+ "lineNum": line_num,
547
+ "line": line.strip()
548
+ })
549
+ except (OSError, UnicodeDecodeError):
550
+ # Skip unreadable files
551
+ continue
552
+
553
+ except Exception as e:
554
+ return {"error": f"Search failed: {e}"}
555
+
556
+ return {"matches": results}
557
+
558
+ @mcp.tool()
559
+ async def example_async_tool(context: Context) -> str:
560
+ """
561
+ Example async tool that asks the user a question via MCP elicitation,
562
+ to experiment with the elicitation flow (server -> client -> user ->
563
+ client -> server) as a building block for eventually responding to
564
+ camera events interactively.
565
+ """
566
+ result = await context.elicit(
567
+ message="What type of trip are you planning? Options: business, leisure, family, adventure",
568
+ schema=TripTypeResponse,
569
+ )
570
+ if isinstance(result, AcceptedElicitation):
571
+ return result.data.value
572
+ elif isinstance(result, DeclinedElicitation):
573
+ return "DECLINED"
574
+ elif isinstance(result, CancelledElicitation):
575
+ return "CANCELLED"
576
+ return "INVALID RESPONSE"
577
+
578
+ @mcp.tool()
579
+ async def get_camera_mcp_version() -> str:
580
+ """
581
+ Get the version of the camera application, along with the version of the
582
+ installed libonvif package it depends on.
583
+
584
+ Returns:
585
+ A JSON string with two fields:
586
+ camera_mcp_version: version derived from the pyproject.toml file.
587
+ libonvif_version: version of the installed libonvif package,
588
+ read via importlib.metadata.
589
+ """
590
+
591
+ camera_mcp_version = None
592
+ current_file = Path(__file__)
593
+ filename = Path(current_file.parent.parent) / "pyproject.toml"
594
+ with open(filename, "r") as f:
595
+ for line in f:
596
+ if line.startswith("version"):
597
+ camera_mcp_version = line.split("=")[1].strip().strip('"')
598
+ logger.debug(f"Found camera_mcp version: {camera_mcp_version}")
599
+ break
600
+
601
+ try:
602
+ libonvif_version = get_installed_version("libonvif")
603
+ except Exception as e:
604
+ logger.error(f"Failed to get libonvif version: {e}")
605
+ libonvif_version = None
606
+
607
+ return json.dumps({
608
+ "camera_mcp_version": camera_mcp_version,
609
+ "libonvif_version": libonvif_version,
610
+ }, indent=4)
611
+
612
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_video_resolution"])
613
+ async def set_camera_video_resolution(ip_address: str, profile_token: str, resolution: str) -> str:
614
+ return await set_camera_video_resolution_core(
615
+ ip_address,
616
+ profile_token,
617
+ resolution,
618
+ )
619
+
620
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_video_frame_rate"])
621
+ async def set_camera_video_frame_rate(ip_address: str, profile_token: str, frame_rate_limit: int) -> str:
622
+ return await set_camera_video_frame_rate_core(
623
+ ip_address,
624
+ profile_token,
625
+ frame_rate_limit,
626
+ )
627
+
628
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_video_bitrate"])
629
+ async def set_camera_video_bitrate(ip_address: str, profile_token: str, bitrate_limit: int) -> str:
630
+ return await set_camera_video_bitrate_core(
631
+ ip_address,
632
+ profile_token,
633
+ bitrate_limit,
634
+ )
635
+
636
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_video_gov_length"])
637
+ async def set_camera_video_gov_length(ip_address: str, profile_token: str, gov_length: int) -> str:
638
+ return await set_camera_video_gov_length_core(
639
+ ip_address,
640
+ profile_token,
641
+ gov_length,
642
+ )
643
+
644
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_audio_encoding"])
645
+ async def set_camera_audio_encoding(ip_address: str, profile_token: str, encoding: str) -> str:
646
+ return await set_camera_audio_encoding_core(
647
+ ip_address,
648
+ profile_token,
649
+ encoding,
650
+ )
651
+
652
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_audio_sample_rate"])
653
+ async def set_camera_audio_sample_rate(ip_address: str, profile_token: str, sample_rate: int) -> str:
654
+ return await set_camera_audio_sample_rate_core(
655
+ ip_address,
656
+ profile_token,
657
+ sample_rate,
658
+ )
659
+
660
+ @mcp.tool(description=TOOL_GUIDANCE["goto_camera_preset"])
661
+ async def goto_camera_preset(camera_ptz_xaddr: str, camera_profile_token: str, camera_preset_token: str, camera_time_offset: int) -> str:
662
+ return await goto_camera_preset_core(
663
+ camera_ptz_xaddr,
664
+ camera_profile_token,
665
+ camera_preset_token,
666
+ camera_time_offset,
667
+ )
668
+
669
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_preset"])
670
+ async def set_camera_preset(ip_address: str, profile_token: str, preset_token: str = None, preset_name: str = None) -> str:
671
+ return await set_camera_preset_core(
672
+ ip_address,
673
+ profile_token,
674
+ preset_token,
675
+ preset_name,
676
+ )
677
+
678
+ @mcp.tool(description=TOOL_GUIDANCE["remove_camera_preset"])
679
+ async def remove_camera_preset(ip_address: str, profile_token: str, preset_token: str) -> str:
680
+ return await remove_camera_preset_core(
681
+ ip_address,
682
+ profile_token,
683
+ preset_token,
684
+ )
685
+
686
+ @mcp.tool(description=TOOL_GUIDANCE["create_camera_preset_tour"])
687
+ async def create_camera_preset_tour(ip_address: str, profile_token: str, tour_name: str = None) -> str:
688
+ return await create_camera_preset_tour_core(
689
+ ip_address,
690
+ profile_token,
691
+ tour_name,
692
+ )
693
+
694
+ @mcp.tool(description=TOOL_GUIDANCE["set_camera_preset_tour"])
695
+ async def set_camera_preset_tour(ip_address: str, profile_token: str, tour_token: str, tour_name: str = None, auto_start: bool = None, spots: list[dict] = None) -> str:
696
+ return await set_camera_preset_tour_core(
697
+ ip_address,
698
+ profile_token,
699
+ tour_token,
700
+ tour_name,
701
+ auto_start,
702
+ spots,
703
+ )
704
+
705
+ @mcp.tool(description=TOOL_GUIDANCE["remove_camera_preset_tour"])
706
+ async def remove_camera_preset_tour(ip_address: str, profile_token: str, tour_token: str) -> str:
707
+ return await remove_camera_preset_tour_core(
708
+ ip_address,
709
+ profile_token,
710
+ tour_token,
711
+ )
712
+
713
+ @mcp.tool(description=TOOL_GUIDANCE["start_camera_preset_tour"])
714
+ async def start_camera_preset_tour(camera_ptz_xaddr: str, camera_profile_token: str, camera_ptz_tour_token: str, camera_time_offset: int) -> str:
715
+ return await start_camera_preset_tour_core(
716
+ camera_ptz_xaddr,
717
+ camera_profile_token,
718
+ camera_ptz_tour_token,
719
+ camera_time_offset,
720
+ )
721
+
722
+ @mcp.tool(description=TOOL_GUIDANCE["stop_camera_preset_tour"])
723
+ async def stop_camera_preset_tour(camera_ptz_xaddr: str, camera_profile_token: str, camera_ptz_tour_token: str, camera_time_offset: int) -> str:
724
+ return await stop_camera_preset_tour_core(
725
+ camera_ptz_xaddr,
726
+ camera_profile_token,
727
+ camera_ptz_tour_token,
728
+ camera_time_offset,
729
+ )
730
+
731
+ @mcp.tool(description=TOOL_GUIDANCE["pan_tilt_camera"])
732
+ async def pan_tilt_camera(camera_ptz_xaddr: str, camera_profile_token: str, camera_time_offset: int, x: float, y: float) -> str:
733
+ return await pan_tilt_camera_core(
734
+ camera_ptz_xaddr,
735
+ camera_profile_token,
736
+ camera_time_offset,
737
+ x,
738
+ y,
739
+ )
740
+
741
+ @mcp.tool(description=TOOL_GUIDANCE["zoom_camera"])
742
+ async def zoom_camera(camera_ptz_xaddr: str, camera_profile_token: str, camera_time_offset: int, z: float) -> str:
743
+ return await zoom_camera_core(
744
+ camera_ptz_xaddr,
745
+ camera_profile_token,
746
+ camera_time_offset,
747
+ z,
748
+ )
749
+
750
+ @mcp.tool(description=TOOL_GUIDANCE["stop_camera_pan_tilt"])
751
+ async def stop_camera_pan_tilt(camera_ptz_xaddr: str, camera_profile_token: str, camera_time_offset: int) -> str:
752
+ return await stop_camera_pan_tilt_core(
753
+ camera_ptz_xaddr,
754
+ camera_profile_token,
755
+ camera_time_offset,
756
+ )
757
+
758
+ @mcp.tool(description=TOOL_GUIDANCE["stop_camera_zoom"])
759
+ async def stop_camera_zoom(camera_ptz_xaddr: str, camera_profile_token: str, camera_time_offset: int) -> str:
760
+ return await stop_camera_zoom_core(
761
+ camera_ptz_xaddr,
762
+ camera_profile_token,
763
+ camera_time_offset,
764
+ )
765
+
766
+ @mcp.tool(description=TOOL_GUIDANCE["change_camera_hostname"])
767
+ async def change_camera_hostname(ip_address: str, new_hostname: str) -> str:
768
+ return await change_camera_hostname_core(ip_address, new_hostname)
769
+
770
+ @mcp.tool(description=TOOL_GUIDANCE["sync_camera_time"])
771
+ async def sync_camera_time(ip_address: str) -> str:
772
+ return await sync_camera_time_core(ip_address)
773
+
774
+ @mcp.tool(description=TOOL_GUIDANCE["reboot_camera"])
775
+ async def reboot_camera(ip_address: str) -> str:
776
+ return await reboot_camera_core(ip_address)
777
+
778
+ @mcp.tool()
779
+ async def stream_camera(camera_device_information_serial_number: str, camera_media_profile_token: str) -> str:
780
+ """
781
+ Open a camera live stream in the user's default web browser.
782
+
783
+ Args:
784
+ camera_device_information_serial_number: The camera serial number found in the ONVIF data of the camera
785
+ that is stored in the device_information topic group.
786
+
787
+ camera_media_profile_token: The media profile token found the ONVIF data topic profiles. The default choice
788
+ should be the first profile.
789
+
790
+ Returns:
791
+ A message indicating success or failure
792
+ """
793
+ stream_server_url = os.environ.get("STREAM_SERVER_URL")
794
+ if not stream_server_url:
795
+ raise RuntimeError("STREAM_SERVER_URL is not configured")
796
+ stream_server_url = stream_server_url.rstrip("/")
797
+ url = (
798
+ f"{stream_server_url}/"
799
+ f"{camera_device_information_serial_number}/{camera_media_profile_token}/"
800
+ )
801
+ opened = webbrowser.open(url)
802
+ if opened:
803
+ return f"Opened {url} in default browser."
804
+ else:
805
+ return f"Failed to open {url}."
806
+
807
+ @mcp.tool(description=TOOL_GUIDANCE["get_web_player_url"])
808
+ async def get_web_player_url(camera_device_information_serial_number: str, camera_media_profile_token: str) -> str:
809
+ return await get_web_player_url_core(
810
+ camera_device_information_serial_number,
811
+ camera_media_profile_token,
812
+ )
813
+
814
+
815
+ @mcp.tool()
816
+ async def get_snapshot_image_base64_encoded(url: str) -> str:
817
+ """
818
+ Get a snapshot image from a camera as a base64-encoded string.
819
+
820
+ Args:
821
+ url: The full URL to the snapshot, e.g. "https://example.com/snapshot.jpg"
822
+
823
+ Returns:
824
+ The snapshot image as a base64-encoded string.
825
+ """
826
+ if not (url.startswith("http://") or url.startswith("https://")):
827
+ raise ValueError(f"Refused to get snapshot from '{url}': must start with http:// or https://")
828
+
829
+ try:
830
+ credentials = get_camera_credentials()
831
+ response = requests.get(url, auth=HTTPDigestAuth(credentials.username, credentials.password), timeout=5)
832
+ response.raise_for_status()
833
+ return base64.b64encode(response.content).decode('utf-8')
834
+ except Exception as e:
835
+ logger.error(f"Failed to get snapshot from {url}: {e}")
836
+ return None
837
+
838
+ @mcp.tool()
839
+ async def download_snapshot_to_file(url: str, file_path: str) -> str:
840
+ """
841
+ Download a snapshot from a camera to a specified file path.
842
+
843
+ Args:
844
+ url: The full URL to the snapshot, e.g. "https://example.com/snapshot.jpg"
845
+ file_path: The local file path where the snapshot will be saved.
846
+
847
+ Returns:
848
+ A message indicating success or failure.
849
+ """
850
+ if not (url.startswith("http://") or url.startswith("https://")):
851
+ return f"Refused to download '{url}': must start with http:// or https://"
852
+
853
+ try:
854
+ credentials = get_camera_credentials()
855
+ response = requests.get(url, auth=HTTPDigestAuth(credentials.username, credentials.password), timeout=5)
856
+ response.raise_for_status()
857
+ with open(file_path, 'wb') as f:
858
+ f.write(response.content)
859
+ return f"Snapshot downloaded successfully to {file_path}."
860
+ except Exception as e:
861
+ logger.error(f"Failed to download snapshot from {url}: {e}")
862
+ return f"Failed to download snapshot from {url}: {e}"
863
+
864
+ @mcp.tool()
865
+ async def show_snapshot_in_browser(url: str) -> str:
866
+ """
867
+ Open a snapshot URL in the user's default web browser.
868
+
869
+ Args:
870
+ url: The full URL to open, e.g. "https://example.com"
871
+
872
+ Returns:
873
+ A confirmation message.
874
+ """
875
+ if not (url.startswith("http://") or url.startswith("https://")):
876
+ return f"Refused to open '{url}': must start with http:// or https://"
877
+
878
+ credentials = get_camera_credentials()
879
+ curl = f"{url[:7]}{credentials.username}:{credentials.password}@{url[7:]}"
880
+ opened = webbrowser.open(curl)
881
+ if opened:
882
+ return f"Opened {url} in default browser."
883
+ else:
884
+ return f"Failed to open {url}."
885
+
886
+ @mcp.tool()
887
+ async def update_camera_data(json_string: str) -> str:
888
+ """
889
+ Re-query a camera fresh, using the xaddr and credentials currently set
890
+ in the given camera JSON.
891
+
892
+ Use this after editing username or password in the JSON returned by
893
+ get_camera/get_cameras - for example, to try different credentials
894
+ against a camera that failed authorization the first time. The edited
895
+ credentials are what get used for the fresh query, not whatever was
896
+ originally used. Any other edits made elsewhere in the JSON are
897
+ ignored, since this re-runs the full query from scratch rather than
898
+ patching the existing data - the returned camera reflects the device's
899
+ actual current state, not your edits (aside from username/password,
900
+ which control how the query is authorized).
901
+
902
+ Do not edit xaddr. It is the camera's own self-reported device service
903
+ address, discovered without authorization, and functions as the
904
+ camera's network identity rather than a configurable setting. Changing
905
+ it points this tool at a different device entirely rather than
906
+ re-querying the same camera.
907
+
908
+ Args:
909
+ json_string: The JSON string representation of the camera, as
910
+ returned by get_camera or get_cameras, with the
911
+ desired username/password already edited.
912
+
913
+ Returns:
914
+ The freshly queried camera as a JSON string, or an error message
915
+ if the JSON could not be parsed or the query itself failed (e.g.
916
+ the credentials are still not authorized).
917
+ """
918
+ try:
919
+ camera = camera_from_json(json_string)
920
+ except Exception as e:
921
+ logger.error(f"Failed to parse camera JSON: {e}")
922
+ return f"Failed to parse camera JSON: {e}"
923
+
924
+ try:
925
+ refreshed = refresh_camera(camera)
926
+ return refreshed.to_json()
927
+ except Exception as e:
928
+ logger.error(f"Failed to refresh camera at {camera.xaddr}: {e}")
929
+ return f"Failed to refresh camera at {camera.xaddr}: {e}"
930
+
931
+
932
+ async def add_subscribed_event(ip_address: str, event_topic: str) -> str:
933
+ """
934
+ Subscribe a camera to an ONVIF event topic and mark it as observed.
935
+
936
+ Updates this server's own bookkeeping (visible afterward as that
937
+ camera's subscribed_events list in get_cameras) AND performs the
938
+ real ONVIF subscription on the camera itself - adding just this one
939
+ topic, without touching any of the camera's other active
940
+ subscriptions (this mirrors ONVIF's own Subscribe operation, which
941
+ is likewise additive/per-topic). All cameras share one underlying
942
+ event listener - the first call to this tool or
943
+ unsubscribe_all_events, for any camera, starts it; every subsequent
944
+ call (for that camera or any other) reuses it.
945
+
946
+ If the real subscription fails (e.g. the camera is unreachable), the
947
+ bookkeeping change is rolled back rather than left showing a topic
948
+ as subscribed when it isn't.
949
+
950
+ event_topic is not validated against the camera's real topics here -
951
+ it should be one of the strings in that camera's event_topics list
952
+ (from get_cameras), but a typo will be sent to the camera as a
953
+ literal (and likely rejected or silently non-matching) topic filter.
954
+
955
+ Args:
956
+ ip_address: The IP address of the camera.
957
+ event_topic: The event topic string to add, e.g.
958
+ "RuleEngine/CellMotionDetector/Motion" - see that
959
+ camera's event_topics list in get_cameras for the
960
+ full set of valid values.
961
+
962
+ Returns:
963
+ A message indicating the result, including the resulting list.
964
+ """
965
+ events = _subscribed_events_by_camera.setdefault(ip_address, [])
966
+ if event_topic in events:
967
+ return f"{event_topic} is already in the subscribed_events list for camera at {ip_address}. Current list: {events}"
968
+
969
+ events.append(event_topic)
970
+
971
+ try:
972
+ _subscribe_camera_event_topic(ip_address, event_topic)
973
+ return f"Added {event_topic} to the subscribed_events list for camera at {ip_address}, and subscribed on the camera. Current list: {events}"
974
+ except Exception as e:
975
+ events.remove(event_topic)
976
+ logger.error(f"Failed to subscribe camera at {ip_address} to {event_topic}: {e}")
977
+ return f"Failed to subscribe camera at {ip_address} to {event_topic}: {e}"
978
+
979
+ @mcp.tool()
980
+ async def unsubscribe_all_events(ip_address: str) -> str:
981
+ """
982
+ Unsubscribe a camera from ALL of its ONVIF event topics and clear it
983
+ from observation.
984
+
985
+ Updates this server's own bookkeeping (visible afterward as that
986
+ camera's subscribed_events list in get_cameras) AND performs the
987
+ real ONVIF unsubscription on the camera itself. This mirrors ONVIF's
988
+ own Unsubscribe operation directly: it has no way to target a single
989
+ subscription while leaving others active, so it always removes every
990
+ push subscription for the camera at once. To resume observing any
991
+ topics afterward, call add_subscribed_event again for each one.
992
+
993
+ If the real unsubscription fails (e.g. the camera is unreachable),
994
+ the bookkeeping change is rolled back rather than left showing an
995
+ empty subscribed_events list when the camera might still be sending
996
+ events.
997
+
998
+ Args:
999
+ ip_address: The IP address of the camera.
1000
+
1001
+ Returns:
1002
+ A message indicating the result, including the resulting list.
1003
+ """
1004
+ events = _subscribed_events_by_camera.get(ip_address, [])
1005
+ if not events:
1006
+ return f"Camera at {ip_address} has no subscribed events. Current list: {events}"
1007
+
1008
+ previous_events = list(events)
1009
+ events.clear()
1010
+
1011
+ try:
1012
+ _unsubscribe_camera_events(ip_address)
1013
+ return f"Unsubscribed camera at {ip_address} from all events. Current list: {events}"
1014
+ except Exception as e:
1015
+ events.extend(previous_events)
1016
+ logger.error(f"Failed to unsubscribe camera at {ip_address} from all events: {e}")
1017
+ return f"Failed to unsubscribe camera at {ip_address} from all events: {e}"
1018
+
1019
+
1020
+ def main():
1021
+ logger.debug("Server starting...")
1022
+ mcp.run(transport="stdio")
1023
+
1024
+ if __name__ == "__main__":
1025
+ main()
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: onvif-mcp-stdio
3
+ Version: 0.1.8
4
+ Summary: MCP server for ONVIF camera using stdio transport
5
+ Project-URL: Homepage, https://github.com/sr99622/onvif-mcp
6
+ Project-URL: Bug Reports, https://github.com/sr99622/onvif-mcp/issues
7
+ Requires-Python: >=3.10
8
+ License-File: LICENSE
9
+ Requires-Dist: mcp>=1.0.0
10
+ Requires-Dist: onvif-mcp-core
11
+ Requires-Dist: libonvif==4.0.24
12
+ Requires-Dist: niquests==3.20.1
13
+ Dynamic: license-file
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/camera.py
5
+ src/onvif_mcp_stdio.egg-info/PKG-INFO
6
+ src/onvif_mcp_stdio.egg-info/SOURCES.txt
7
+ src/onvif_mcp_stdio.egg-info/dependency_links.txt
8
+ src/onvif_mcp_stdio.egg-info/requires.txt
9
+ src/onvif_mcp_stdio.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ mcp>=1.0.0
2
+ onvif-mcp-core
3
+ libonvif==4.0.24
4
+ niquests==3.20.1