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