Add batch PLSS queries with optional county validation
- get_plss_details_batch: query multiple coordinates in parallel - Census TIGERweb integration for county lookups (layer 82) - Returns unique_townships for discovering T/R combinations in a grid - County data useful for validating sample points fall within expected area Version bump to 2026.01.25
This commit is contained in:
parent
b96fe3ea68
commit
97bf173fd0
39
README.md
39
README.md
@ -52,10 +52,12 @@ uvx mcblmplss
|
|||||||
claude mcp add blm "uvx mcblmplss"
|
claude mcp add blm "uvx mcblmplss"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools (18 total)
|
## Tools (19 total)
|
||||||
|
|
||||||
Each data type has two tools: a human-readable version and a `_details` version returning structured data.
|
Each data type has two tools: a human-readable version and a `_details` version returning structured data.
|
||||||
|
|
||||||
|
Plus a batch tool for efficient multi-coordinate queries.
|
||||||
|
|
||||||
### PLSS — Public Land Survey System
|
### PLSS — Public Land Survey System
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -66,6 +68,38 @@ State: CO
|
|||||||
PLSS ID: CO060010S0680W0SN090
|
PLSS ID: CO060010S0680W0SN090
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Batch PLSS Queries
|
||||||
|
|
||||||
|
Query multiple coordinates in parallel with optional county validation:
|
||||||
|
|
||||||
|
```
|
||||||
|
> get_plss_details_batch(
|
||||||
|
coordinates=[[44.5, -115.8], [44.6, -115.9], [44.7, -116.0]],
|
||||||
|
include_county=True
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"latitude": 44.5,
|
||||||
|
"longitude": -115.8,
|
||||||
|
"township": {"township": "13N", "range": "5E", ...},
|
||||||
|
"section": {"section": "30", ...},
|
||||||
|
"county": {"name": "Valley County", "fips": "16085", ...}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"total": 3,
|
||||||
|
"successful": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"unique_townships": ["13N 5E", "14N 4E"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `unique_townships` field is useful for discovering which Township/Range combinations exist within a sampling grid.
|
||||||
|
|
||||||
|
**County data** comes from Census TIGERweb (layer 82), not BLM PLSS. Use `include_county=True` when you need to validate that sampled points fall within an expected county.
|
||||||
|
|
||||||
### Land Manager — Surface Management Agency
|
### Land Manager — Surface Management Agency
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -177,11 +211,12 @@ Data is available for **30 states** where the Public Land Survey System was used
|
|||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
All data comes from official BLM ArcGIS REST services:
|
All data comes from official government ArcGIS REST services (BLM + Census TIGERweb):
|
||||||
|
|
||||||
| Data | Source | Typical Update |
|
| Data | Source | Typical Update |
|
||||||
|------|--------|----------------|
|
|------|--------|----------------|
|
||||||
| PLSS | [BLM National PLSS CadNSDI](https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer) | Quarterly |
|
| PLSS | [BLM National PLSS CadNSDI](https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer) | Quarterly |
|
||||||
|
| County | [Census TIGERweb](https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/tigerWMS_Current/MapServer) | Annual |
|
||||||
| Surface Management | [BLM SMA](https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_SMA_LimitedScale/MapServer) | Annual |
|
| Surface Management | [BLM SMA](https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_SMA_LimitedScale/MapServer) | Annual |
|
||||||
| Mining Claims | [BLM MLRS](https://gis.blm.gov/nlsdb/rest/services/Mining_Claims/MiningClaims/MapServer) | Weekly |
|
| Mining Claims | [BLM MLRS](https://gis.blm.gov/nlsdb/rest/services/Mining_Claims/MiningClaims/MapServer) | Weekly |
|
||||||
| Grazing | [BLM Grazing Allotment](https://gis.blm.gov/arcgis/rest/services/range/BLM_Natl_Grazing_Allotment/MapServer) | Annual |
|
| Grazing | [BLM Grazing Allotment](https://gis.blm.gov/arcgis/rest/services/range/BLM_Natl_Grazing_Allotment/MapServer) | Annual |
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcblmplss"
|
name = "mcblmplss"
|
||||||
version = "2025.01.25"
|
version = "2026.01.25"
|
||||||
description = "MCP server for querying BLM land data: PLSS, land manager, mining claims, grazing, wild horses, recreation, wilderness, rivers, and ACECs"
|
description = "MCP server for querying BLM land data: PLSS, land manager, mining claims, grazing, wild horses, recreation, wilderness, rivers, and ACECs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
120
src/mcblmplss/census.py
Normal file
120
src/mcblmplss/census.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
Census Bureau TIGERweb client for county lookups.
|
||||||
|
|
||||||
|
Provides county information via the Census Bureau's TIGERweb ArcGIS service.
|
||||||
|
This complements BLM data which doesn't include county boundaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# Census TIGERweb MapServer
|
||||||
|
CENSUS_URL = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/tigerWMS_Current/MapServer"
|
||||||
|
LAYER_COUNTY = 82
|
||||||
|
|
||||||
|
|
||||||
|
class CountyInfo(BaseModel):
|
||||||
|
"""County information from Census TIGERweb."""
|
||||||
|
|
||||||
|
name: str = Field(..., description="County name (e.g., 'Valley County')")
|
||||||
|
fips: str = Field(..., description="Full FIPS code: state + county (e.g., '16085')")
|
||||||
|
county_fips: str = Field(..., description="County FIPS code only (e.g., '085')")
|
||||||
|
state_fips: str = Field(..., description="State FIPS code (e.g., '16' for Idaho)")
|
||||||
|
|
||||||
|
|
||||||
|
class CensusAPIError(Exception):
|
||||||
|
"""Error communicating with Census TIGERweb services."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_county(latitude: float, longitude: float, timeout: float = 30.0) -> CountyInfo | None:
|
||||||
|
"""
|
||||||
|
Get county information for coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
latitude: WGS84 latitude
|
||||||
|
longitude: WGS84 longitude
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CountyInfo if found, None if no county data available
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CensusAPIError: If the API request fails
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"f": "json",
|
||||||
|
"geometry": f"{longitude},{latitude}",
|
||||||
|
"geometryType": "esriGeometryPoint",
|
||||||
|
"sr": "4326",
|
||||||
|
"layers": f"all:{LAYER_COUNTY}",
|
||||||
|
"tolerance": "1",
|
||||||
|
"mapExtent": f"{longitude - 1},{latitude - 1},{longitude + 1},{latitude + 1}",
|
||||||
|
"imageDisplay": "100,100,96",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.get(f"{CENSUS_URL}/identify", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise CensusAPIError("Census API request timed out.")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise CensusAPIError(f"Census API returned error {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise CensusAPIError(f"Failed to connect to Census API: {e}")
|
||||||
|
|
||||||
|
if "error" in data:
|
||||||
|
error_msg = data["error"].get("message", "Unknown error")
|
||||||
|
raise CensusAPIError(f"Census API error: {error_msg}")
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the county layer result
|
||||||
|
for result in results:
|
||||||
|
if result.get("layerId") == LAYER_COUNTY:
|
||||||
|
attrs = result.get("attributes", {})
|
||||||
|
name = attrs.get("NAME", "")
|
||||||
|
geoid = attrs.get("GEOID", "")
|
||||||
|
county_fips = attrs.get("COUNTY", "")
|
||||||
|
|
||||||
|
if geoid and len(geoid) >= 5:
|
||||||
|
return CountyInfo(
|
||||||
|
name=name,
|
||||||
|
fips=geoid,
|
||||||
|
county_fips=county_fips,
|
||||||
|
state_fips=geoid[:2],
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_counties_batch(
|
||||||
|
coordinates: list[tuple[float, float]],
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> list[CountyInfo | None]:
|
||||||
|
"""
|
||||||
|
Get county information for multiple coordinates in parallel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coordinates: List of (latitude, longitude) tuples
|
||||||
|
timeout: Request timeout per coordinate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CountyInfo or None for each coordinate
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def safe_get_county(lat: float, lon: float) -> CountyInfo | None:
|
||||||
|
try:
|
||||||
|
return await get_county(lat, lon, timeout)
|
||||||
|
except CensusAPIError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tasks = [safe_get_county(lat, lon) for lat, lon in coordinates]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
@ -4,6 +4,7 @@ Shared HTTP client for BLM ArcGIS REST API queries.
|
|||||||
Provides common identify/query operations against BLM MapServer endpoints.
|
Provides common identify/query operations against BLM MapServer endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -143,6 +144,34 @@ class BLMClient:
|
|||||||
features = data.get("features", [])
|
features = data.get("features", [])
|
||||||
return [f.get("attributes", {}) for f in features]
|
return [f.get("attributes", {}) for f in features]
|
||||||
|
|
||||||
|
async def identify_batch(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
coordinates: list[tuple[float, float]],
|
||||||
|
layers: str,
|
||||||
|
tolerance: int = 1,
|
||||||
|
return_geometry: bool = False,
|
||||||
|
) -> list[list[dict[str, Any]] | BLMAPIError]:
|
||||||
|
"""
|
||||||
|
Perform identify operations on multiple coordinates in parallel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: MapServer URL (e.g., https://gis.blm.gov/.../MapServer)
|
||||||
|
coordinates: List of (latitude, longitude) tuples
|
||||||
|
layers: Layer specification (e.g., "all:1,2" or "visible:0")
|
||||||
|
tolerance: Pixel tolerance for hit detection
|
||||||
|
return_geometry: Whether to include geometry in response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of results for each coordinate. Each result is either a list
|
||||||
|
of result dictionaries, or a BLMAPIError if that request failed.
|
||||||
|
"""
|
||||||
|
tasks = [
|
||||||
|
self.identify(base_url, lat, lon, layers, tolerance, return_geometry)
|
||||||
|
for lat, lon in coordinates
|
||||||
|
]
|
||||||
|
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
# Shared client instance
|
# Shared client instance
|
||||||
blm_client = BLMClient(timeout=60.0)
|
blm_client = BLMClient(timeout=60.0)
|
||||||
|
|||||||
@ -4,10 +4,11 @@ MCP Mixins for BLM data services.
|
|||||||
Each mixin provides tools for a specific BLM data domain.
|
Each mixin provides tools for a specific BLM data domain.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from mcblmplss.census import CountyInfo
|
||||||
from mcblmplss.mixins.acec import ACECMixin
|
from mcblmplss.mixins.acec import ACECMixin
|
||||||
from mcblmplss.mixins.grazing import GrazingMixin
|
from mcblmplss.mixins.grazing import GrazingMixin
|
||||||
from mcblmplss.mixins.mining_claims import MiningClaimsMixin
|
from mcblmplss.mixins.mining_claims import MiningClaimsMixin
|
||||||
from mcblmplss.mixins.plss import PLSSMixin
|
from mcblmplss.mixins.plss import BatchPLSSResult, PLSSMixin
|
||||||
from mcblmplss.mixins.recreation import RecreationMixin
|
from mcblmplss.mixins.recreation import RecreationMixin
|
||||||
from mcblmplss.mixins.surface_management import SurfaceManagementMixin
|
from mcblmplss.mixins.surface_management import SurfaceManagementMixin
|
||||||
from mcblmplss.mixins.wild_horses import WildHorseMixin
|
from mcblmplss.mixins.wild_horses import WildHorseMixin
|
||||||
@ -15,6 +16,7 @@ from mcblmplss.mixins.wild_rivers import WildRiversMixin
|
|||||||
from mcblmplss.mixins.wilderness import WildernessMixin
|
from mcblmplss.mixins.wilderness import WildernessMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# Mixins
|
||||||
"ACECMixin",
|
"ACECMixin",
|
||||||
"PLSSMixin",
|
"PLSSMixin",
|
||||||
"SurfaceManagementMixin",
|
"SurfaceManagementMixin",
|
||||||
@ -24,4 +26,7 @@ __all__ = [
|
|||||||
"WildRiversMixin",
|
"WildRiversMixin",
|
||||||
"WildernessMixin",
|
"WildernessMixin",
|
||||||
"RecreationMixin",
|
"RecreationMixin",
|
||||||
|
# Models
|
||||||
|
"BatchPLSSResult",
|
||||||
|
"CountyInfo",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -7,6 +7,7 @@ Provides tools for querying Section, Township, and Range from coordinates.
|
|||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from mcblmplss.census import CountyInfo, get_counties_batch
|
||||||
from mcblmplss.client import BLMAPIError, blm_client
|
from mcblmplss.client import BLMAPIError, blm_client
|
||||||
|
|
||||||
# BLM Cadastral MapServer
|
# BLM Cadastral MapServer
|
||||||
@ -34,9 +35,26 @@ class PLSSResult(BaseModel):
|
|||||||
longitude: float
|
longitude: float
|
||||||
township: PLSSLocation | None = None
|
township: PLSSLocation | None = None
|
||||||
section: PLSSLocation | None = None
|
section: PLSSLocation | None = None
|
||||||
|
county: CountyInfo | None = Field(
|
||||||
|
None, description="County information from Census TIGERweb"
|
||||||
|
)
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchPLSSResult(BaseModel):
|
||||||
|
"""Result from batch PLSS coordinate query."""
|
||||||
|
|
||||||
|
results: list[PLSSResult] = Field(
|
||||||
|
..., description="Individual results for each coordinate"
|
||||||
|
)
|
||||||
|
total: int = Field(..., description="Total number of coordinates queried")
|
||||||
|
successful: int = Field(..., description="Number of successful lookups")
|
||||||
|
failed: int = Field(..., description="Number of failed lookups")
|
||||||
|
unique_townships: list[str] = Field(
|
||||||
|
..., description="Unique Township/Range combinations found (e.g., '13N 5E')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_township(attrs: dict) -> PLSSLocation:
|
def _parse_township(attrs: dict) -> PLSSLocation:
|
||||||
"""Parse township attributes from API response."""
|
"""Parse township attributes from API response."""
|
||||||
twp_num = attrs.get("TWNSHPNO", "").lstrip("0") or "0"
|
twp_num = attrs.get("TWNSHPNO", "").lstrip("0") or "0"
|
||||||
@ -179,3 +197,119 @@ class PLSSMixin(MCPMixin):
|
|||||||
township=township_data,
|
township=township_data,
|
||||||
section=section_data,
|
section=section_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
name="get_plss_details_batch",
|
||||||
|
description=(
|
||||||
|
"Get PLSS details for multiple coordinates in parallel. "
|
||||||
|
"Returns structured data with unique township/range summary."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_plss_details_batch(
|
||||||
|
self,
|
||||||
|
coordinates: list[tuple[float, float]] = Field(
|
||||||
|
description="List of [latitude, longitude] coordinate pairs"
|
||||||
|
),
|
||||||
|
include_county: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Include county lookup from Census TIGERweb (validates location)",
|
||||||
|
),
|
||||||
|
) -> BatchPLSSResult:
|
||||||
|
"""
|
||||||
|
Get PLSS details for multiple coordinates in parallel.
|
||||||
|
|
||||||
|
Optimized for batch queries (e.g., grid sampling a county). Returns
|
||||||
|
results with unique township/range summary for T/R discovery workflows.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coordinates: List of (latitude, longitude) tuples
|
||||||
|
include_county: If True, also query Census TIGERweb for county info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BatchPLSSResult with individual results and unique T/R summary
|
||||||
|
"""
|
||||||
|
# Query PLSS data in parallel
|
||||||
|
batch_results = await blm_client.identify_batch(
|
||||||
|
PLSS_URL,
|
||||||
|
coordinates,
|
||||||
|
f"all:{LAYER_TOWNSHIP},{LAYER_SECTION}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally query county data in parallel
|
||||||
|
counties: list[CountyInfo | None] = []
|
||||||
|
if include_county:
|
||||||
|
counties = await get_counties_batch(coordinates)
|
||||||
|
else:
|
||||||
|
counties = [None] * len(coordinates)
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
results: list[PLSSResult] = []
|
||||||
|
unique_tr: set[str] = set()
|
||||||
|
successful = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, (lat, lon) in enumerate(coordinates):
|
||||||
|
api_result = batch_results[i]
|
||||||
|
county_info = counties[i] if include_county else None
|
||||||
|
|
||||||
|
# Handle API errors
|
||||||
|
if isinstance(api_result, Exception):
|
||||||
|
results.append(
|
||||||
|
PLSSResult(
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
county=county_info,
|
||||||
|
error=str(api_result),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse PLSS data
|
||||||
|
if not api_result:
|
||||||
|
results.append(
|
||||||
|
PLSSResult(
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
county=county_info,
|
||||||
|
error="No PLSS data found. Location may be outside surveyed areas.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
township_data: PLSSLocation | None = None
|
||||||
|
section_data: PLSSLocation | None = None
|
||||||
|
|
||||||
|
for result in api_result:
|
||||||
|
layer_id = result.get("layerId")
|
||||||
|
attrs = result.get("attributes", {})
|
||||||
|
|
||||||
|
if layer_id == LAYER_TOWNSHIP and township_data is None:
|
||||||
|
township_data = _parse_township(attrs)
|
||||||
|
elif layer_id == LAYER_SECTION and section_data is None:
|
||||||
|
section_data = _parse_section(attrs, township_data)
|
||||||
|
|
||||||
|
# Track unique T/R combinations
|
||||||
|
if township_data:
|
||||||
|
tr_key = f"{township_data.township} {township_data.range}"
|
||||||
|
unique_tr.add(tr_key)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
PLSSResult(
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
township=township_data,
|
||||||
|
section=section_data,
|
||||||
|
county=county_info,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
successful += 1
|
||||||
|
|
||||||
|
return BatchPLSSResult(
|
||||||
|
results=results,
|
||||||
|
total=len(coordinates),
|
||||||
|
successful=successful,
|
||||||
|
failed=failed,
|
||||||
|
unique_townships=sorted(unique_tr),
|
||||||
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user