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()
|