Add 6 new BLM data mixins: grazing, wild horses, recreation, wilderness, rivers, ACECs
- Grazing: allotment names, acreage, admin units - Wild Horses: HMA populations, AML targets, inventory dates - Recreation: campgrounds, trailheads, facilities from RIDB - Wilderness: designated areas and WSAs with designation dates - Wild Rivers: Wild & Scenic River segments with classification - ACECs: Areas of Critical Environmental Concern with protected values Total: 18 tools across 9 BLM data domains
This commit is contained in:
parent
d8d160efdc
commit
b96fe3ea68
163
README.md
163
README.md
@ -6,20 +6,33 @@
|
||||
|
||||
**MCP server for querying U.S. public land data by coordinates.**
|
||||
|
||||
Drop a pin anywhere in the western U.S. and instantly get:
|
||||
- **PLSS location** — Section 12, Township 4N, Range 6E
|
||||
- **Land manager** — BLM, Forest Service, National Park, Private, etc.
|
||||
- **Mining claims** — Active lode/placer claims with serial numbers
|
||||
Drop a pin anywhere in the western U.S. and get comprehensive land data:
|
||||
|
||||
| Category | What you get |
|
||||
|----------|--------------|
|
||||
| **PLSS Location** | Section 12, Township 4N, Range 6E, Principal Meridian |
|
||||
| **Land Manager** | BLM, Forest Service, NPS, Private, State, Tribal |
|
||||
| **Mining Claims** | Active lode/placer claims with serial numbers |
|
||||
| **Grazing Allotments** | Livestock grazing permits and acreage |
|
||||
| **Wild Horses** | Herd Management Areas with population data |
|
||||
| **Recreation Sites** | Campgrounds, trailheads, boat launches |
|
||||
| **Wilderness Areas** | Designated Wilderness & Study Areas (WSAs) |
|
||||
| **Wild Rivers** | Wild & Scenic River segments |
|
||||
| **ACECs** | Areas of Critical Environmental Concern |
|
||||
|
||||
## When would I use this?
|
||||
|
||||
| Use Case | What you get |
|
||||
| Use Case | Tools to use |
|
||||
|----------|--------------|
|
||||
| **Dispersed camping** | Check if land is BLM/Forest Service before setting up camp |
|
||||
| **Land research** | Get legal descriptions for title searches or due diligence |
|
||||
| **Prospecting** | Find existing mining claims before staking your own |
|
||||
| **Navigation** | Convert GPS coordinates to the township/range system used on paper maps |
|
||||
| **GIS workflows** | Programmatic access to BLM cadastral data |
|
||||
| **Dispersed camping** | `get_land_manager` — Check if it's BLM/USFS public land |
|
||||
| **Trip planning** | `get_recreation_sites` — Find nearby campgrounds and trailheads |
|
||||
| **Land research** | `get_plss_location` — Get legal descriptions for title searches |
|
||||
| **Prospecting** | `get_mining_claims` — Check existing claims before staking |
|
||||
| **Ranching** | `get_grazing_allotment` — Research grazing permit areas |
|
||||
| **Wildlife viewing** | `get_wild_horse_herd` — Find wild horse/burro areas |
|
||||
| **Backcountry planning** | `get_wilderness_area` — Identify wilderness regulations |
|
||||
| **River trips** | `get_wild_river` — Check Wild & Scenic River status |
|
||||
| **Conservation research** | `get_acec` — Find protected environmental areas |
|
||||
|
||||
## Installation
|
||||
|
||||
@ -27,7 +40,7 @@ Drop a pin anywhere in the western U.S. and instantly get:
|
||||
pip install mcblmplss
|
||||
```
|
||||
|
||||
Or run directly without installing:
|
||||
Or run directly:
|
||||
|
||||
```bash
|
||||
uvx mcblmplss
|
||||
@ -39,26 +52,24 @@ uvx mcblmplss
|
||||
claude mcp add blm "uvx mcblmplss"
|
||||
```
|
||||
|
||||
## Tools
|
||||
## Tools (18 total)
|
||||
|
||||
### `get_plss_location`
|
||||
Each data type has two tools: a human-readable version and a `_details` version returning structured data.
|
||||
|
||||
Convert coordinates to Section/Township/Range.
|
||||
### PLSS — Public Land Survey System
|
||||
|
||||
```
|
||||
> get_plss_location(latitude=40.0, longitude=-105.0)
|
||||
> get_plss_location(40.0, -105.0)
|
||||
|
||||
Section 9, Township 1N, Range 68W, 6th Meridian
|
||||
State: CO
|
||||
PLSS ID: CO060010S0680W0SN090
|
||||
```
|
||||
|
||||
### `get_land_manager`
|
||||
|
||||
Find out who manages the land (and whether you can access it).
|
||||
### Land Manager — Surface Management Agency
|
||||
|
||||
```
|
||||
> get_land_manager(latitude=38.5, longitude=-110.5)
|
||||
> get_land_manager(38.5, -110.5)
|
||||
|
||||
Bureau of Land Management (BLM) - Department of the Interior
|
||||
Unit: Bureau of Land Management
|
||||
@ -66,19 +77,10 @@ State: UT
|
||||
Status: Federal, Public access
|
||||
```
|
||||
|
||||
```
|
||||
> get_land_manager(latitude=40.0, longitude=-105.0)
|
||||
|
||||
Private (PVT)
|
||||
State: CO
|
||||
```
|
||||
|
||||
### `get_mining_claims`
|
||||
|
||||
Find active mining claims at a location.
|
||||
### Mining Claims
|
||||
|
||||
```
|
||||
> get_mining_claims(latitude=39.5, longitude=-117.0)
|
||||
> get_mining_claims(39.5, -117.0)
|
||||
|
||||
Found 42 mining claim(s):
|
||||
|
||||
@ -87,13 +89,82 @@ MAGA #6
|
||||
Type: Lode Claim
|
||||
Status: Active
|
||||
Acres: 20.66
|
||||
```
|
||||
|
||||
MS 2
|
||||
Serial: NV105223666
|
||||
Type: Lode Claim
|
||||
Status: Active
|
||||
Acres: 20.66
|
||||
...
|
||||
### Grazing Allotments
|
||||
|
||||
```
|
||||
> get_grazing_allotment(40.0, -117.0)
|
||||
|
||||
AUSTIN (#10004)
|
||||
State: Nevada
|
||||
Admin Unit: MOUNT LEWIS FIELD OFFICE
|
||||
Acres: 245,420
|
||||
Managing Number: NV10004
|
||||
```
|
||||
|
||||
### Wild Horses & Burros
|
||||
|
||||
```
|
||||
> get_wild_horse_herd(40.0, -117.5)
|
||||
|
||||
Augusta Mountains (NV0311)
|
||||
State: Nevada
|
||||
Type: Horse
|
||||
Acres: 177,570
|
||||
Horse AML: 185-308
|
||||
Estimated Population: 475 (154% of AML)
|
||||
Last Inventory: 2015-01-01
|
||||
```
|
||||
|
||||
### Recreation Sites
|
||||
|
||||
```
|
||||
> get_recreation_sites(38.9, -111.2)
|
||||
|
||||
Found 3 recreation site(s):
|
||||
|
||||
Rochester Panel
|
||||
Type: Facility
|
||||
Reservable: No
|
||||
Phone: 435-636-3600
|
||||
https://www.blm.gov/visit/search-details/257016/1
|
||||
```
|
||||
|
||||
### Wilderness & WSAs
|
||||
|
||||
```
|
||||
> get_wilderness_area(38.4, -110.9)
|
||||
|
||||
Middle Wild Horse Mesa
|
||||
Status: Designated Wilderness
|
||||
State: Utah
|
||||
NLCS ID: NLCS000885
|
||||
Designated: 3/12/2019
|
||||
```
|
||||
|
||||
### Wild & Scenic Rivers
|
||||
|
||||
```
|
||||
> get_wild_river(42.5, -123.5)
|
||||
|
||||
Rogue River
|
||||
Classification: Recreational
|
||||
State: Oregon
|
||||
NLCS ID: NLCS000836
|
||||
```
|
||||
|
||||
### ACECs — Areas of Critical Environmental Concern
|
||||
|
||||
```
|
||||
> get_acec(35.0, -117.0)
|
||||
|
||||
Superior-Cronese
|
||||
State: California
|
||||
Protected Values: Natural Process, Natural System, Wildlife
|
||||
Acres: 518,461
|
||||
Land Use Plan: CDCA Plan, as amended by DRECP
|
||||
Designated: 9/14/2016
|
||||
```
|
||||
|
||||
## Coverage
|
||||
@ -102,25 +173,23 @@ Data is available for **30 states** where the Public Land Survey System was used
|
||||
|
||||

|
||||
|
||||
**Not covered:** Eastern seaboard states (use metes-and-bounds), Texas (independent surveys), Hawaii.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The server returns clear error messages when:
|
||||
|
||||
- **Outside PLSS coverage**: "No PLSS data found. Location may be outside surveyed areas."
|
||||
- **API timeout**: "BLM API request timed out. The service may be slow or unavailable."
|
||||
- **No mining claims**: "No mining claims found at this location."
|
||||
**Not covered:** Eastern seaboard states (metes-and-bounds), Texas (independent surveys), Hawaii.
|
||||
|
||||
## Data Sources
|
||||
|
||||
All data comes from official BLM ArcGIS REST services:
|
||||
|
||||
| Data | Source | Update Frequency |
|
||||
|------|--------|------------------|
|
||||
| Data | Source | Typical Update |
|
||||
|------|--------|----------------|
|
||||
| PLSS | [BLM National PLSS CadNSDI](https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer) | Quarterly |
|
||||
| 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 |
|
||||
| Grazing | [BLM Grazing Allotment](https://gis.blm.gov/arcgis/rest/services/range/BLM_Natl_Grazing_Allotment/MapServer) | Annual |
|
||||
| Wild Horses | [BLM WHB](https://gis.blm.gov/arcgis/rest/services/range/BLM_Natl_WHB_Geocortex/MapServer) | Annual |
|
||||
| Recreation | [BLM RIDB](https://gis.blm.gov/arcgis/rest/services/recreation/BLM_Natl_Recreation_Sites_Facilities/MapServer) | Nightly |
|
||||
| Wilderness | [BLM NLCS WLD/WSA](https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_NLCS_WLD_WSA/MapServer) | As designated |
|
||||
| Wild Rivers | [BLM NLCS WSR](https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_NLCS_WSR/MapServer) | As designated |
|
||||
| ACEC | [BLM ACEC](https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_ACEC/MapServer) | As designated |
|
||||
|
||||
**Disclaimer:** This data is for informational purposes only. For legal land descriptions, consult official BLM records or a licensed surveyor.
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "mcblmplss"
|
||||
version = "2024.12.03"
|
||||
description = "MCP server for querying BLM land data: PLSS coordinates, surface management agency, and mining claims"
|
||||
version = "2025.01.25"
|
||||
description = "MCP server for querying BLM land data: PLSS, land manager, mining claims, grazing, wild horses, recreation, wilderness, rivers, and ACECs"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.11"
|
||||
@ -20,6 +20,12 @@ keywords = [
|
||||
"surface-management",
|
||||
"land-ownership",
|
||||
"public-lands",
|
||||
"grazing",
|
||||
"wild-horses",
|
||||
"recreation",
|
||||
"wilderness",
|
||||
"wild-rivers",
|
||||
"acec",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
|
||||
@ -4,8 +4,24 @@ MCP Mixins for BLM data services.
|
||||
Each mixin provides tools for a specific BLM data domain.
|
||||
"""
|
||||
|
||||
from mcblmplss.mixins.acec import ACECMixin
|
||||
from mcblmplss.mixins.grazing import GrazingMixin
|
||||
from mcblmplss.mixins.mining_claims import MiningClaimsMixin
|
||||
from mcblmplss.mixins.plss import PLSSMixin
|
||||
from mcblmplss.mixins.recreation import RecreationMixin
|
||||
from mcblmplss.mixins.surface_management import SurfaceManagementMixin
|
||||
from mcblmplss.mixins.wild_horses import WildHorseMixin
|
||||
from mcblmplss.mixins.wild_rivers import WildRiversMixin
|
||||
from mcblmplss.mixins.wilderness import WildernessMixin
|
||||
|
||||
__all__ = ["PLSSMixin", "SurfaceManagementMixin", "MiningClaimsMixin"]
|
||||
__all__ = [
|
||||
"ACECMixin",
|
||||
"PLSSMixin",
|
||||
"SurfaceManagementMixin",
|
||||
"MiningClaimsMixin",
|
||||
"GrazingMixin",
|
||||
"WildHorseMixin",
|
||||
"WildRiversMixin",
|
||||
"WildernessMixin",
|
||||
"RecreationMixin",
|
||||
]
|
||||
|
||||
176
src/mcblmplss/mixins/acec.py
Normal file
176
src/mcblmplss/mixins/acec.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
ACEC (Areas of Critical Environmental Concern) mixin for BLM MCP server.
|
||||
|
||||
Provides tools for querying BLM-designated areas requiring special management
|
||||
to protect important natural, cultural, or scenic values.
|
||||
"""
|
||||
|
||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcblmplss.client import BLMAPIError, blm_client
|
||||
|
||||
# ACEC MapServer
|
||||
ACEC_URL = "https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_ACEC/MapServer"
|
||||
LAYER_ACEC = 0 # ACEC Designated Polygons
|
||||
|
||||
# Relevance field mappings
|
||||
RELEVANCE_FIELDS = {
|
||||
"ACEC Cultural Designation Relevance": "Cultural",
|
||||
"ACEC Wildlife Resource Designation Relevance": "Wildlife",
|
||||
"ACEC Scenic Designation Relevance": "Scenic",
|
||||
"ACEC Natural System Designation Relevance": "Natural System",
|
||||
}
|
||||
|
||||
RELEVANCE_POSITIVE = "Yes - Affirmative or Present"
|
||||
|
||||
|
||||
class ACEC(BaseModel):
|
||||
"""Individual ACEC record."""
|
||||
|
||||
name: str = Field(..., description="ACEC name")
|
||||
land_use_plan: str | None = Field(None, description="Land Use Plan name")
|
||||
designation_date: str | None = Field(None, description="Record of Decision date")
|
||||
acres: float | None = Field(None, description="GIS-calculated acreage")
|
||||
state: str = Field(..., description="Administrative state abbreviation")
|
||||
relevance_values: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Protected values (Cultural, Wildlife, Scenic, Natural System)",
|
||||
)
|
||||
|
||||
|
||||
class ACECResult(BaseModel):
|
||||
"""Result from ACEC query."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
acecs: list[ACEC] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_acec(attrs: dict) -> ACEC:
|
||||
"""Parse ACEC attributes from API response."""
|
||||
# Extract relevance values where designation is affirmative
|
||||
relevance_values = []
|
||||
for field_name, value_name in RELEVANCE_FIELDS.items():
|
||||
if attrs.get(field_name) == RELEVANCE_POSITIVE:
|
||||
relevance_values.append(value_name)
|
||||
|
||||
# Parse acres
|
||||
acres_val = attrs.get("GIS_ACRES")
|
||||
try:
|
||||
acres = float(acres_val) if acres_val is not None else None
|
||||
except (ValueError, TypeError):
|
||||
acres = None
|
||||
|
||||
return ACEC(
|
||||
name=attrs.get("ACEC_NAME", "Unnamed ACEC"),
|
||||
land_use_plan=attrs.get("LUP_NAME") if attrs.get("LUP_NAME") else None,
|
||||
designation_date=attrs.get("ROD_DATE") if attrs.get("ROD_DATE") else None,
|
||||
acres=acres,
|
||||
state=attrs.get("ADMIN_ST", ""),
|
||||
relevance_values=relevance_values,
|
||||
)
|
||||
|
||||
|
||||
class ACECMixin(MCPMixin):
|
||||
"""MCP tools for ACEC (Areas of Critical Environmental Concern) queries."""
|
||||
|
||||
@mcp_tool(
|
||||
name="get_acec",
|
||||
description="Find Areas of Critical Environmental Concern at coordinates.",
|
||||
)
|
||||
async def get_acec(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> str:
|
||||
"""
|
||||
Get information about Areas of Critical Environmental Concern (ACEC).
|
||||
|
||||
ACECs are BLM-designated areas requiring special management to protect
|
||||
important values including:
|
||||
- Cultural resources (archaeological sites, historic areas)
|
||||
- Wildlife resources (habitat, migration corridors)
|
||||
- Scenic values (viewsheds, landscapes)
|
||||
- Natural systems (unique geology, rare plants)
|
||||
|
||||
Returns ACEC name, protected values, acreage, and land use plan info.
|
||||
"""
|
||||
result = await self._query_acec(latitude, longitude)
|
||||
|
||||
if result.error:
|
||||
return f"Error: {result.error}"
|
||||
|
||||
if not result.acecs:
|
||||
return "No Areas of Critical Environmental Concern found at this location."
|
||||
|
||||
lines = [f"Found {len(result.acecs)} ACEC(s):"]
|
||||
for acec in result.acecs:
|
||||
lines.append(f"\n{acec.name}")
|
||||
lines.append(f" State: {acec.state}")
|
||||
|
||||
if acec.relevance_values:
|
||||
lines.append(f" Protected Values: {', '.join(acec.relevance_values)}")
|
||||
|
||||
if acec.acres:
|
||||
lines.append(f" Acres: {acec.acres:,.1f}")
|
||||
|
||||
if acec.land_use_plan:
|
||||
lines.append(f" Land Use Plan: {acec.land_use_plan}")
|
||||
|
||||
if acec.designation_date:
|
||||
lines.append(f" Designated: {acec.designation_date}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp_tool(
|
||||
name="get_acec_details",
|
||||
description="Get detailed ACEC data as structured objects.",
|
||||
)
|
||||
async def get_acec_details(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> ACECResult:
|
||||
"""Get full ACEC details including all designation relevance fields."""
|
||||
return await self._query_acec(latitude, longitude)
|
||||
|
||||
async def _query_acec(self, latitude: float, longitude: float) -> ACECResult:
|
||||
"""Query BLM ACEC API for location."""
|
||||
try:
|
||||
results = await blm_client.identify(
|
||||
ACEC_URL, latitude, longitude, f"all:{LAYER_ACEC}"
|
||||
)
|
||||
except BLMAPIError as e:
|
||||
return ACECResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if not results:
|
||||
return ACECResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
acecs=[],
|
||||
)
|
||||
|
||||
acecs = []
|
||||
seen_names = set()
|
||||
|
||||
for result in results:
|
||||
if result.get("layerId") == LAYER_ACEC:
|
||||
attrs = result.get("attributes", {})
|
||||
name = attrs.get("ACEC_NAME", "")
|
||||
|
||||
# Dedupe by ACEC name
|
||||
if name and name not in seen_names:
|
||||
seen_names.add(name)
|
||||
acecs.append(_parse_acec(attrs))
|
||||
|
||||
return ACECResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
acecs=acecs,
|
||||
)
|
||||
148
src/mcblmplss/mixins/grazing.py
Normal file
148
src/mcblmplss/mixins/grazing.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""
|
||||
Grazing Allotment mixin for BLM MCP server.
|
||||
|
||||
Provides tools for querying BLM grazing allotment information.
|
||||
"""
|
||||
|
||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcblmplss.client import BLMAPIError, blm_client
|
||||
|
||||
# Grazing Allotment MapServer
|
||||
GRAZING_URL = "https://gis.blm.gov/arcgis/rest/services/range/BLM_Natl_Grazing_Allotment/MapServer"
|
||||
LAYER_ALLOTMENT = 12 # Grazing Allotment Polygons
|
||||
|
||||
|
||||
class GrazingAllotment(BaseModel):
|
||||
"""Grazing allotment information."""
|
||||
|
||||
allotment_number: str = Field(..., description="Allotment number identifier")
|
||||
allotment_name: str = Field(..., description="Name of the grazing allotment")
|
||||
acres: float | None = Field(None, description="GIS-calculated acreage")
|
||||
state: str = Field(..., description="Administrative state code")
|
||||
admin_unit: str | None = Field(None, description="Administrative unit code")
|
||||
managing_number: str | None = Field(
|
||||
None, description="Managing state allotment number"
|
||||
)
|
||||
|
||||
|
||||
class GrazingResult(BaseModel):
|
||||
"""Result from grazing allotment query."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
allotment: GrazingAllotment | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_allotment(attrs: dict) -> GrazingAllotment:
|
||||
"""Parse grazing allotment attributes from API response."""
|
||||
acres_val = attrs.get("GIS Acres")
|
||||
try:
|
||||
acres = float(acres_val) if acres_val is not None else None
|
||||
except (ValueError, TypeError):
|
||||
acres = None
|
||||
|
||||
return GrazingAllotment(
|
||||
allotment_number=attrs.get("Allotment Number", ""),
|
||||
allotment_name=attrs.get("Allotment Name", "Unnamed"),
|
||||
acres=acres,
|
||||
state=attrs.get("Administrative State Code", ""),
|
||||
admin_unit=attrs.get("Adminstrative Unit Code"), # Note: BLM's typo
|
||||
managing_number=attrs.get("Managing State Allotment Number"),
|
||||
)
|
||||
|
||||
|
||||
class GrazingMixin(MCPMixin):
|
||||
"""MCP tools for grazing allotment queries."""
|
||||
|
||||
@mcp_tool(
|
||||
name="get_grazing_allotment",
|
||||
description="Find BLM grazing allotment information at coordinates.",
|
||||
)
|
||||
async def get_grazing_allotment(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> str:
|
||||
"""
|
||||
Get grazing allotment information for a location.
|
||||
|
||||
Grazing allotments are areas of public land designated for
|
||||
livestock grazing under BLM permits. Returns the allotment
|
||||
name, number, acreage, and administrative details.
|
||||
"""
|
||||
result = await self._query_grazing(latitude, longitude)
|
||||
|
||||
if result.error:
|
||||
return f"Error: {result.error}"
|
||||
|
||||
if result.allotment:
|
||||
allot = result.allotment
|
||||
lines = [f"{allot.allotment_name} (#{allot.allotment_number})"]
|
||||
|
||||
if allot.acres:
|
||||
lines.append(f"Acres: {allot.acres:,.2f}")
|
||||
|
||||
lines.append(f"State: {allot.state}")
|
||||
|
||||
if allot.admin_unit:
|
||||
lines.append(f"Admin Unit: {allot.admin_unit}")
|
||||
|
||||
if allot.managing_number:
|
||||
lines.append(f"Managing Number: {allot.managing_number}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
return "No grazing allotment found at this location."
|
||||
|
||||
@mcp_tool(
|
||||
name="get_grazing_allotment_details",
|
||||
description="Get detailed grazing allotment data as structured object.",
|
||||
)
|
||||
async def get_grazing_allotment_details(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> GrazingResult:
|
||||
"""Get full grazing allotment details including administrative codes."""
|
||||
return await self._query_grazing(latitude, longitude)
|
||||
|
||||
async def _query_grazing(
|
||||
self, latitude: float, longitude: float
|
||||
) -> GrazingResult:
|
||||
"""Query BLM Grazing Allotment API for location."""
|
||||
try:
|
||||
results = await blm_client.identify(
|
||||
GRAZING_URL, latitude, longitude, f"all:{LAYER_ALLOTMENT}"
|
||||
)
|
||||
except BLMAPIError as e:
|
||||
return GrazingResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if not results:
|
||||
return GrazingResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error="No grazing allotment data found for this location.",
|
||||
)
|
||||
|
||||
# Use first result from allotment layer
|
||||
for result in results:
|
||||
if result.get("layerId") == LAYER_ALLOTMENT:
|
||||
attrs = result.get("attributes", {})
|
||||
return GrazingResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
allotment=_parse_allotment(attrs),
|
||||
)
|
||||
|
||||
return GrazingResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error="No grazing allotment data in response.",
|
||||
)
|
||||
182
src/mcblmplss/mixins/recreation.py
Normal file
182
src/mcblmplss/mixins/recreation.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""
|
||||
Recreation Sites and Facilities mixin for BLM MCP server.
|
||||
|
||||
Provides tools for querying BLM recreation sites including campgrounds,
|
||||
trailheads, boat ramps, and other public facilities.
|
||||
"""
|
||||
|
||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcblmplss.client import BLMAPIError, blm_client
|
||||
|
||||
# Recreation Sites and Facilities MapServer
|
||||
RECREATION_URL = "https://gis.blm.gov/arcgis/rest/services/recreation/BLM_Natl_Recreation_Sites_Facilities/MapServer"
|
||||
LAYER_FACILITIES = 0
|
||||
LAYER_SITES = 1
|
||||
|
||||
|
||||
class RecreationSite(BaseModel):
|
||||
"""Recreation site or facility record."""
|
||||
|
||||
facility_id: str = Field(..., description="BLM facility identifier")
|
||||
name: str = Field(..., description="Facility or site name")
|
||||
description: str | None = Field(None, description="Facility description")
|
||||
site_type: str | None = Field(None, description="Type of recreation facility")
|
||||
directions: str | None = Field(None, description="Directions to the facility")
|
||||
phone: str | None = Field(None, description="Contact phone number")
|
||||
email: str | None = Field(None, description="Contact email address")
|
||||
url: str | None = Field(None, description="Website URL for more information")
|
||||
state: str | None = Field(None, description="State abbreviation")
|
||||
reservable: bool = Field(False, description="Whether reservations are accepted")
|
||||
|
||||
|
||||
class RecreationResult(BaseModel):
|
||||
"""Result from recreation sites query."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
sites: list[RecreationSite] = Field(default_factory=list)
|
||||
total_found: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_site(attrs: dict) -> RecreationSite:
|
||||
"""Parse recreation site attributes from API response."""
|
||||
|
||||
def clean_string(value: str | None) -> str | None:
|
||||
"""Convert empty strings and null markers to None."""
|
||||
if value is None or value == "" or value == "Null":
|
||||
return None
|
||||
return value
|
||||
|
||||
# Convert Reservable: 0 = False, -1 = True (typical ArcGIS boolean)
|
||||
reservable_val = attrs.get("Reservable", 0)
|
||||
reservable = reservable_val == -1 or reservable_val == 1
|
||||
|
||||
return RecreationSite(
|
||||
facility_id=attrs.get("FacilityID", ""),
|
||||
name=attrs.get("FacilityName", "Unnamed"),
|
||||
description=clean_string(attrs.get("FacilityDescription")),
|
||||
site_type=clean_string(attrs.get("FacilityTypeDescription")),
|
||||
directions=clean_string(attrs.get("FacilityDirections")),
|
||||
phone=clean_string(attrs.get("FacilityPhone")),
|
||||
email=clean_string(attrs.get("FacilityEmail")),
|
||||
url=clean_string(attrs.get("URL")),
|
||||
state=clean_string(attrs.get("State")),
|
||||
reservable=reservable,
|
||||
)
|
||||
|
||||
|
||||
class RecreationMixin(MCPMixin):
|
||||
"""MCP tools for recreation sites and facilities queries."""
|
||||
|
||||
@mcp_tool(
|
||||
name="get_recreation_sites",
|
||||
description="Find BLM recreation sites near coordinates (campgrounds, trailheads, etc.).",
|
||||
)
|
||||
async def get_recreation_sites(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> str:
|
||||
"""
|
||||
Find recreation sites and facilities near a location.
|
||||
|
||||
Recreation sites include:
|
||||
- Campgrounds and camping areas
|
||||
- Trailheads and hiking areas
|
||||
- Boat ramps and water access
|
||||
- Picnic areas
|
||||
- Visitor centers
|
||||
|
||||
Returns site names, types, contact info, and reservation status.
|
||||
"""
|
||||
result = await self._query_recreation(latitude, longitude, tolerance=100)
|
||||
|
||||
if result.error:
|
||||
return f"Error: {result.error}"
|
||||
|
||||
if not result.sites:
|
||||
return "No recreation sites found near this location."
|
||||
|
||||
lines = [f"Found {result.total_found} recreation site(s):"]
|
||||
for site in result.sites[:10]: # Limit display to 10
|
||||
lines.append(f"\n{site.name}")
|
||||
if site.site_type:
|
||||
lines.append(f" Type: {site.site_type}")
|
||||
if site.state:
|
||||
lines.append(f" State: {site.state}")
|
||||
if site.reservable:
|
||||
lines.append(" Reservations: Accepted")
|
||||
if site.phone:
|
||||
lines.append(f" Phone: {site.phone}")
|
||||
if site.url:
|
||||
lines.append(f" Website: {site.url}")
|
||||
|
||||
if result.total_found > 10:
|
||||
lines.append(f"\n... and {result.total_found - 10} more sites")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp_tool(
|
||||
name="get_recreation_sites_details",
|
||||
description="Get detailed recreation site data as structured objects.",
|
||||
)
|
||||
async def get_recreation_sites_details(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
tolerance: int = Field(
|
||||
default=100, description="Search radius in pixels (larger = wider search)"
|
||||
),
|
||||
) -> RecreationResult:
|
||||
"""Get full recreation site data including descriptions and contact info."""
|
||||
return await self._query_recreation(latitude, longitude, tolerance)
|
||||
|
||||
async def _query_recreation(
|
||||
self,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
tolerance: int = 100,
|
||||
) -> RecreationResult:
|
||||
"""Query BLM Recreation Sites API."""
|
||||
layers = f"all:{LAYER_FACILITIES},{LAYER_SITES}"
|
||||
|
||||
try:
|
||||
results = await blm_client.identify(
|
||||
RECREATION_URL, latitude, longitude, layers, tolerance=tolerance
|
||||
)
|
||||
except BLMAPIError as e:
|
||||
return RecreationResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if not results:
|
||||
return RecreationResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
sites=[],
|
||||
total_found=0,
|
||||
)
|
||||
|
||||
sites = []
|
||||
seen_ids = set()
|
||||
|
||||
for result in results:
|
||||
attrs = result.get("attributes", {})
|
||||
|
||||
# Dedupe by facility ID
|
||||
facility_id = attrs.get("FacilityID", "")
|
||||
if facility_id and facility_id not in seen_ids:
|
||||
seen_ids.add(facility_id)
|
||||
sites.append(_parse_site(attrs))
|
||||
|
||||
return RecreationResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
sites=sites,
|
||||
total_found=len(sites),
|
||||
)
|
||||
225
src/mcblmplss/mixins/wild_horses.py
Normal file
225
src/mcblmplss/mixins/wild_horses.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""
|
||||
Wild Horse and Burro mixin for BLM MCP server.
|
||||
|
||||
Provides tools for querying Herd Management Areas (HMA) and Herd Areas (HA)
|
||||
from BLM's Wild Horse and Burro program.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcblmplss.client import BLMAPIError, blm_client
|
||||
|
||||
# Wild Horse and Burro MapServer
|
||||
WHB_URL = "https://gis.blm.gov/arcgis/rest/services/range/BLM_Natl_WHB_Geocortex/MapServer"
|
||||
LAYER_HMA = 8 # Herd Management Area
|
||||
LAYER_HA = 9 # Herd Area
|
||||
|
||||
|
||||
class HerdManagementArea(BaseModel):
|
||||
"""Herd Management Area information."""
|
||||
|
||||
name: str = Field(..., description="Herd Management Area name")
|
||||
hma_id: str | None = Field(None, description="HMA identifier")
|
||||
herd_type: str | None = Field(None, description="Type of animals (Horse, Burro, or both)")
|
||||
acres: float | None = Field(None, description="Total acres in HMA")
|
||||
state: str | None = Field(None, description="Administrative state code")
|
||||
horse_aml_low: int | None = Field(None, description="Horse AML (low)")
|
||||
horse_aml_high: int | None = Field(None, description="Horse AML (high)")
|
||||
horse_population: int | None = Field(None, description="Estimated horse population")
|
||||
burro_aml_low: int | None = Field(None, description="Burro AML (low)")
|
||||
burro_aml_high: int | None = Field(None, description="Burro AML (high)")
|
||||
burro_population: int | None = Field(None, description="Estimated burro population")
|
||||
inventory_date: str | None = Field(None, description="Population inventory date")
|
||||
website: str | None = Field(None, description="HMA website link")
|
||||
|
||||
|
||||
class WildHorseResult(BaseModel):
|
||||
"""Result from wild horse/burro herd query."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
hma: HerdManagementArea | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_nullable(value: str | int | float | None) -> str | None:
|
||||
"""Convert 'Null' strings and empty values to None."""
|
||||
if value is None or value == "Null" or value == "":
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _parse_int(value: str | int | float | None) -> int | None:
|
||||
"""Parse integer from API, handling 'Null' strings."""
|
||||
if value is None or value == "Null" or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_float(value: str | int | float | None) -> float | None:
|
||||
"""Parse float from API, handling 'Null' strings."""
|
||||
if value is None or value == "Null" or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(value: str | int | None) -> str | None:
|
||||
"""Parse date from API (may be epoch ms or string)."""
|
||||
if value is None or value == "Null" or value == "":
|
||||
return None
|
||||
try:
|
||||
# ArcGIS often returns epoch milliseconds
|
||||
if isinstance(value, (int, float)) or (isinstance(value, str) and value.isdigit()):
|
||||
epoch_ms = int(value)
|
||||
return datetime.fromtimestamp(epoch_ms / 1000).strftime("%Y-%m-%d")
|
||||
return str(value)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _parse_hma(attrs: dict) -> HerdManagementArea:
|
||||
"""Parse HMA attributes from API response."""
|
||||
# Note: BLM has a typo in their field name - "Mangement" instead of "Management"
|
||||
name = (
|
||||
attrs.get("Herd Mangement Area Name")
|
||||
or attrs.get("Herd Management Area Name", "Unknown")
|
||||
)
|
||||
|
||||
return HerdManagementArea(
|
||||
name=name,
|
||||
hma_id=_parse_nullable(attrs.get("Herd Management Area Identifier")),
|
||||
herd_type=_parse_nullable(attrs.get("Herd Type")),
|
||||
acres=_parse_float(attrs.get("TOTAL_ACRES") or attrs.get("BLM Acres")),
|
||||
state=_parse_nullable(attrs.get("Administrative State Code")),
|
||||
horse_aml_low=_parse_int(attrs.get("HORSE_AML_LOW")),
|
||||
horse_aml_high=_parse_int(attrs.get("HORSE_AML_HIGH")),
|
||||
horse_population=_parse_int(attrs.get("EST_HORSE_POP")),
|
||||
burro_aml_low=_parse_int(attrs.get("BURRO_AML_LOW")),
|
||||
burro_aml_high=_parse_int(attrs.get("BURRO_AML_HIGH")),
|
||||
burro_population=_parse_int(attrs.get("EST_BURRO_POP")),
|
||||
inventory_date=_parse_date(attrs.get("POP_INVENTORY_DT")),
|
||||
website=_parse_nullable(attrs.get("HMA Website Link")),
|
||||
)
|
||||
|
||||
|
||||
class WildHorseMixin(MCPMixin):
|
||||
"""MCP tools for Wild Horse and Burro herd queries."""
|
||||
|
||||
@mcp_tool(
|
||||
name="get_wild_horse_herd",
|
||||
description="Check if a location is within a Wild Horse or Burro Herd Management Area.",
|
||||
)
|
||||
async def get_wild_horse_herd(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> str:
|
||||
"""
|
||||
Get wild horse and burro herd management information for a location.
|
||||
|
||||
Returns details about the Herd Management Area (HMA) including:
|
||||
- HMA name and herd type (horses, burros, or both)
|
||||
- Appropriate Management Level (AML) population targets
|
||||
- Current estimated populations
|
||||
- Total acreage
|
||||
|
||||
HMAs are areas managed by BLM for wild horse and burro populations
|
||||
under the Wild Free-Roaming Horses and Burros Act of 1971.
|
||||
"""
|
||||
result = await self._query_hma(latitude, longitude)
|
||||
|
||||
if result.error:
|
||||
return f"Error: {result.error}"
|
||||
|
||||
if result.hma:
|
||||
hma = result.hma
|
||||
lines = [f"Herd Management Area: {hma.name}"]
|
||||
|
||||
if hma.herd_type:
|
||||
lines.append(f"Herd Type: {hma.herd_type}")
|
||||
|
||||
if hma.state:
|
||||
lines.append(f"State: {hma.state}")
|
||||
|
||||
if hma.acres:
|
||||
lines.append(f"Total Acres: {hma.acres:,.0f}")
|
||||
|
||||
# Horse population info
|
||||
if hma.horse_aml_low is not None or hma.horse_aml_high is not None:
|
||||
aml_range = f"{hma.horse_aml_low or 0}-{hma.horse_aml_high or '?'}"
|
||||
lines.append(f"Horse AML Range: {aml_range}")
|
||||
if hma.horse_population is not None:
|
||||
lines.append(f"Estimated Horse Population: {hma.horse_population:,}")
|
||||
|
||||
# Burro population info
|
||||
if hma.burro_aml_low is not None or hma.burro_aml_high is not None:
|
||||
aml_range = f"{hma.burro_aml_low or 0}-{hma.burro_aml_high or '?'}"
|
||||
lines.append(f"Burro AML Range: {aml_range}")
|
||||
if hma.burro_population is not None:
|
||||
lines.append(f"Estimated Burro Population: {hma.burro_population:,}")
|
||||
|
||||
if hma.inventory_date:
|
||||
lines.append(f"Population Inventory Date: {hma.inventory_date}")
|
||||
|
||||
if hma.website:
|
||||
lines.append(f"More Info: {hma.website}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
return "This location is not within a Wild Horse or Burro Herd Management Area."
|
||||
|
||||
@mcp_tool(
|
||||
name="get_wild_horse_herd_details",
|
||||
description="Get detailed Wild Horse/Burro HMA data as structured object.",
|
||||
)
|
||||
async def get_wild_horse_herd_details(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> WildHorseResult:
|
||||
"""Get full HMA details including all population metrics."""
|
||||
return await self._query_hma(latitude, longitude)
|
||||
|
||||
async def _query_hma(self, latitude: float, longitude: float) -> WildHorseResult:
|
||||
"""Query BLM Wild Horse/Burro API for HMA at location."""
|
||||
try:
|
||||
# Query HMA layer (layer 8)
|
||||
results = await blm_client.identify(WHB_URL, latitude, longitude, f"all:{LAYER_HMA}")
|
||||
except BLMAPIError as e:
|
||||
return WildHorseResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if not results:
|
||||
return WildHorseResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
hma=None,
|
||||
)
|
||||
|
||||
# Use first result from HMA layer
|
||||
for result in results:
|
||||
if result.get("layerId") == LAYER_HMA:
|
||||
attrs = result.get("attributes", {})
|
||||
return WildHorseResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
hma=_parse_hma(attrs),
|
||||
)
|
||||
|
||||
return WildHorseResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
hma=None,
|
||||
)
|
||||
167
src/mcblmplss/mixins/wild_rivers.py
Normal file
167
src/mcblmplss/mixins/wild_rivers.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""
|
||||
Wild and Scenic Rivers mixin for BLM MCP server.
|
||||
|
||||
Provides tools for querying designated Wild and Scenic Rivers from BLM's NLCS database.
|
||||
"""
|
||||
|
||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcblmplss.client import BLMAPIError, blm_client
|
||||
|
||||
# Wild and Scenic Rivers MapServer
|
||||
WSR_URL = "https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_NLCS_WSR/MapServer"
|
||||
LAYER_WSR = 0 # Wild and Scenic Rivers layer
|
||||
|
||||
|
||||
class WildRiver(BaseModel):
|
||||
"""Individual wild and scenic river segment."""
|
||||
|
||||
nlcs_id: str = Field(..., description="NLCS unique identifier")
|
||||
name: str = Field(..., description="River/segment name")
|
||||
category: str = Field(..., description="Full designation category")
|
||||
classification: str = Field(
|
||||
..., description="River classification (Wild, Scenic, or Recreational)"
|
||||
)
|
||||
state: str = Field(..., description="Administrative state code")
|
||||
segment_number: str | None = Field(None, description="WSR segment number")
|
||||
|
||||
|
||||
class WildRiverResult(BaseModel):
|
||||
"""Result from wild and scenic rivers query."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
rivers: list[WildRiver] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_river(attrs: dict) -> WildRiver:
|
||||
"""Parse wild river attributes from API response."""
|
||||
category = attrs.get("Category", "")
|
||||
|
||||
# Extract classification from category string
|
||||
# e.g., "Designated - Recreational" -> "Recreational"
|
||||
classification = "Unknown"
|
||||
if category:
|
||||
category_lower = category.lower()
|
||||
if "wild" in category_lower:
|
||||
classification = "Wild"
|
||||
elif "scenic" in category_lower:
|
||||
classification = "Scenic"
|
||||
elif "recreational" in category_lower:
|
||||
classification = "Recreational"
|
||||
|
||||
segment_num = attrs.get("WSR Segment Number")
|
||||
if segment_num and segment_num == "Null":
|
||||
segment_num = None
|
||||
|
||||
return WildRiver(
|
||||
nlcs_id=attrs.get("NLCS_ID", ""),
|
||||
name=attrs.get("NLCS Name", "Unknown"),
|
||||
category=category,
|
||||
classification=classification,
|
||||
state=attrs.get("Administrative State Code", ""),
|
||||
segment_number=segment_num,
|
||||
)
|
||||
|
||||
|
||||
class WildRiversMixin(MCPMixin):
|
||||
"""MCP tools for Wild and Scenic Rivers queries."""
|
||||
|
||||
@mcp_tool(
|
||||
name="get_wild_river",
|
||||
description="Find Wild and Scenic Rivers near coordinates.",
|
||||
)
|
||||
async def get_wild_river(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> str:
|
||||
"""
|
||||
Find Wild and Scenic River segments near a location.
|
||||
|
||||
The National Wild and Scenic Rivers System preserves rivers with
|
||||
outstanding natural, cultural, or recreational values in a free-flowing
|
||||
condition. Rivers are classified as:
|
||||
|
||||
- Wild: Primitive, undeveloped watersheds, no road access
|
||||
- Scenic: Largely undeveloped but accessible by road
|
||||
- Recreational: Readily accessible with some development
|
||||
|
||||
Uses tolerance=50 for linear river features.
|
||||
"""
|
||||
result = await self._query_rivers(latitude, longitude)
|
||||
|
||||
if result.error:
|
||||
return f"Error: {result.error}"
|
||||
|
||||
if not result.rivers:
|
||||
return "No Wild and Scenic Rivers found near this location."
|
||||
|
||||
lines = [f"Found {len(result.rivers)} Wild and Scenic River segment(s):"]
|
||||
for river in result.rivers:
|
||||
lines.append(f"\n{river.name}")
|
||||
lines.append(f" Classification: {river.classification}")
|
||||
lines.append(f" Category: {river.category}")
|
||||
lines.append(f" State: {river.state}")
|
||||
if river.segment_number:
|
||||
lines.append(f" Segment: {river.segment_number}")
|
||||
lines.append(f" NLCS ID: {river.nlcs_id}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp_tool(
|
||||
name="get_wild_river_details",
|
||||
description="Get detailed Wild and Scenic Rivers data as structured objects.",
|
||||
)
|
||||
async def get_wild_river_details(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> WildRiverResult:
|
||||
"""Get full Wild and Scenic Rivers data including NLCS identifiers."""
|
||||
return await self._query_rivers(latitude, longitude)
|
||||
|
||||
async def _query_rivers(
|
||||
self,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
) -> WildRiverResult:
|
||||
"""Query BLM Wild and Scenic Rivers API."""
|
||||
try:
|
||||
results = await blm_client.identify(
|
||||
WSR_URL, latitude, longitude, f"all:{LAYER_WSR}", tolerance=50
|
||||
)
|
||||
except BLMAPIError as e:
|
||||
return WildRiverResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if not results:
|
||||
return WildRiverResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
rivers=[],
|
||||
)
|
||||
|
||||
rivers = []
|
||||
seen_ids = set()
|
||||
|
||||
for result in results:
|
||||
if result.get("layerId") == LAYER_WSR:
|
||||
attrs = result.get("attributes", {})
|
||||
|
||||
# Dedupe by NLCS ID
|
||||
nlcs_id = attrs.get("NLCS_ID", "")
|
||||
if nlcs_id and nlcs_id not in seen_ids:
|
||||
seen_ids.add(nlcs_id)
|
||||
rivers.append(_parse_river(attrs))
|
||||
|
||||
return WildRiverResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
rivers=rivers,
|
||||
)
|
||||
166
src/mcblmplss/mixins/wilderness.py
Normal file
166
src/mcblmplss/mixins/wilderness.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""
|
||||
Wilderness Area mixin for BLM MCP server.
|
||||
|
||||
Provides tools for querying Wilderness Areas and Wilderness Study Areas (WSAs)
|
||||
from BLM's National Landscape Conservation System (NLCS).
|
||||
"""
|
||||
|
||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcblmplss.client import BLMAPIError, blm_client
|
||||
|
||||
# Wilderness Areas and WSAs MapServer
|
||||
WILDERNESS_URL = (
|
||||
"https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_NLCS_WLD_WSA/MapServer"
|
||||
)
|
||||
LAYER_WILDERNESS = 0 # Designated Wilderness Areas
|
||||
LAYER_WSA = 1 # Wilderness Study Areas
|
||||
|
||||
|
||||
class WildernessArea(BaseModel):
|
||||
"""Individual wilderness or wilderness study area record."""
|
||||
|
||||
nlcs_id: str = Field(..., description="NLCS identifier")
|
||||
name: str = Field(..., description="Wilderness or WSA name")
|
||||
state: str = Field(..., description="Administrative state code")
|
||||
designation_date: str | None = Field(None, description="Date of designation")
|
||||
casefile: str | None = Field(None, description="Casefile number")
|
||||
is_designated: bool = Field(
|
||||
..., description="True if designated wilderness, False if WSA"
|
||||
)
|
||||
|
||||
|
||||
class WildernessResult(BaseModel):
|
||||
"""Result from wilderness query."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
areas: list[WildernessArea] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_wilderness(attrs: dict, layer_id: int) -> WildernessArea:
|
||||
"""Parse wilderness attributes from API response."""
|
||||
is_designated = layer_id == LAYER_WILDERNESS
|
||||
|
||||
# Handle null/empty values
|
||||
desig_date = attrs.get("DESIG_DATE")
|
||||
if desig_date and desig_date != "Null":
|
||||
desig_date = str(desig_date)
|
||||
else:
|
||||
desig_date = None
|
||||
|
||||
casefile = attrs.get("Casefile Number")
|
||||
if casefile and casefile != "Null":
|
||||
casefile = str(casefile)
|
||||
else:
|
||||
casefile = None
|
||||
|
||||
return WildernessArea(
|
||||
nlcs_id=attrs.get("NLCS_ID", ""),
|
||||
name=attrs.get("NLCS Name", "Unnamed"),
|
||||
state=attrs.get("Administrative State Code", ""),
|
||||
designation_date=desig_date,
|
||||
casefile=casefile,
|
||||
is_designated=is_designated,
|
||||
)
|
||||
|
||||
|
||||
class WildernessMixin(MCPMixin):
|
||||
"""MCP tools for wilderness area queries."""
|
||||
|
||||
@mcp_tool(
|
||||
name="get_wilderness_area",
|
||||
description="Check if a location is in a Wilderness Area or Wilderness Study Area (WSA).",
|
||||
)
|
||||
async def get_wilderness_area(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> str:
|
||||
"""
|
||||
Determine if coordinates fall within a Wilderness Area or WSA.
|
||||
|
||||
Wilderness Areas are congressionally designated lands with the highest
|
||||
level of protection. WSAs are areas under study for potential designation.
|
||||
|
||||
Returns wilderness/WSA name and designation status.
|
||||
"""
|
||||
result = await self._query_wilderness(latitude, longitude)
|
||||
|
||||
if result.error:
|
||||
return f"Error: {result.error}"
|
||||
|
||||
if not result.areas:
|
||||
return "This location is not within a Wilderness Area or Wilderness Study Area."
|
||||
|
||||
lines = []
|
||||
for area in result.areas:
|
||||
status = "Designated Wilderness" if area.is_designated else "WSA"
|
||||
lines.append(f"{area.name}")
|
||||
lines.append(f" Status: {status}")
|
||||
lines.append(f" State: {area.state}")
|
||||
lines.append(f" NLCS ID: {area.nlcs_id}")
|
||||
if area.designation_date:
|
||||
lines.append(f" Designated: {area.designation_date}")
|
||||
if area.casefile:
|
||||
lines.append(f" Casefile: {area.casefile}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@mcp_tool(
|
||||
name="get_wilderness_area_details",
|
||||
description="Get detailed wilderness/WSA data as structured objects.",
|
||||
)
|
||||
async def get_wilderness_area_details(
|
||||
self,
|
||||
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||
) -> WildernessResult:
|
||||
"""Get full wilderness area data including NLCS IDs and designation status."""
|
||||
return await self._query_wilderness(latitude, longitude)
|
||||
|
||||
async def _query_wilderness(
|
||||
self, latitude: float, longitude: float
|
||||
) -> WildernessResult:
|
||||
"""Query BLM Wilderness/WSA API for location."""
|
||||
layers = f"all:{LAYER_WILDERNESS},{LAYER_WSA}"
|
||||
|
||||
try:
|
||||
results = await blm_client.identify(
|
||||
WILDERNESS_URL, latitude, longitude, layers
|
||||
)
|
||||
except BLMAPIError as e:
|
||||
return WildernessResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if not results:
|
||||
return WildernessResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
areas=[],
|
||||
)
|
||||
|
||||
areas = []
|
||||
seen_ids = set()
|
||||
|
||||
for result in results:
|
||||
layer_id = result.get("layerId")
|
||||
attrs = result.get("attributes", {})
|
||||
|
||||
# Dedupe by NLCS ID
|
||||
nlcs_id = attrs.get("NLCS_ID", "")
|
||||
if nlcs_id and nlcs_id not in seen_ids:
|
||||
seen_ids.add(nlcs_id)
|
||||
areas.append(_parse_wilderness(attrs, layer_id))
|
||||
|
||||
return WildernessResult(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
areas=areas,
|
||||
)
|
||||
@ -5,13 +5,29 @@ Provides tools for:
|
||||
- PLSS (Public Land Survey System) - Section, Township, Range
|
||||
- Surface Management Agency - Who manages the land
|
||||
- Mining Claims - Active and closed mining claims
|
||||
- Grazing Allotments - Livestock grazing areas
|
||||
- Wild Horses & Burros - Herd Management Areas
|
||||
- Recreation Sites - Campgrounds, trailheads, facilities
|
||||
- Wilderness & WSAs - Protected wilderness areas
|
||||
- Wild & Scenic Rivers - Designated river segments
|
||||
- ACECs - Areas of Critical Environmental Concern
|
||||
|
||||
All data is queried from official BLM ArcGIS REST services.
|
||||
"""
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcblmplss.mixins import MiningClaimsMixin, PLSSMixin, SurfaceManagementMixin
|
||||
from mcblmplss.mixins import (
|
||||
ACECMixin,
|
||||
GrazingMixin,
|
||||
MiningClaimsMixin,
|
||||
PLSSMixin,
|
||||
RecreationMixin,
|
||||
SurfaceManagementMixin,
|
||||
WildernessMixin,
|
||||
WildHorseMixin,
|
||||
WildRiversMixin,
|
||||
)
|
||||
|
||||
# Initialize FastMCP server
|
||||
mcp = FastMCP(
|
||||
@ -36,20 +52,50 @@ mcp = FastMCP(
|
||||
- Placer Claims: Loose deposits (gold in streams)
|
||||
- Mill Sites, Tunnel Sites
|
||||
|
||||
**Grazing Allotments**
|
||||
BLM-managed areas for livestock grazing permits.
|
||||
|
||||
**Wild Horses & Burros**
|
||||
Herd Management Areas (HMAs) with population data and AML targets.
|
||||
|
||||
**Recreation Sites**
|
||||
Campgrounds, trailheads, day use areas, boat launches, and facilities.
|
||||
|
||||
**Wilderness & WSAs**
|
||||
Designated Wilderness Areas and Wilderness Study Areas (WSAs).
|
||||
|
||||
**Wild & Scenic Rivers**
|
||||
Federally designated Wild, Scenic, or Recreational river segments.
|
||||
|
||||
**ACECs (Areas of Critical Environmental Concern)**
|
||||
BLM areas requiring special management for natural, cultural, or scenic values.
|
||||
|
||||
Coverage: 30 western/midwestern states. Eastern seaboard and Texas
|
||||
use different survey systems.
|
||||
""",
|
||||
)
|
||||
|
||||
# Create mixin instances and register tools
|
||||
# Create mixin instances
|
||||
plss_mixin = PLSSMixin()
|
||||
sma_mixin = SurfaceManagementMixin()
|
||||
mining_mixin = MiningClaimsMixin()
|
||||
grazing_mixin = GrazingMixin()
|
||||
wild_horse_mixin = WildHorseMixin()
|
||||
recreation_mixin = RecreationMixin()
|
||||
wilderness_mixin = WildernessMixin()
|
||||
wild_rivers_mixin = WildRiversMixin()
|
||||
acec_mixin = ACECMixin()
|
||||
|
||||
# Register all mixin tools with the server
|
||||
plss_mixin.register_all(mcp)
|
||||
sma_mixin.register_all(mcp)
|
||||
mining_mixin.register_all(mcp)
|
||||
grazing_mixin.register_all(mcp)
|
||||
wild_horse_mixin.register_all(mcp)
|
||||
recreation_mixin.register_all(mcp)
|
||||
wilderness_mixin.register_all(mcp)
|
||||
wild_rivers_mixin.register_all(mcp)
|
||||
acec_mixin.register_all(mcp)
|
||||
|
||||
|
||||
def main():
|
||||
@ -62,7 +108,8 @@ def main():
|
||||
package_version = "dev"
|
||||
|
||||
print(f"🗺️ mcblmplss v{package_version} - BLM Land Data Server")
|
||||
print("📍 PLSS | 🏛️ Surface Management | ⛏️ Mining Claims")
|
||||
print("📍 PLSS | 🏛️ Land Manager | ⛏️ Mining | 🐄 Grazing")
|
||||
print("🐴 Wild Horses | ⛺ Recreation | 🏔️ Wilderness | 🏞️ Rivers | 🌿 ACEC")
|
||||
mcp.run()
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user