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.
- onvif_mcp_stdio-0.1.8/LICENSE +21 -0
- onvif_mcp_stdio-0.1.8/PKG-INFO +13 -0
- onvif_mcp_stdio-0.1.8/README.md +155 -0
- onvif_mcp_stdio-0.1.8/pyproject.toml +19 -0
- onvif_mcp_stdio-0.1.8/setup.cfg +4 -0
- onvif_mcp_stdio-0.1.8/src/camera.py +1025 -0
- onvif_mcp_stdio-0.1.8/src/onvif_mcp_stdio.egg-info/PKG-INFO +13 -0
- onvif_mcp_stdio-0.1.8/src/onvif_mcp_stdio.egg-info/SOURCES.txt +9 -0
- onvif_mcp_stdio-0.1.8/src/onvif_mcp_stdio.egg-info/dependency_links.txt +1 -0
- onvif_mcp_stdio-0.1.8/src/onvif_mcp_stdio.egg-info/requires.txt +4 -0
- onvif_mcp_stdio-0.1.8/src/onvif_mcp_stdio.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
camera
|