From d8d160efdcc55409c4ab66772b7f6635048e68fc Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 25 Jan 2026 12:12:56 -0700 Subject: [PATCH] Prep for PyPI release: error handling, better docs, MIT license - Add BLMAPIError exception with user-friendly error messages - Rename is_public to allows_public_access with correct logic (BIA/DOD are federal but restricted, not public access) - Add MIT license - Expand pyproject.toml with URLs, keywords, classifiers - Rewrite README with badges, use cases, coverage map, examples --- LICENSE | 21 ++++ README.md | 126 ++++++++++++++------- pyproject.toml | 24 +++- src/mcblmplss/client.py | 63 ++++++++--- src/mcblmplss/mixins/__init__.py | 2 +- src/mcblmplss/mixins/mining_claims.py | 23 ++-- src/mcblmplss/mixins/plss.py | 25 ++-- src/mcblmplss/mixins/surface_management.py | 47 +++++--- src/mcblmplss/server.py | 3 +- 9 files changed, 238 insertions(+), 96 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f4b96b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Ryan Malloy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9e8fc1e..14c15e2 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,84 @@ # mcblmplss -FastMCP server for querying BLM (Bureau of Land Management) land data by coordinates. +[![PyPI version](https://img.shields.io/pypi/v/mcblmplss.svg)](https://pypi.org/project/mcblmplss/) +[![Python versions](https://img.shields.io/pypi/pyversions/mcblmplss.svg)](https://pypi.org/project/mcblmplss/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -## Features +**MCP server for querying U.S. public land data by coordinates.** -- **PLSS** - Public Land Survey System (Section, Township, Range) -- **Surface Management** - Who manages the land (BLM, Forest Service, NPS, Private, etc.) -- **Mining Claims** - Active and closed mining claims from MLRS database +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 + +## When would I use this? + +| Use Case | What you get | +|----------|--------------| +| **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 | ## Installation ```bash -# Run directly with uvx -uvx mcblmplss +pip install mcblmplss +``` -# Add to Claude Code -claude mcp add blm-land "uvx mcblmplss" +Or run directly without installing: + +```bash +uvx mcblmplss +``` + +### Add to Claude Code + +```bash +claude mcp add blm "uvx mcblmplss" ``` ## Tools -### PLSS (Public Land Survey System) +### `get_plss_location` -| Tool | Description | -|------|-------------| -| `get_plss_location` | Get Section/Township/Range as human-readable text | -| `get_plss_details` | Get full PLSS data as structured object | +Convert coordinates to Section/Township/Range. ``` -> get_plss_location(40.0, -105.0) +> get_plss_location(latitude=40.0, longitude=-105.0) Section 9, Township 1N, Range 68W, 6th Meridian State: CO PLSS ID: CO060010S0680W0SN090 ``` -### Surface Management Agency +### `get_land_manager` -| Tool | Description | -|------|-------------| -| `get_land_manager` | Determine who manages the land | -| `get_land_manager_details` | Get full SMA data as structured object | +Find out who manages the land (and whether you can access it). ``` -> get_land_manager(38.5, -110.5) +> get_land_manager(latitude=38.5, longitude=-110.5) Bureau of Land Management (BLM) - Department of the Interior Unit: Bureau of Land Management State: UT -Status: Federal, Public land +Status: Federal, Public access ``` -### Mining Claims +``` +> get_land_manager(latitude=40.0, longitude=-105.0) -| Tool | Description | -|------|-------------| -| `get_mining_claims` | Find mining claims at/near location | -| `get_mining_claims_details` | Get full claim data as structured objects | +Private (PVT) +State: CO +``` + +### `get_mining_claims` + +Find active mining claims at a location. ``` -> get_mining_claims(39.5, -117.0) +> get_mining_claims(latitude=39.5, longitude=-117.0) Found 42 mining claim(s): @@ -68,31 +87,52 @@ MAGA #6 Type: Lode Claim Status: Active Acres: 20.66 + +MS 2 + Serial: NV105223666 + Type: Lode Claim + Status: Active + Acres: 20.66 ... ``` ## Coverage -Data available for **30 western/midwestern states** where federal land surveys were conducted. Not available for eastern seaboard states or Texas (different survey systems). +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." ## Data Sources -All data queried from official BLM ArcGIS REST services: +All data comes from official BLM ArcGIS REST services: -- [BLM National PLSS](https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer) -- [Surface Management Agency](https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_SMA_LimitedScale/MapServer) -- [Mining Claims (MLRS)](https://gis.blm.gov/nlsdb/rest/services/Mining_Claims/MiningClaims/MapServer) +| Data | Source | Update Frequency | +|------|--------|------------------| +| 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 | -## Architecture +**Disclaimer:** This data is for informational purposes only. For legal land descriptions, consult official BLM records or a licensed surveyor. -Uses FastMCP's mixin pattern for composable tool modules: +## Development +```bash +git clone https://git.supported.systems/MCP/mcblmplss.git +cd mcblmplss +uv sync +uv run mcblmplss ``` -src/mcblmplss/ -├── server.py # Main FastMCP server -├── client.py # Shared BLM API client -└── mixins/ - ├── plss.py # PLSS tools - ├── surface_management.py # SMA tools - └── mining_claims.py # Mining claims tools -``` + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml index a8ec0ac..0e550d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,33 @@ [project] name = "mcblmplss" version = "2024.12.03" -description = "FastMCP server for querying BLM Public Land Survey System (PLSS) data by coordinates" +description = "MCP server for querying BLM land data: PLSS coordinates, surface management agency, and mining claims" readme = "README.md" +license = "MIT" requires-python = ">=3.11" authors = [ {name = "Ryan Malloy", email = "ryan@supported.systems"} ] -keywords = ["mcp", "fastmcp", "blm", "plss", "cadastral", "land-survey", "gis"] +keywords = [ + "mcp", + "fastmcp", + "blm", + "plss", + "cadastral", + "land-survey", + "gis", + "mining-claims", + "surface-management", + "land-ownership", + "public-lands", +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: GIS", ] dependencies = [ @@ -20,6 +35,11 @@ dependencies = [ "httpx>=0.28.1", ] +[project.urls] +Homepage = "https://git.supported.systems/MCP/mcblmplss" +Repository = "https://git.supported.systems/MCP/mcblmplss" +Issues = "https://git.supported.systems/MCP/mcblmplss/issues" + [project.scripts] mcblmplss = "mcblmplss:main" diff --git a/src/mcblmplss/client.py b/src/mcblmplss/client.py index 299071a..2abcc93 100644 --- a/src/mcblmplss/client.py +++ b/src/mcblmplss/client.py @@ -4,14 +4,21 @@ Shared HTTP client for BLM ArcGIS REST API queries. Provides common identify/query operations against BLM MapServer endpoints. """ -import httpx from typing import Any +import httpx + + +class BLMAPIError(Exception): + """Error communicating with BLM ArcGIS services.""" + + pass + class BLMClient: """Async HTTP client for BLM ArcGIS REST services.""" - def __init__(self, timeout: float = 30.0): + def __init__(self, timeout: float = 60.0): self.timeout = timeout async def identify( @@ -36,6 +43,9 @@ class BLMClient: Returns: List of result dictionaries with layerId and attributes + + Raises: + BLMAPIError: If the API request fails """ params = { "f": "json", @@ -44,15 +54,27 @@ class BLMClient: "sr": "4326", "layers": layers, "tolerance": str(tolerance), - "mapExtent": f"{longitude-1},{latitude-1},{longitude+1},{latitude+1}", + "mapExtent": f"{longitude - 1},{latitude - 1},{longitude + 1},{latitude + 1}", "imageDisplay": "100,100,96", "returnGeometry": "true" if return_geometry else "false", } - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.get(f"{base_url}/identify", params=params) - response.raise_for_status() - data = response.json() + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(f"{base_url}/identify", params=params) + response.raise_for_status() + data = response.json() + except httpx.TimeoutException: + raise BLMAPIError("BLM API request timed out. The service may be slow or unavailable.") + except httpx.HTTPStatusError as e: + raise BLMAPIError(f"BLM API returned error {e.response.status_code}") + except httpx.RequestError as e: + raise BLMAPIError(f"Failed to connect to BLM API: {e}") + + # Check for ArcGIS error response + if "error" in data: + error_msg = data["error"].get("message", "Unknown error") + raise BLMAPIError(f"BLM API error: {error_msg}") return data.get("results", []) @@ -82,6 +104,9 @@ class BLMClient: Returns: List of feature attribute dictionaries + + Raises: + BLMAPIError: If the API request fails """ params = { "f": "json", @@ -98,16 +123,26 @@ class BLMClient: params["spatialRel"] = "esriSpatialRelIntersects" params["inSR"] = "4326" - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.get( - f"{base_url}/{layer_id}/query", params=params - ) - response.raise_for_status() - data = response.json() + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(f"{base_url}/{layer_id}/query", params=params) + response.raise_for_status() + data = response.json() + except httpx.TimeoutException: + raise BLMAPIError("BLM API request timed out. The service may be slow or unavailable.") + except httpx.HTTPStatusError as e: + raise BLMAPIError(f"BLM API returned error {e.response.status_code}") + except httpx.RequestError as e: + raise BLMAPIError(f"Failed to connect to BLM API: {e}") + + # Check for ArcGIS error response + if "error" in data: + error_msg = data["error"].get("message", "Unknown error") + raise BLMAPIError(f"BLM API error: {error_msg}") features = data.get("features", []) return [f.get("attributes", {}) for f in features] -# Shared client instance - longer timeout for slower services like mining claims +# Shared client instance blm_client = BLMClient(timeout=60.0) diff --git a/src/mcblmplss/mixins/__init__.py b/src/mcblmplss/mixins/__init__.py index c502b07..91fba59 100644 --- a/src/mcblmplss/mixins/__init__.py +++ b/src/mcblmplss/mixins/__init__.py @@ -4,8 +4,8 @@ MCP Mixins for BLM data services. Each mixin provides tools for a specific BLM data domain. """ +from mcblmplss.mixins.mining_claims import MiningClaimsMixin from mcblmplss.mixins.plss import PLSSMixin from mcblmplss.mixins.surface_management import SurfaceManagementMixin -from mcblmplss.mixins.mining_claims import MiningClaimsMixin __all__ = ["PLSSMixin", "SurfaceManagementMixin", "MiningClaimsMixin"] diff --git a/src/mcblmplss/mixins/mining_claims.py b/src/mcblmplss/mixins/mining_claims.py index 84e80a7..b453f17 100644 --- a/src/mcblmplss/mixins/mining_claims.py +++ b/src/mcblmplss/mixins/mining_claims.py @@ -4,10 +4,10 @@ Mining Claims mixin for BLM MCP server. Provides tools for querying active and closed mining claims from BLM's MLRS database. """ -from pydantic import BaseModel, Field from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool +from pydantic import BaseModel, Field -from mcblmplss.client import blm_client +from mcblmplss.client import BLMAPIError, blm_client # Mining Claims MapServer (separate server from main BLM arcgis) MINING_URL = "https://gis.blm.gov/nlsdb/rest/services/Mining_Claims/MiningClaims/MapServer" @@ -71,7 +71,7 @@ class MiningClaimsMixin(MCPMixin): @mcp_tool( name="get_mining_claims", - description="Find active mining claims at or near coordinates. Returns claim names, serial numbers, and types.", + description="Find active mining claims at or near coordinates.", ) async def get_mining_claims( self, @@ -125,9 +125,7 @@ class MiningClaimsMixin(MCPMixin): self, latitude: float = Field(description="Latitude in decimal degrees (WGS84)"), longitude: float = Field(description="Longitude in decimal degrees (WGS84)"), - include_closed: bool = Field( - default=False, description="Include closed/void claims" - ), + include_closed: bool = Field(default=False, description="Include closed/void claims"), tolerance: int = Field(default=10, description="Search radius in pixels"), ) -> MiningClaimsResult: """Get full mining claims data including legal descriptions.""" @@ -145,9 +143,16 @@ class MiningClaimsMixin(MCPMixin): if include_closed: layers = f"all:{LAYER_ACTIVE},{LAYER_CLOSED}" - results = await blm_client.identify( - MINING_URL, latitude, longitude, layers, tolerance=tolerance - ) + try: + results = await blm_client.identify( + MINING_URL, latitude, longitude, layers, tolerance=tolerance + ) + except BLMAPIError as e: + return MiningClaimsResult( + latitude=latitude, + longitude=longitude, + error=str(e), + ) if not results: return MiningClaimsResult( diff --git a/src/mcblmplss/mixins/plss.py b/src/mcblmplss/mixins/plss.py index dbebd47..402127a 100644 --- a/src/mcblmplss/mixins/plss.py +++ b/src/mcblmplss/mixins/plss.py @@ -4,10 +4,10 @@ PLSS (Public Land Survey System) mixin for BLM MCP server. Provides tools for querying Section, Township, and Range from coordinates. """ -from pydantic import BaseModel, Field from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool +from pydantic import BaseModel, Field -from mcblmplss.client import blm_client +from mcblmplss.client import BLMAPIError, blm_client # BLM Cadastral MapServer PLSS_URL = "https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer" @@ -57,9 +57,9 @@ def _parse_township(attrs: dict) -> PLSSLocation: def _parse_section(attrs: dict, township: PLSSLocation | None) -> PLSSLocation: """Parse section attributes from API response.""" - sec_num = ( - attrs.get("First Division Number", "") or attrs.get("FRSTDIVNO", "") - ).lstrip("0") or "0" + sec_num = (attrs.get("First Division Number", "") or attrs.get("FRSTDIVNO", "")).lstrip( + "0" + ) or "0" div_id = attrs.get("First Division Identifier", "") or attrs.get("FRSTDIVID", "") if township: @@ -90,7 +90,7 @@ class PLSSMixin(MCPMixin): @mcp_tool( name="get_plss_location", - description="Get Section/Township/Range for coordinates. Returns the PLSS legal land description.", + description="Get Section/Township/Range for coordinates (PLSS legal description).", ) async def get_plss_location( self, @@ -143,9 +143,16 @@ class PLSSMixin(MCPMixin): async def _query_plss(self, latitude: float, longitude: float) -> PLSSResult: """Query BLM PLSS API for location.""" - results = await blm_client.identify( - PLSS_URL, latitude, longitude, f"all:{LAYER_TOWNSHIP},{LAYER_SECTION}" - ) + try: + results = await blm_client.identify( + PLSS_URL, latitude, longitude, f"all:{LAYER_TOWNSHIP},{LAYER_SECTION}" + ) + except BLMAPIError as e: + return PLSSResult( + latitude=latitude, + longitude=longitude, + error=str(e), + ) if not results: return PLSSResult( diff --git a/src/mcblmplss/mixins/surface_management.py b/src/mcblmplss/mixins/surface_management.py index 69660a3..96680ba 100644 --- a/src/mcblmplss/mixins/surface_management.py +++ b/src/mcblmplss/mixins/surface_management.py @@ -4,10 +4,10 @@ Surface Management Agency mixin for BLM MCP server. Provides tools for determining who manages federal lands (BLM, USFS, NPS, etc.). """ -from pydantic import BaseModel, Field from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool +from pydantic import BaseModel, Field -from mcblmplss.client import blm_client +from mcblmplss.client import BLMAPIError, blm_client # Surface Management Agency MapServer SMA_URL = "https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_SMA_LimitedScale/MapServer" @@ -52,7 +52,9 @@ class LandManager(BaseModel): admin_unit_type: str | None = Field(None, description="Administrative unit type") state: str = Field(..., description="State abbreviation") is_federal: bool = Field(..., description="Whether this is federal land") - is_public: bool = Field(..., description="Whether this is publicly accessible") + allows_public_access: bool = Field( + ..., description="Whether general public access is typically allowed" + ) class SurfaceManagementResult(BaseModel): @@ -69,23 +71,29 @@ def _parse_sma(attrs: dict) -> LandManager: agency_code = attrs.get("ADMIN_AGENCY_CODE", "UND") dept_code = attrs.get("ADMIN_DEPT_CODE", "") - # Determine if federal and public + # Federal departments (excluding private/state/local) federal_depts = {"DOI", "USDA", "DOD", "DOE"} - public_agencies = {"BLM", "USFS", "NPS", "FWS", "USBR"} - is_federal = dept_code in federal_depts - is_public = agency_code in public_agencies + + # Agencies that generally allow public recreation access + # Note: BIA (tribal) and DOD (military) are federal but restricted + public_access_agencies = {"BLM", "USFS", "NPS", "FWS", "USBR"} + allows_public_access = agency_code in public_access_agencies return LandManager( agency_code=agency_code, agency_name=AGENCY_NAMES.get(agency_code, agency_code), department_code=dept_code if dept_code and dept_code != "Null" else None, department_name=DEPT_NAMES.get(dept_code) if dept_code else None, - admin_unit_name=attrs.get("ADMIN_UNIT_NAME") if attrs.get("ADMIN_UNIT_NAME") != "Null" else None, - admin_unit_type=attrs.get("ADMIN_UNIT_TYPE") if attrs.get("ADMIN_UNIT_TYPE") != "Null" else None, + admin_unit_name=( + attrs.get("ADMIN_UNIT_NAME") if attrs.get("ADMIN_UNIT_NAME") != "Null" else None + ), + admin_unit_type=( + attrs.get("ADMIN_UNIT_TYPE") if attrs.get("ADMIN_UNIT_TYPE") != "Null" else None + ), state=attrs.get("ADMIN_ST", ""), is_federal=is_federal, - is_public=is_public, + allows_public_access=allows_public_access, ) @@ -94,7 +102,7 @@ class SurfaceManagementMixin(MCPMixin): @mcp_tool( name="get_land_manager", - description="Determine who manages the land at given coordinates (BLM, Forest Service, NPS, Private, etc.)", + description="Determine who manages the land (BLM, Forest Service, NPS, Private, etc.)", ) async def get_land_manager( self, @@ -132,10 +140,10 @@ class SurfaceManagementMixin(MCPMixin): status = [] if mgr.is_federal: status.append("Federal") - if mgr.is_public: - status.append("Public") + if mgr.allows_public_access: + status.append("Public access") if status: - lines.append(f"Status: {', '.join(status)} land") + lines.append(f"Status: {', '.join(status)}") return "\n".join(lines) @@ -155,9 +163,14 @@ class SurfaceManagementMixin(MCPMixin): async def _query_sma(self, latitude: float, longitude: float) -> SurfaceManagementResult: """Query BLM SMA API for location.""" - results = await blm_client.identify( - SMA_URL, latitude, longitude, f"all:{LAYER_SMA}" - ) + try: + results = await blm_client.identify(SMA_URL, latitude, longitude, f"all:{LAYER_SMA}") + except BLMAPIError as e: + return SurfaceManagementResult( + latitude=latitude, + longitude=longitude, + error=str(e), + ) if not results: return SurfaceManagementResult( diff --git a/src/mcblmplss/server.py b/src/mcblmplss/server.py index 2d375b9..9dcf1a8 100644 --- a/src/mcblmplss/server.py +++ b/src/mcblmplss/server.py @@ -11,7 +11,7 @@ All data is queried from official BLM ArcGIS REST services. from fastmcp import FastMCP -from mcblmplss.mixins import PLSSMixin, SurfaceManagementMixin, MiningClaimsMixin +from mcblmplss.mixins import MiningClaimsMixin, PLSSMixin, SurfaceManagementMixin # Initialize FastMCP server mcp = FastMCP( @@ -56,6 +56,7 @@ def main(): """Entry point for the mcblmplss MCP server.""" try: from importlib.metadata import version + package_version = version("mcblmplss") except Exception: package_version = "dev"