Initial commit: FastMCP server for BLM PLSS queries
Query Section/Township/Range from lat/long coordinates using the BLM National Cadastral ArcGIS REST service. Tools: - get_plss_location: human-readable PLSS description - get_plss_details: structured data with full metadata
This commit is contained in:
commit
b1617104ee
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# uv
|
||||||
|
.python-version
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
32
README.md
Normal file
32
README.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# mcblmplss
|
||||||
|
|
||||||
|
FastMCP server for querying BLM Public Land Survey System (PLSS) data by coordinates.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install and run
|
||||||
|
uvx mcblmplss
|
||||||
|
|
||||||
|
# Add to Claude Code
|
||||||
|
claude mcp add plss "uvx mcblmplss"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- **get_plss_location** - Get Section/Township/Range for lat/long coordinates
|
||||||
|
- **get_plss_details** - Get full structured PLSS data
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```
|
||||||
|
> get_plss_location(latitude=40.0, longitude=-105.0)
|
||||||
|
|
||||||
|
Section 10, Township 1N, Range 68W, 6th Meridian
|
||||||
|
State: CO
|
||||||
|
PLSS ID: CO060010N0680W0SN100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
PLSS data is available for 30 states where federal land surveys were conducted (western/midwestern US). Not available for eastern seaboard states or Texas.
|
||||||
38
pyproject.toml
Normal file
38
pyproject.toml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
[project]
|
||||||
|
name = "mcblmplss"
|
||||||
|
version = "2024.12.03"
|
||||||
|
description = "FastMCP server for querying BLM Public Land Survey System (PLSS) data by coordinates"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
||||||
|
]
|
||||||
|
keywords = ["mcp", "fastmcp", "blm", "plss", "cadastral", "land-survey", "gis"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Scientific/Engineering :: GIS",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.13.2",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcblmplss = "mcblmplss:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mcblmplss"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP"]
|
||||||
10
src/mcblmplss/__init__.py
Normal file
10
src/mcblmplss/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
mcblmplss - FastMCP server for BLM Public Land Survey System queries.
|
||||||
|
|
||||||
|
Query PLSS data (Township, Range, Section) from coordinates using the
|
||||||
|
BLM National Cadastral API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from mcblmplss.server import main, mcp
|
||||||
|
|
||||||
|
__all__ = ["main", "mcp"]
|
||||||
242
src/mcblmplss/server.py
Normal file
242
src/mcblmplss/server.py
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
FastMCP server for querying BLM Public Land Survey System (PLSS) data.
|
||||||
|
|
||||||
|
Converts lat/long coordinates to Section, Township, Range using the
|
||||||
|
BLM National Cadastral ArcGIS REST service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# BLM Cadastral MapServer endpoint
|
||||||
|
BLM_PLSS_URL = "https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer"
|
||||||
|
|
||||||
|
# Layer IDs in the MapServer
|
||||||
|
LAYER_TOWNSHIP = 1
|
||||||
|
LAYER_SECTION = 2
|
||||||
|
|
||||||
|
|
||||||
|
class PLSSLocation(BaseModel):
|
||||||
|
"""Public Land Survey System location description."""
|
||||||
|
|
||||||
|
section: str | None = Field(None, description="Section number (1-36)")
|
||||||
|
township: str = Field(..., description="Township number with direction (e.g., '4N')")
|
||||||
|
range: str = Field(..., description="Range number with direction (e.g., '6E')")
|
||||||
|
principal_meridian: str = Field(..., description="Principal meridian name")
|
||||||
|
state: str = Field(..., description="State abbreviation")
|
||||||
|
label: str = Field(..., description="Human-readable PLSS label")
|
||||||
|
plss_id: str = Field(..., description="Unique PLSS identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class PLSSQueryResult(BaseModel):
|
||||||
|
"""Result from a PLSS coordinate query."""
|
||||||
|
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
township: PLSSLocation | None = None
|
||||||
|
section: PLSSLocation | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_township_attributes(attrs: dict) -> PLSSLocation:
|
||||||
|
"""Parse township attributes from BLM API response."""
|
||||||
|
township_num = attrs.get("TWNSHPNO", "").lstrip("0") or "0"
|
||||||
|
township_dir = attrs.get("TWNSHPDIR", "")
|
||||||
|
range_num = attrs.get("RANGENO", "").lstrip("0") or "0"
|
||||||
|
range_dir = attrs.get("RANGEDIR", "")
|
||||||
|
|
||||||
|
return PLSSLocation(
|
||||||
|
section=None,
|
||||||
|
township=f"{township_num}{township_dir}",
|
||||||
|
range=f"{range_num}{range_dir}",
|
||||||
|
principal_meridian=attrs.get("PRINMER", "Unknown"),
|
||||||
|
state=attrs.get("STATEABBR", ""),
|
||||||
|
label=attrs.get("TWNSHPLAB", ""),
|
||||||
|
plss_id=attrs.get("PLSSID", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_section_attributes(attrs: dict, township: PLSSLocation | None) -> PLSSLocation:
|
||||||
|
"""Parse section attributes from BLM API response.
|
||||||
|
|
||||||
|
Note: Section layer uses human-readable field names like "First Division Number"
|
||||||
|
while Township layer uses abbreviated names like "TWNSHPNO".
|
||||||
|
"""
|
||||||
|
# Try both field name formats (API returns friendly names for section layer)
|
||||||
|
section_num = (
|
||||||
|
attrs.get("First Division Number", "") or attrs.get("FRSTDIVNO", "")
|
||||||
|
).lstrip("0") or "0"
|
||||||
|
|
||||||
|
# Get the division ID (also uses friendly name)
|
||||||
|
division_id = attrs.get("First Division Identifier", "") or attrs.get("FRSTDIVID", "")
|
||||||
|
|
||||||
|
if township:
|
||||||
|
return PLSSLocation(
|
||||||
|
section=section_num,
|
||||||
|
township=township.township,
|
||||||
|
range=township.range,
|
||||||
|
principal_meridian=township.principal_meridian,
|
||||||
|
state=township.state,
|
||||||
|
label=f"Section {section_num}, {township.label}",
|
||||||
|
plss_id=division_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: extract state from Township Identifier if no township data
|
||||||
|
township_id = attrs.get("Township Identifier", "") or attrs.get("PLSSID", "")
|
||||||
|
return PLSSLocation(
|
||||||
|
section=section_num,
|
||||||
|
township="Unknown",
|
||||||
|
range="Unknown",
|
||||||
|
principal_meridian="Unknown",
|
||||||
|
state=township_id[:2] if len(township_id) >= 2 else "",
|
||||||
|
label=f"Section {section_num}",
|
||||||
|
plss_id=division_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def query_plss(latitude: float, longitude: float) -> PLSSQueryResult:
|
||||||
|
"""
|
||||||
|
Query BLM PLSS API for location at given coordinates.
|
||||||
|
|
||||||
|
Uses the ArcGIS REST identify operation to find township and section
|
||||||
|
features that contain the specified point.
|
||||||
|
"""
|
||||||
|
# Build identify request parameters
|
||||||
|
# Note: BLM service accepts WGS84 (EPSG:4326) coordinates directly
|
||||||
|
params = {
|
||||||
|
"f": "json",
|
||||||
|
"geometry": f"{longitude},{latitude}",
|
||||||
|
"geometryType": "esriGeometryPoint",
|
||||||
|
"sr": "4326", # WGS84
|
||||||
|
"layers": f"all:{LAYER_TOWNSHIP},{LAYER_SECTION}",
|
||||||
|
"tolerance": "1",
|
||||||
|
"mapExtent": f"{longitude-1},{latitude-1},{longitude+1},{latitude+1}",
|
||||||
|
"imageDisplay": "100,100,96",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(f"{BLM_PLSS_URL}/identify", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return PLSSQueryResult(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
error="No PLSS data found for this location. The point may be outside surveyed areas.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse results by layer
|
||||||
|
township_data: PLSSLocation | None = None
|
||||||
|
section_data: PLSSLocation | None = None
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
layer_id = result.get("layerId")
|
||||||
|
attrs = result.get("attributes", {})
|
||||||
|
|
||||||
|
if layer_id == LAYER_TOWNSHIP and township_data is None:
|
||||||
|
township_data = parse_township_attributes(attrs)
|
||||||
|
elif layer_id == LAYER_SECTION and section_data is None:
|
||||||
|
section_data = parse_section_attributes(attrs, township_data)
|
||||||
|
|
||||||
|
return PLSSQueryResult(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
township=township_data,
|
||||||
|
section=section_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize FastMCP server
|
||||||
|
mcp = FastMCP(
|
||||||
|
name="mcblmplss",
|
||||||
|
instructions="""
|
||||||
|
Query the U.S. Public Land Survey System (PLSS) using coordinates.
|
||||||
|
|
||||||
|
The PLSS divides land into:
|
||||||
|
- Townships: 6x6 mile squares identified by Township (N/S) and Range (E/W)
|
||||||
|
- Sections: 1 square mile (640 acres), numbered 1-36 within townships
|
||||||
|
- Principal Meridians: Reference lines for the survey system
|
||||||
|
|
||||||
|
This server queries the BLM National Cadastral database for authoritative PLSS data.
|
||||||
|
Coverage: 30 western and midwestern states (not eastern seaboard or Texas).
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_plss_location(
|
||||||
|
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||||
|
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Get the Public Land Survey System (PLSS) location for coordinates.
|
||||||
|
|
||||||
|
Returns Section, Township, Range, and Principal Meridian for the given
|
||||||
|
latitude/longitude. Example: "Section 12, Township 4N, Range 6E, 6th PM"
|
||||||
|
|
||||||
|
Note: PLSS coverage is limited to 30 states where federal land surveys
|
||||||
|
were conducted. Eastern states and Texas use different systems.
|
||||||
|
"""
|
||||||
|
result = await query_plss(latitude, longitude)
|
||||||
|
|
||||||
|
if result.error:
|
||||||
|
return f"Error: {result.error}"
|
||||||
|
|
||||||
|
if result.section:
|
||||||
|
return (
|
||||||
|
f"Section {result.section.section}, "
|
||||||
|
f"Township {result.section.township}, "
|
||||||
|
f"Range {result.section.range}, "
|
||||||
|
f"{result.section.principal_meridian}\n"
|
||||||
|
f"State: {result.section.state}\n"
|
||||||
|
f"PLSS ID: {result.section.plss_id}"
|
||||||
|
)
|
||||||
|
elif result.township:
|
||||||
|
return (
|
||||||
|
f"Township {result.township.township}, "
|
||||||
|
f"Range {result.township.range}, "
|
||||||
|
f"{result.township.principal_meridian}\n"
|
||||||
|
f"State: {result.township.state}\n"
|
||||||
|
f"(Section data not available for this location)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return "Unable to determine PLSS location for these coordinates."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_plss_details(
|
||||||
|
latitude: float = Field(description="Latitude in decimal degrees (WGS84)"),
|
||||||
|
longitude: float = Field(description="Longitude in decimal degrees (WGS84)"),
|
||||||
|
) -> PLSSQueryResult:
|
||||||
|
"""
|
||||||
|
Get detailed PLSS information as structured data.
|
||||||
|
|
||||||
|
Returns a PLSSQueryResult object with full township and section details
|
||||||
|
including PLSS IDs, principal meridian, and state information.
|
||||||
|
|
||||||
|
Useful when you need programmatic access to all PLSS fields.
|
||||||
|
"""
|
||||||
|
return await query_plss(latitude, longitude)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point for the mcblmplss MCP server."""
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
package_version = version("mcblmplss")
|
||||||
|
except Exception:
|
||||||
|
package_version = "dev"
|
||||||
|
|
||||||
|
print(f"🗺️ mcblmplss v{package_version} - BLM PLSS Query Server")
|
||||||
|
print("📍 Query Section/Township/Range from coordinates")
|
||||||
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user