From b96fe3ea68576a338b7d009d9d9f298b1826cad0 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 25 Jan 2026 12:28:29 -0700 Subject: [PATCH] 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 --- README.md | 163 ++++++++++++++------ pyproject.toml | 10 +- src/mcblmplss/mixins/__init__.py | 18 ++- src/mcblmplss/mixins/acec.py | 176 ++++++++++++++++++++++ src/mcblmplss/mixins/grazing.py | 148 ++++++++++++++++++ src/mcblmplss/mixins/recreation.py | 182 ++++++++++++++++++++++ src/mcblmplss/mixins/wild_horses.py | 225 ++++++++++++++++++++++++++++ src/mcblmplss/mixins/wild_rivers.py | 167 +++++++++++++++++++++ src/mcblmplss/mixins/wilderness.py | 166 ++++++++++++++++++++ src/mcblmplss/server.py | 53 ++++++- 10 files changed, 1255 insertions(+), 53 deletions(-) create mode 100644 src/mcblmplss/mixins/acec.py create mode 100644 src/mcblmplss/mixins/grazing.py create mode 100644 src/mcblmplss/mixins/recreation.py create mode 100644 src/mcblmplss/mixins/wild_horses.py create mode 100644 src/mcblmplss/mixins/wild_rivers.py create mode 100644 src/mcblmplss/mixins/wilderness.py diff --git a/README.md b/README.md index 14c15e2..aacd9af 100644 --- a/README.md +++ b/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 ![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. diff --git a/pyproject.toml b/pyproject.toml index 0e550d2..c7e9c52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/mcblmplss/mixins/__init__.py b/src/mcblmplss/mixins/__init__.py index 91fba59..a069f1e 100644 --- a/src/mcblmplss/mixins/__init__.py +++ b/src/mcblmplss/mixins/__init__.py @@ -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", +] diff --git a/src/mcblmplss/mixins/acec.py b/src/mcblmplss/mixins/acec.py new file mode 100644 index 0000000..a8c352b --- /dev/null +++ b/src/mcblmplss/mixins/acec.py @@ -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, + ) diff --git a/src/mcblmplss/mixins/grazing.py b/src/mcblmplss/mixins/grazing.py new file mode 100644 index 0000000..0bce08d --- /dev/null +++ b/src/mcblmplss/mixins/grazing.py @@ -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.", + ) diff --git a/src/mcblmplss/mixins/recreation.py b/src/mcblmplss/mixins/recreation.py new file mode 100644 index 0000000..96af13b --- /dev/null +++ b/src/mcblmplss/mixins/recreation.py @@ -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), + ) diff --git a/src/mcblmplss/mixins/wild_horses.py b/src/mcblmplss/mixins/wild_horses.py new file mode 100644 index 0000000..91fda28 --- /dev/null +++ b/src/mcblmplss/mixins/wild_horses.py @@ -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, + ) diff --git a/src/mcblmplss/mixins/wild_rivers.py b/src/mcblmplss/mixins/wild_rivers.py new file mode 100644 index 0000000..bd06a54 --- /dev/null +++ b/src/mcblmplss/mixins/wild_rivers.py @@ -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, + ) diff --git a/src/mcblmplss/mixins/wilderness.py b/src/mcblmplss/mixins/wilderness.py new file mode 100644 index 0000000..5fd718c --- /dev/null +++ b/src/mcblmplss/mixins/wilderness.py @@ -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, + ) diff --git a/src/mcblmplss/server.py b/src/mcblmplss/server.py index 9dcf1a8..5746a8a 100644 --- a/src/mcblmplss/server.py +++ b/src/mcblmplss/server.py @@ -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()