onvif-mcp-http 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,15 @@
1
+ Metadata-Version: 2.3
2
+ Name: onvif-mcp-http
3
+ Version: 0.1.8
4
+ Summary: ONVIF MCP server using the streamable-http transport
5
+ Requires-Dist: mcp>=1.0.0
6
+ Requires-Dist: onvif-mcp-core
7
+ Requires-Dist: starlette>=0.27.0
8
+ Requires-Dist: uvicorn>=0.23.0
9
+ Requires-Dist: libonvif==4.0.24
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: niquests
12
+ Requires-Dist: pyjwt[crypto]>=2.10.0
13
+ Requires-Python: >=3.10
14
+ Project-URL: Bug Reports, https://github.com/sr99622/onvif-mcp/issues
15
+ Project-URL: Homepage, https://github.com/sr99622/onvif-mcp
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "onvif-mcp-http"
3
+ version = "0.1.8"
4
+ description = "ONVIF MCP server using the streamable-http transport"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "mcp>=1.0.0",
8
+ "onvif-mcp-core",
9
+ "starlette>=0.27.0",
10
+ "uvicorn>=0.23.0",
11
+ "libonvif==4.0.24",
12
+ "pydantic>=2.0.0",
13
+ "niquests",
14
+ "pyjwt[crypto]>=2.10.0",
15
+ ]
16
+
17
+ [tool.uv.sources]
18
+ onvif-mcp-core = { workspace = true }
19
+
20
+ [project.scripts]
21
+ onvif-mcp-http = "onvif_mcp_http.main:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.8.0,<0.9.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [project.urls]
28
+ "Homepage" = "https://github.com/sr99622/onvif-mcp"
29
+ "Bug Reports" = "https://github.com/sr99622/onvif-mcp/issues"
File without changes
@@ -0,0 +1,87 @@
1
+ """OAuth access-token verification for the Camera MCP resource server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import jwt
9
+ from mcp.server.auth.provider import AccessToken
10
+
11
+
12
+ class JWTVerifier:
13
+ """Validate RFC 9068 JWT access tokens against its JWKS."""
14
+
15
+ def __init__(
16
+ self,
17
+ *,
18
+ issuer: str,
19
+ audience: str,
20
+ jwks_url: str,
21
+ ) -> None:
22
+ self.issuer = issuer
23
+ self.audience = audience
24
+ self._jwk_client = jwt.PyJWKClient(
25
+ jwks_url,
26
+ cache_keys=True,
27
+ cache_jwk_set=True,
28
+ lifespan=300,
29
+ timeout=5,
30
+ )
31
+
32
+ async def verify_token(self, token: str) -> AccessToken | None:
33
+ try:
34
+ signing_key = await asyncio.to_thread(
35
+ self._jwk_client.get_signing_key_from_jwt,
36
+ token,
37
+ )
38
+ claims: dict[str, Any] = jwt.decode(
39
+ token,
40
+ signing_key.key,
41
+ algorithms=["RS256"],
42
+ issuer=self.issuer,
43
+ audience=self.audience,
44
+ leeway=30,
45
+ options={
46
+ "require": ["exp", "iat", "iss", "sub"],
47
+ },
48
+ )
49
+ except (jwt.PyJWTError, OSError, ValueError):
50
+ return None
51
+
52
+ client_id = claims.get("client_id") or claims.get("azp")
53
+ subject = claims.get("sub")
54
+ expires_at = claims.get("exp")
55
+
56
+ if not isinstance(client_id, str) or not client_id:
57
+ return None
58
+ if not isinstance(subject, str) or not subject:
59
+ return None
60
+ if not isinstance(expires_at, int):
61
+ return None
62
+
63
+ scopes = self._extract_scopes(claims)
64
+
65
+ return AccessToken(
66
+ token=token,
67
+ client_id=client_id,
68
+ scopes=scopes,
69
+ expires_at=expires_at,
70
+ resource=self.audience,
71
+ subject=subject,
72
+ claims=claims,
73
+ )
74
+
75
+ @staticmethod
76
+ def _extract_scopes(claims: dict[str, Any]) -> list[str]:
77
+ scope = claims.get("scope")
78
+ if isinstance(scope, str):
79
+ return scope.split()
80
+
81
+ scopes = claims.get("scp")
82
+ if isinstance(scopes, list) and all(
83
+ isinstance(value, str) for value in scopes
84
+ ):
85
+ return scopes
86
+
87
+ return []
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ import json
7
+ from datetime import datetime
8
+ import logging
9
+ from pathlib import Path
10
+ import uvicorn
11
+ from importlib.metadata import version as get_installed_version
12
+ from starlette.middleware.cors import CORSMiddleware
13
+ from starlette.requests import Request
14
+ from starlette.responses import StreamingResponse
15
+ from starlette.types import ASGIApp, Receive, Scope, Send
16
+ from pydantic import AnyHttpUrl, BaseModel
17
+ from mcp.server.fastmcp import FastMCP, Context
18
+ from mcp.server.auth.settings import AuthSettings
19
+ from mcp.server.elicitation import AcceptedElicitation, DeclinedElicitation, CancelledElicitation
20
+ from mcp.server.transport_security import TransportSecuritySettings
21
+ from onvif_mcp_http.auth import JWTVerifier
22
+ from onvif_mcp_core.camera_queries import get_adapters as get_adapters_query
23
+ from onvif_mcp_core.guidance import TOOL_GUIDANCE
24
+ from onvif_mcp_core.tools import (
25
+ register_audio_configuration_tools,
26
+ register_camera_query_tools,
27
+ register_device_management_tools,
28
+ register_ptz_tools,
29
+ register_streaming_tools,
30
+ register_video_configuration_tools,
31
+ )
32
+
33
+
34
+ LOG_FILE = Path(__file__).parent / "camera_events.log"
35
+
36
+ logging.basicConfig(
37
+ filename=LOG_FILE,
38
+ level=logging.WARNING,
39
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
40
+ )
41
+ logger = logging.getLogger(__name__)
42
+ logger.setLevel(logging.DEBUG)
43
+
44
+ # --- Event listener integration ---
45
+ # Bridges the standalone motion_watcher.py prototype (packages/sse) into
46
+ # this server, generalized to "event listener" since future work will
47
+ # subscribe to event topics beyond just motion. All cameras share ONE
48
+ # EventServer (ONVIF push events are just an HTTP POST to whatever URL a
49
+ # camera was told during Subscribe - nothing about the protocol requires
50
+ # a separate listener per camera), created on first use by whichever
51
+ # camera adds its first subscribed event. Each camera gets its own
52
+ # SubscriptionManager, since subscriptions (and their resubscribe
53
+ # timers) are inherently per-camera.
54
+
55
+ EVENT_SERVER_PORT = int(os.environ.get("EVENT_SERVER_PORT", "8856"))
56
+ SNAPSHOT_DIR = Path(__file__).parent / "snapshots"
57
+ OPENCLAW_HOOK_URL = os.environ.get("OPENCLAW_HOOK_URL", "http://127.0.0.1:18789/hooks/camera-motion")
58
+ OPENCLAW_HOOK_TOKEN = os.environ.get("OPENCLAW_HOOK_TOKEN", "")
59
+ # Home-relative subdirectory OpenClaw uses as its own workspace folder for
60
+ # camera snapshots/descriptions. Motion-event snapshots are now written
61
+ # directly here by _on_event_listener_event (see CAMERA_EVENTS_DIR below)
62
+ # instead of camera.py's own SNAPSHOT_DIR, so there is exactly one capture
63
+ # per event, taken at alarm time, and OpenClaw's `read` tool loads those
64
+ # same bytes rather than re-querying the camera itself several seconds
65
+ # later once its own reasoning gets around to a download step. OpenClaw's
66
+ # own tools already resolve "~" against this same machine's home
67
+ # directory (confirmed via trajectory review), so using "~" in both the
68
+ # path we write to and the path we tell OpenClaw to read needs no
69
+ # $WORKSPACE_DIR substitution or other coordination.
70
+ OPENCLAW_SNAPSHOT_SUBDIR = "onvif-events"
71
+ CAMERA_EVENTS_DIR = Path(os.path.expanduser(f"~/{OPENCLAW_SNAPSHOT_SUBDIR}"))
72
+
73
+ # The one shared EventServer instance, or None until the first camera
74
+ # adds a subscribed event. Created by _ensure_camera_subscription_entry.
75
+ _event_server = None
76
+
77
+ # Per-camera state, keyed by IP address: {"camera": Camera, "subscription_manager": SubscriptionManager}.
78
+ # Populated lazily, the first time a given camera's subscriptions are
79
+ # touched. The Camera object here is queried once and then reused
80
+ # across resyncs (its subscription_references list is what actually
81
+ # tracks live ONVIF subscriptions) - it is NOT refreshed automatically,
82
+ # so if a camera's IP/credentials/xaddr genuinely change, its entry here
83
+ # would need to be rebuilt (not handled yet - a later concern).
84
+ _camera_subscriptions: dict[str, dict] = {}
85
+
86
+ # In-memory store, keyed by camera IP address, for the set of event
87
+ # topics the user wants that camera marked for observation on. Kept
88
+ # deliberately separate from _event_server/_camera_subscriptions above:
89
+ # those track live ONVIF subscription state (built lazily, in memory
90
+ # only), while this needs to hold user preferences for potentially many
91
+ MCP_OAUTH_ENABLED = os.environ.get("MCP_OAUTH_ENABLED", "").lower() in {
92
+ "1",
93
+ "true",
94
+ "yes",
95
+ }
96
+ MCP_OAUTH_ISSUER = os.environ.get(
97
+ "MCP_OAUTH_ISSUER",
98
+ "https://gmktec.home.arpa/auth/realms/mcp",
99
+ )
100
+ MCP_RESOURCE_URL = os.environ.get(
101
+ "MCP_RESOURCE_URL",
102
+ "https://gmktec.home.arpa/mcp",
103
+ )
104
+ MCP_OAUTH_JWKS_URL = os.environ.get(
105
+ "MCP_OAUTH_JWKS_URL",
106
+ "http://127.0.0.1:8080/auth/realms/mcp/protocol/openid-connect/certs",
107
+ )
108
+ oauth_settings = (
109
+ AuthSettings(
110
+ issuer_url=AnyHttpUrl(MCP_OAUTH_ISSUER),
111
+ resource_server_url=AnyHttpUrl(MCP_RESOURCE_URL),
112
+ required_scopes=["mcp:tools"],
113
+ )
114
+ if MCP_OAUTH_ENABLED
115
+ else None
116
+ )
117
+ oauth_token_verifier = (
118
+ JWTVerifier(
119
+ issuer=MCP_OAUTH_ISSUER,
120
+ audience=MCP_RESOURCE_URL,
121
+ jwks_url=MCP_OAUTH_JWKS_URL,
122
+ )
123
+ if MCP_OAUTH_ENABLED
124
+ else None
125
+ )
126
+
127
+ mcp = FastMCP(
128
+ "camera-mcp",
129
+ auth=oauth_settings,
130
+ token_verifier=oauth_token_verifier,
131
+ transport_security=TransportSecuritySettings(
132
+ enable_dns_rebinding_protection=True,
133
+ allowed_hosts=[
134
+ "127.0.0.1:*",
135
+ "localhost:*",
136
+ "[::1]:*",
137
+ "10.1.1.2:*",
138
+ "10.1.1.3:*",
139
+ "10.1.1.5:*",
140
+ "10.1.1.6:*",
141
+ "gmktec.home.arpa",
142
+ "flexi.home.arpa",
143
+ "nuc.home.arpa",
144
+ ],
145
+ allowed_origins=[
146
+ "http://127.0.0.1:*",
147
+ "http://localhost:*",
148
+ "http://[::1]:*",
149
+ "http://10.1.1.2:*",
150
+ "http://10.1.1.3:*",
151
+ "http://10.1.1.5:*",
152
+ "http://10.1.1.6.*",
153
+ "https://gmktec.home.arpa",
154
+ "http://flexi.home.arpa:8080",
155
+ "http://nuc.home.arpa",
156
+ ],
157
+ ),
158
+ )
159
+ register_video_configuration_tools(mcp)
160
+ register_audio_configuration_tools(mcp)
161
+ register_ptz_tools(mcp)
162
+ register_device_management_tools(mcp)
163
+ register_camera_query_tools(mcp)
164
+ register_streaming_tools(mcp)
165
+
166
+ @mcp.tool(description=TOOL_GUIDANCE["get_adapters"])
167
+ async def get_adapters() -> str:
168
+ """Return a list of available active network adapters.
169
+
170
+ Returns:
171
+ A delimited string containing the IP address of each active adapter,
172
+ one per line, separated by "\n--\n".
173
+ """
174
+ return await get_adapters_query()
175
+
176
+ class TripTypeResponse(BaseModel):
177
+ value: str
178
+
179
+ @mcp.tool()
180
+ async def example_elicit_tool(context: Context) -> str:
181
+ """
182
+ Example tool that asks the user a question via MCP elicitation, to
183
+ test whether a given client (e.g. llama.cpp's web UI) implements the
184
+ client side of the elicitation flow - Claude Desktop returned
185
+ "Method not found" when this was tried there.
186
+ """
187
+ result = await context.elicit(
188
+ message="What type of trip are you planning? Options: business, leisure, family, adventure",
189
+ schema=TripTypeResponse,
190
+ )
191
+ if isinstance(result, AcceptedElicitation):
192
+ return result.data.value
193
+ elif isinstance(result, DeclinedElicitation):
194
+ return "DECLINED"
195
+ elif isinstance(result, CancelledElicitation):
196
+ return "CANCELLED"
197
+ return "INVALID RESPONSE"
198
+
199
+ @mcp.tool()
200
+ async def get_camera_mcp_version() -> str:
201
+ """
202
+ Get the version of the camera application, along with the version of the
203
+ installed libonvif package it depends on.
204
+
205
+ Returns:
206
+ A JSON string with two fields:
207
+ camera_mcp_version: version derived from the pyproject.toml file.
208
+ libonvif_version: version of the installed libonvif package,
209
+ read via importlib.metadata.
210
+ """
211
+
212
+ camera_mcp_version = None
213
+ current_file = Path(__file__)
214
+ filename = Path(current_file.parent.parent.parent) / "pyproject.toml"
215
+ with open(filename, "r") as f:
216
+ for line in f:
217
+ if line.startswith("version"):
218
+ camera_mcp_version = line.split("=")[1].strip().strip('"')
219
+ logger.debug(f"Found camera_mcp version: {camera_mcp_version}")
220
+ break
221
+
222
+ try:
223
+ libonvif_version = get_installed_version("libonvif")
224
+ except Exception as e:
225
+ logger.error(f"Failed to get libonvif version: {e}")
226
+ libonvif_version = None
227
+
228
+ return json.dumps({
229
+ "camera_mcp_version": camera_mcp_version,
230
+ "libonvif_version": libonvif_version,
231
+ }, indent=4)
232
+
233
+
234
+
235
+
236
+ class PrivateNetworkAccessMiddleware:
237
+ """
238
+ Adds the Access-Control-Allow-Private-Network header some Chromium
239
+ browsers require (in addition to normal CORS) before allowing a page
240
+ served from a non-loopback origin to fetch a loopback address like
241
+ 127.0.0.1. Without this, the browser can reject the request before
242
+ it ever reaches this server, showing up client-side as a generic
243
+ "Failed to fetch" with no server-side log at all.
244
+ """
245
+
246
+ def __init__(self, app: ASGIApp) -> None:
247
+ self.app = app
248
+
249
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
250
+ if scope["type"] != "http":
251
+ await self.app(scope, receive, send)
252
+ return
253
+
254
+ async def send_wrapper(message):
255
+ if message["type"] == "http.response.start":
256
+ headers = message.setdefault("headers", [])
257
+ headers.append((b"access-control-allow-private-network", b"true"))
258
+ await send(message)
259
+
260
+ await self.app(scope, receive, send_wrapper)
261
+
262
+
263
+ async def event_stream(request: Request) -> StreamingResponse:
264
+ """
265
+ ** PLEASE DO NOT USE THIS TOOL IT IS FOR REFERENCE ONLY **
266
+
267
+ Plain Server-Sent Events endpoint, independent of the MCP protocol -
268
+ just a raw text/event-stream that emits one tick every 5 seconds.
269
+ Built to test/observe the SSE mechanism itself directly (e.g. via
270
+ curl -N http://127.0.0.1:8000/events, or a browser EventSource),
271
+ separate from anything MCP-specific like tool calls or sessions.
272
+ """
273
+
274
+ async def generator():
275
+ count = 0
276
+ try:
277
+ while True:
278
+ await asyncio.sleep(5)
279
+ count += 1
280
+ yield f"data: tick {count} at {datetime.now().isoformat()}\n\n"
281
+ except asyncio.CancelledError:
282
+ pass
283
+
284
+ return StreamingResponse(generator(), media_type="text/event-stream")
285
+
286
+
287
+ def main():
288
+ app = mcp.streamable_http_app()
289
+ app.add_route("/events", event_stream, methods=["GET"])
290
+ app.add_middleware(PrivateNetworkAccessMiddleware)
291
+ app.add_middleware(
292
+ CORSMiddleware,
293
+ allow_origins=["*"],
294
+ allow_methods=["*"],
295
+ allow_headers=["*"],
296
+ # The streamable-http transport returns a session ID in a custom
297
+ # response header on the initialize call, and expects it echoed
298
+ # back on every subsequent request. Browsers hide custom response
299
+ # headers from JS by default unless the server explicitly exposes
300
+ # them via CORS - without this, the client never sees the session
301
+ # ID and every follow-up request gets rejected as missing one.
302
+ expose_headers=["mcp-session-id"],
303
+ )
304
+ # Bind only to loopback; Nginx provides HTTPS and authentication.
305
+ # Internal endpoint: http://127.0.0.1:8001/mcp
306
+ host = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
307
+ port = int(os.environ.get("MCP_HTTP_PORT", "8001"))
308
+ uvicorn.run(app, host=host, port=port)
309
+
310
+ if __name__ == "__main__":
311
+ main()