earthdiff 0.2.0__py3-none-any.whl
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.
- earthdiff/__init__.py +28 -0
- earthdiff/__main__.py +3 -0
- earthdiff/aoi.py +161 -0
- earthdiff/cause.py +144 -0
- earthdiff/change.py +337 -0
- earthdiff/cli.py +202 -0
- earthdiff/composite.py +169 -0
- earthdiff/geocode.py +186 -0
- earthdiff/history.py +86 -0
- earthdiff/i18n.py +201 -0
- earthdiff/indices.py +52 -0
- earthdiff/mcp_prompts.py +54 -0
- earthdiff/mcp_server.py +338 -0
- earthdiff/mcp_service.py +887 -0
- earthdiff/periods.py +87 -0
- earthdiff/pipeline.py +476 -0
- earthdiff/reader.py +59 -0
- earthdiff/render.py +146 -0
- earthdiff/report.py +113 -0
- earthdiff/safety.py +93 -0
- earthdiff/seasonal.py +155 -0
- earthdiff/stac.py +163 -0
- earthdiff/static/ui.html +239 -0
- earthdiff/templates/report.html +850 -0
- earthdiff/terrain.py +120 -0
- earthdiff/ui.py +227 -0
- earthdiff/vendor/LEAFLET-LICENSE.txt +26 -0
- earthdiff/vendor/leaflet.css +661 -0
- earthdiff/vendor/leaflet.js +6 -0
- earthdiff/watch.py +138 -0
- earthdiff/xlsx.py +183 -0
- earthdiff/yearly.py +109 -0
- earthdiff-0.2.0.dist-info/METADATA +368 -0
- earthdiff-0.2.0.dist-info/RECORD +37 -0
- earthdiff-0.2.0.dist-info/WHEEL +4 -0
- earthdiff-0.2.0.dist-info/entry_points.txt +3 -0
- earthdiff-0.2.0.dist-info/licenses/LICENSE +21 -0
earthdiff/mcp_server.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""MCP server (stdio): ``earthdiff-mcp``. Tools: find_place, plan_change, detect_change, explain_method."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Annotated, Any, Literal
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import anyio
|
|
17
|
+
from pydantic import Field
|
|
18
|
+
|
|
19
|
+
try: # mcp >= 2.0
|
|
20
|
+
from mcp.server.mcpserver import Context, Image, MCPServer
|
|
21
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
22
|
+
except ImportError: # mcp 1.x
|
|
23
|
+
from mcp.server.fastmcp import Context, Image # type: ignore[assignment]
|
|
24
|
+
from mcp.server.fastmcp import FastMCP as MCPServer # type: ignore[assignment]
|
|
25
|
+
from mcp.server.fastmcp.exceptions import ToolError # type: ignore[assignment]
|
|
26
|
+
from mcp.types import ToolAnnotations
|
|
27
|
+
except ImportError as _exc: # pragma: no cover
|
|
28
|
+
raise SystemExit('earthdiff-mcp needs the "mcp" extra: pip install "earthdiff[mcp]" (or: uvx --from "earthdiff[mcp]" earthdiff-mcp)') from _exc
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
from .mcp_prompts import GUIDE, INSTRUCTIONS, what_changed
|
|
32
|
+
from .mcp_service import HARD_MAX_AREA_KM2, EarthDiffService, RunStopped, Settings, ToolInputError
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("earthdiff.mcp")
|
|
35
|
+
|
|
36
|
+
Bbox = Annotated[
|
|
37
|
+
list[float] | str | None,
|
|
38
|
+
Field(description="Rectangle [west, south, east, north] = [lon_min, lat_min, lon_max, lat_max] in WGS84 degrees (longitude FIRST). Alternative to geojson."),
|
|
39
|
+
]
|
|
40
|
+
GeoJson = Annotated[
|
|
41
|
+
dict[str, Any] | str | None,
|
|
42
|
+
Field(description="Polygon/MultiPolygon (or Feature/FeatureCollection) in WGS84 for a non-rectangular area. Alternative to bbox."),
|
|
43
|
+
]
|
|
44
|
+
After = Annotated[str, Field(description="The recent window: 'YYYY-MM' (whole month), 'YYYY-MM-DD', 'YYYY-MM/YYYY-MM' or 'YYYY-MM-DD/YYYY-MM-DD'. At most 366 days; 1 to 3 months works best.")]
|
|
45
|
+
Before = Annotated[str | None, Field(description="The baseline window, same formats, ending before 'after' starts. Empty = the 'after' window one year earlier (same season, recommended).")]
|
|
46
|
+
MaxCloud = Annotated[float, Field(description="Ignore scenes whose scene-level cloud cover is above this percent (10 to 100).", ge=10, le=100)]
|
|
47
|
+
Resolution = Annotated[int | None, Field(description="Analysis pixel size in metres: 10, 20, 30 or 60. Empty = automatic (finest that keeps the grid under 4 million pixels). Coarser is faster.")]
|
|
48
|
+
Backend = Literal["earth-search", "planetary-computer"]
|
|
49
|
+
Sensitivity = Literal["strict", "default", "sensitive"]
|
|
50
|
+
Topic = Literal["all", "rules", "pipeline", "likely_cause", "seasonal", "bright_surfaces", "confidence", "limits", "data"]
|
|
51
|
+
SeasonalCheck = Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
Field(description="Also check whether the vegetation loss repeats in the same windows of the 2 earlier years (flags likely_seasonal, e.g. harvest; polygons are kept). Adds roughly 45 s or more (measured +47 s on a 39 km2 test); default false. plan_change reports the extra windows."),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
_READ = dict(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _dump(obj: Any) -> str:
|
|
60
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _open_in_viewer(path: str | None) -> bool:
|
|
64
|
+
"""Open report.html in the default browser (``--open-report``). Never raises."""
|
|
65
|
+
if not path:
|
|
66
|
+
return False
|
|
67
|
+
try:
|
|
68
|
+
if sys.platform.startswith("win"):
|
|
69
|
+
os.startfile(path) # type: ignore[attr-defined]
|
|
70
|
+
else:
|
|
71
|
+
subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
72
|
+
return True
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
log.warning("could not open the report in a viewer")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _StrictServer(MCPServer): # type: ignore[misc, valid-type]
|
|
79
|
+
"""Rejects unknown tool arguments instead of silently dropping them (a model that invents ``crs=`` or ``threshold=`` must
|
|
80
|
+
get an error, not an analysis that ignored its wish)."""
|
|
81
|
+
|
|
82
|
+
async def call_tool(self, name: str, arguments: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
|
83
|
+
tool = self._tool_manager.get_tool(name)
|
|
84
|
+
if tool is not None and arguments:
|
|
85
|
+
allowed = set((tool.parameters or {}).get("properties", {}))
|
|
86
|
+
unknown = sorted(set(arguments) - allowed)
|
|
87
|
+
if unknown:
|
|
88
|
+
raise ToolError(f"Unknown parameter(s) for {name}: {', '.join(unknown)}. Allowed: {', '.join(sorted(allowed))}.")
|
|
89
|
+
return await super().call_tool(name, arguments, *args, **kwargs)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Progress:
|
|
93
|
+
"""Thread-safe, strictly increasing progress notifications from the worker thread and a heartbeat."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, ctx: Any):
|
|
96
|
+
self.ctx = ctx
|
|
97
|
+
self.last = 0.0
|
|
98
|
+
self.text = "starting"
|
|
99
|
+
self.started = time.monotonic()
|
|
100
|
+
self._lock = threading.Lock()
|
|
101
|
+
|
|
102
|
+
def next_value(self, value: float) -> float:
|
|
103
|
+
with self._lock:
|
|
104
|
+
self.last = max(value, self.last + 0.01)
|
|
105
|
+
return self.last
|
|
106
|
+
|
|
107
|
+
async def send(self, value: float, text: str, elapsed: bool = False) -> None:
|
|
108
|
+
self.text = text
|
|
109
|
+
v = self.next_value(value)
|
|
110
|
+
msg = f"{text} ({time.monotonic() - self.started:.0f} s)" if elapsed else text
|
|
111
|
+
try:
|
|
112
|
+
await self.ctx.report_progress(v, 100, msg)
|
|
113
|
+
except Exception: # noqa: BLE001 (client without progress support, closed stream ...)
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
def from_thread(self, value: float, text: str) -> None:
|
|
117
|
+
try:
|
|
118
|
+
anyio.from_thread.run(self.send, value, text)
|
|
119
|
+
except Exception: # noqa: BLE001
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_server(settings: Settings, service: EarthDiffService | None = None) -> MCPServer:
|
|
124
|
+
svc = service or EarthDiffService(settings)
|
|
125
|
+
try: # mcp 2.x reports the server version in `initialize`; mcp 1.x FastMCP has no `version` argument
|
|
126
|
+
mcp = _StrictServer("earthdiff", instructions=INSTRUCTIONS, version=__version__)
|
|
127
|
+
except TypeError: # pragma: no cover
|
|
128
|
+
mcp = _StrictServer("earthdiff", instructions=INSTRUCTIONS)
|
|
129
|
+
try:
|
|
130
|
+
mcp._earthdiff_service = svc # type: ignore[attr-defined]
|
|
131
|
+
except Exception: # pragma: no cover
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
async def call(name: str, fn, *args: Any, **kwargs: Any) -> Any:
|
|
135
|
+
"""Run a blocking service call in a worker thread; every failure becomes a path-free ToolError."""
|
|
136
|
+
t0 = time.perf_counter()
|
|
137
|
+
try:
|
|
138
|
+
result = await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs), abandon_on_cancel=True)
|
|
139
|
+
except (ToolInputError, RunStopped) as e:
|
|
140
|
+
raise ToolError(svc._redact(e)) from None
|
|
141
|
+
except ToolError:
|
|
142
|
+
raise
|
|
143
|
+
except Exception as e: # noqa: BLE001
|
|
144
|
+
log.exception("tool %s failed", name)
|
|
145
|
+
raise ToolError(f"Internal error in {name}: {type(e).__name__}. Check the server log (stderr).") from None
|
|
146
|
+
log.info("tool %s ok in %.0f ms", name, (time.perf_counter() - t0) * 1000)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------ tools
|
|
150
|
+
@mcp.tool(
|
|
151
|
+
description="Find a place by name (OpenStreetMap Nominatim) and get a bounding box to analyse. If several different places match, "
|
|
152
|
+
"status is 'ambiguous' and you must ASK the user which one; never guess. If the box is larger than the analysis limit, "
|
|
153
|
+
"narrowing_options (a centred box and tiles) are returned for the user to choose from. Rate limited to 1 request per second and cached. "
|
|
154
|
+
"Use margin_km to include the surroundings of a small feature (a lake, a town).",
|
|
155
|
+
annotations=ToolAnnotations(title="Find a place", **_READ),
|
|
156
|
+
structured_output=False,
|
|
157
|
+
)
|
|
158
|
+
async def find_place(
|
|
159
|
+
query: Annotated[str, Field(description="Place name, ideally with country or region, e.g. 'Lake Mead, Nevada' or 'Manavgat, Antalya'.", min_length=2, max_length=200)],
|
|
160
|
+
country_codes: Annotated[str | None, Field(description="Restrict to countries: comma-separated ISO 3166-1 alpha-2 codes, e.g. 'tr' or 'us,ca'.")] = None,
|
|
161
|
+
language: Annotated[str | None, Field(description="Language of the returned names, e.g. 'en' or 'tr'. Empty = local names.")] = None,
|
|
162
|
+
limit: Annotated[int, Field(description="Maximum number of candidates (1 to 5).", ge=1, le=5)] = 5,
|
|
163
|
+
margin_km: Annotated[float, Field(description="Expand each bounding box by this many km on every side (0 to 20), for example to include the shores around a lake.", ge=0, le=20)] = 0.0,
|
|
164
|
+
) -> str:
|
|
165
|
+
res = await call("find_place", svc.find_place, query, country_codes, language, limit, margin_km)
|
|
166
|
+
return _dump(res)
|
|
167
|
+
|
|
168
|
+
@mcp.tool(
|
|
169
|
+
description="Dry run for detect_change: validates the area and windows and searches Sentinel-2 scenes WITHOUT downloading imagery. "
|
|
170
|
+
"Returns scene counts and dates per window, grid size, expected runtime and warnings (season mismatch, no or few scenes, clouds, "
|
|
171
|
+
"very long or short windows). Call it for large areas, unusual windows, or when unsure. Takes a few seconds.",
|
|
172
|
+
annotations=ToolAnnotations(title="Plan a change analysis", **_READ),
|
|
173
|
+
structured_output=False,
|
|
174
|
+
)
|
|
175
|
+
async def plan_change(
|
|
176
|
+
after: After,
|
|
177
|
+
bbox: Bbox = None,
|
|
178
|
+
geojson: GeoJson = None,
|
|
179
|
+
before: Before = None,
|
|
180
|
+
max_cloud: MaxCloud = 80.0,
|
|
181
|
+
resolution_m: Resolution = None,
|
|
182
|
+
backend: Annotated[Backend, Field(description="Free public STAC API. 'earth-search' (default) or 'planetary-computer' (needs the optional planetary-computer package).")] = "earth-search",
|
|
183
|
+
seasonal_check: SeasonalCheck = False,
|
|
184
|
+
) -> str:
|
|
185
|
+
res = await call("plan_change", svc.plan_change, bbox, geojson, after, before, max_cloud, resolution_m, backend, seasonal_check)
|
|
186
|
+
return _dump(res)
|
|
187
|
+
|
|
188
|
+
@mcp.tool(
|
|
189
|
+
description="Answer 'what changed here?': compares median composites of free Sentinel-2 imagery of two windows and returns hectares and polygon "
|
|
190
|
+
"counts per class (vegetation loss/gain, new water, water loss, new bare soil / built-up), a likely_cause HINT for vegetation loss (fire-like, clearing-like, unclear; from dNBR, brightness and shape), "
|
|
191
|
+
"the comparable share of the area, confidence (lowered on very bright surfaces such as salt, sand, snow), data quality, warnings, the largest changed areas, and THREE IMAGES (before, after, detected changes). Also writes report.html (interactive swipe map), "
|
|
192
|
+
"changes.geojson and more to the local output folder and returns their paths. Takes about 1 to 4 minutes and reports progress; one analysis at a time. "
|
|
193
|
+
"Report ALL warnings to the user. It describes spectral change; likely_cause is only a hint, never a verdict; it is a screening tool, not an official assessment.",
|
|
194
|
+
annotations=ToolAnnotations(title="Detect change", readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True),
|
|
195
|
+
structured_output=False,
|
|
196
|
+
)
|
|
197
|
+
async def detect_change(
|
|
198
|
+
ctx: Context,
|
|
199
|
+
after: After,
|
|
200
|
+
bbox: Bbox = None,
|
|
201
|
+
geojson: GeoJson = None,
|
|
202
|
+
before: Before = None,
|
|
203
|
+
name: Annotated[str | None, Field(description="Title of the analysis (used for the report and the output folder name; sanitised).", max_length=80)] = None,
|
|
204
|
+
sensitivity: Annotated[Sensitivity, Field(description="'strict' fewer, stronger changes; 'default'; 'sensitive' more, weaker changes (more false alarms).")] = "default",
|
|
205
|
+
resolution_m: Resolution = None,
|
|
206
|
+
mmu_ha: Annotated[float, Field(description="Minimum mapping unit in hectares: smaller changes are dropped (0.1 to 50).", ge=0.1, le=50)] = 0.5,
|
|
207
|
+
max_cloud: MaxCloud = 80.0,
|
|
208
|
+
backend: Annotated[Backend, Field(description="Free public STAC API. 'earth-search' (default) or 'planetary-computer'.")] = "earth-search",
|
|
209
|
+
seasonal_check: SeasonalCheck = False,
|
|
210
|
+
) -> list:
|
|
211
|
+
prog = _Progress(ctx)
|
|
212
|
+
stop = threading.Event()
|
|
213
|
+
state: dict[str, Any] = {}
|
|
214
|
+
|
|
215
|
+
def work() -> Any:
|
|
216
|
+
return svc.detect_change(
|
|
217
|
+
bbox=bbox, geojson=geojson, after=after, before=before, name=name, sensitivity=sensitivity,
|
|
218
|
+
resolution_m=resolution_m, mmu_ha=mmu_ha, max_cloud=max_cloud, backend=backend, seasonal_check=seasonal_check,
|
|
219
|
+
progress=prog.from_thread, should_stop=stop.is_set,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def heartbeat() -> None:
|
|
223
|
+
while True:
|
|
224
|
+
await anyio.sleep(8)
|
|
225
|
+
await prog.send(prog.last, prog.text, elapsed=True)
|
|
226
|
+
|
|
227
|
+
deadline = settings.max_runtime_s + 90
|
|
228
|
+
err: Exception | None = None
|
|
229
|
+
finished = False
|
|
230
|
+
try:
|
|
231
|
+
with anyio.move_on_after(deadline) as scope:
|
|
232
|
+
async with anyio.create_task_group() as tg:
|
|
233
|
+
tg.start_soon(heartbeat)
|
|
234
|
+
try:
|
|
235
|
+
state["outcome"] = await call("detect_change", work)
|
|
236
|
+
except Exception as e: # noqa: BLE001 (ToolError): re-raised below, outside the task group
|
|
237
|
+
err = e
|
|
238
|
+
tg.cancel_scope.cancel()
|
|
239
|
+
finished = True
|
|
240
|
+
finally:
|
|
241
|
+
if not finished:
|
|
242
|
+
stop.set() # client cancelled: the worker stops at its next progress line
|
|
243
|
+
if scope.cancelled_caught:
|
|
244
|
+
stop.set()
|
|
245
|
+
raise ToolError(
|
|
246
|
+
f"The analysis did not finish within {settings.max_runtime_s} s and was stopped. Try a smaller area, a coarser resolution_m or shorter windows."
|
|
247
|
+
)
|
|
248
|
+
if err is not None:
|
|
249
|
+
raise err
|
|
250
|
+
outcome = state["outcome"]
|
|
251
|
+
await prog.send(100, "done")
|
|
252
|
+
result = outcome.result
|
|
253
|
+
if settings.open_report:
|
|
254
|
+
result["report_opened"] = _open_in_viewer(str(outcome.report_path))
|
|
255
|
+
content: list[Any] = [Image(data=data, format="jpeg") for _, data in outcome.images]
|
|
256
|
+
content.append(_dump(result))
|
|
257
|
+
return content
|
|
258
|
+
|
|
259
|
+
@mcp.tool(
|
|
260
|
+
description="Explain how earthdiff works and how far to trust it: the exact rules per change class with thresholds, the processing pipeline, how confidence is "
|
|
261
|
+
"computed, the known limits and error sources, data sources and attribution, and this server's limits. Use it when the user asks 'how does it know?' or 'can I rely on this?'.",
|
|
262
|
+
annotations=ToolAnnotations(title="Explain the method", readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False),
|
|
263
|
+
structured_output=False,
|
|
264
|
+
)
|
|
265
|
+
async def explain_method(
|
|
266
|
+
topic: Annotated[Topic, Field(description="'all' (default), 'rules', 'pipeline', 'confidence', 'limits' or 'data'.")] = "all",
|
|
267
|
+
) -> str:
|
|
268
|
+
res = await call("explain_method", svc.explain_method, topic)
|
|
269
|
+
return _dump(res)
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------ prompts & resources
|
|
272
|
+
@mcp.prompt(name="what_changed", description="Answer 'what changed around <place> in <period>?' with the earthdiff tools: asks when ambiguous, reports every warning.")
|
|
273
|
+
def what_changed_prompt(place_or_area: str, period: str = "") -> str:
|
|
274
|
+
return what_changed(place_or_area, period)
|
|
275
|
+
|
|
276
|
+
@mcp.resource("earthdiff://guide", name="guide", description="How to use earthdiff effectively and honestly.", mime_type="text/markdown")
|
|
277
|
+
def guide_resource() -> str:
|
|
278
|
+
return GUIDE
|
|
279
|
+
|
|
280
|
+
return mcp
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------------------------------- CLI
|
|
284
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
285
|
+
p = argparse.ArgumentParser(
|
|
286
|
+
prog="earthdiff-mcp",
|
|
287
|
+
description="MCP server (stdio) that lets AI assistants answer 'what changed here?' with free Sentinel-2 imagery. No account, no API key. "
|
|
288
|
+
"Settings can also be given as environment variables: EARTHDIFF_OUTPUT_DIR, EARTHDIFF_MAX_AREA_KM2, EARTHDIFF_MAX_RUNTIME_S, "
|
|
289
|
+
"EARTHDIFF_CONTACT (an e-mail or URL added to the Nominatim User-Agent), EARTHDIFF_OPEN_REPORT.",
|
|
290
|
+
)
|
|
291
|
+
p.add_argument("--version", action="version", version=f"earthdiff-mcp {__version__}")
|
|
292
|
+
p.add_argument("--output-dir", help="Where reports and images are written (env EARTHDIFF_OUTPUT_DIR, default ~/earthdiff-mcp-output).")
|
|
293
|
+
p.add_argument("--max-area-km2", type=float, help="Largest area one analysis may cover (env EARTHDIFF_MAX_AREA_KM2; default and maximum 2000, the same limit as the CLI and the web UI).")
|
|
294
|
+
p.add_argument("--max-runtime-s", type=int, help="Time limit of one analysis in seconds (env EARTHDIFF_MAX_RUNTIME_S, default 480).")
|
|
295
|
+
p.add_argument("--open-report", action="store_true", help="Open report.html in the default browser after each analysis, for clients that do not show tool images (env EARTHDIFF_OPEN_REPORT=1).")
|
|
296
|
+
p.add_argument("--check", action="store_true", help="Print the effective settings and dependency status as JSON, then exit.")
|
|
297
|
+
return p
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def settings_from_args(args: argparse.Namespace) -> Settings:
|
|
301
|
+
import dataclasses
|
|
302
|
+
from pathlib import Path
|
|
303
|
+
|
|
304
|
+
s = Settings.from_env()
|
|
305
|
+
upd: dict[str, Any] = {}
|
|
306
|
+
if args.output_dir:
|
|
307
|
+
upd["output_dir"] = Path(args.output_dir)
|
|
308
|
+
if args.max_area_km2:
|
|
309
|
+
upd["max_area_km2"] = min(max(args.max_area_km2, 1.0), HARD_MAX_AREA_KM2)
|
|
310
|
+
if args.max_runtime_s:
|
|
311
|
+
upd["max_runtime_s"] = min(max(args.max_runtime_s, 60), 1800)
|
|
312
|
+
if args.open_report:
|
|
313
|
+
upd["open_report"] = True
|
|
314
|
+
return dataclasses.replace(s, **upd)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main(argv: list[str] | None = None) -> None:
|
|
318
|
+
args = build_parser().parse_args(argv)
|
|
319
|
+
# stdout belongs to the MCP protocol on stdio: log to stderr only
|
|
320
|
+
logging.basicConfig(level=os.environ.get("EARTHDIFF_LOG_LEVEL", "WARNING").upper(), stream=sys.stderr, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
321
|
+
settings = settings_from_args(args)
|
|
322
|
+
if args.check:
|
|
323
|
+
import rasterio
|
|
324
|
+
|
|
325
|
+
print(json.dumps({
|
|
326
|
+
"earthdiff_mcp": __version__, "output_dir": str(settings.output_dir), "max_area_km2": settings.max_area_km2,
|
|
327
|
+
"max_runtime_s": settings.max_runtime_s, "rasterio": rasterio.__version__, "transport": "stdio",
|
|
328
|
+
}, indent=2))
|
|
329
|
+
return
|
|
330
|
+
server = create_server(settings)
|
|
331
|
+
try:
|
|
332
|
+
server.run("stdio")
|
|
333
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__": # pragma: no cover
|
|
338
|
+
main()
|