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:
Ryan Malloy 2026-01-25 12:28:29 -07:00
parent d8d160efdc
commit b96fe3ea68
10 changed files with 1255 additions and 53 deletions

163
README.md
View File

@ -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
![PLSS Coverage](https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Public_Land_Survey_System.png/800px-Public_Land_Survey_System.png)
**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.

View File

@ -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",

View File

@ -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",
]

View 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,
)

View 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.",
)

View 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),
)

View 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,
)

View 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,
)

View 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,
)

View File

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