Skip to main content
Glama
municode-mcp-server.py22.1 kB
#!/usr/bin/env python3 """ Municode MCP Server Model Context Protocol server for accessing municipal code libraries from Municode. Provides tools to search, retrieve, and navigate municipal ordinances and codes. Based on the unofficial Municode API documentation at: https://sr.ht/~partytax/unofficial-municode-api-documentation/ """ import asyncio import json import logging from typing import Any, Dict, List, Optional from urllib.parse import quote, urljoin import httpx from mcp.server import Server from mcp.server.models import InitializationOptions from mcp.types import ( Resource, Tool, TextContent, ImageContent, EmbeddedResource, LoggingLevel, ServerCapabilities, ) from pydantic import BaseModel # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Municode API base URL MUNICODE_API_BASE = "https://api.municode.com" MUNICODE_LIBRARY_BASE = "https://library.municode.com" class MunicodeClient: """HTTP client for interacting with Municode API.""" def __init__(self): self.client = httpx.AsyncClient( timeout=30.0, headers={ "User-Agent": "MCP-Municode-Server/1.0", "Accept": "application/json", } ) async def close(self): """Close the HTTP client.""" await self.client.aclose() async def get_states(self, state_abbr: str) -> Dict[str, Any]: """Get state information by abbreviation.""" url = f"{MUNICODE_API_BASE}/States/abbr" params = {"stateAbbr": state_abbr} response = await self.client.get(url, params=params) response.raise_for_status() return response.json() async def get_clients_by_state(self, state_abbr: str) -> List[Dict[str, Any]]: """Get all Municode clients in a state.""" url = f"{MUNICODE_API_BASE}/Clients/stateAbbr" params = {"stateAbbr": state_abbr} response = await self.client.get(url, params=params) response.raise_for_status() return response.json() async def get_client_by_name(self, client_name: str, state_abbr: str) -> Dict[str, Any]: """Get client information by name and state.""" url = f"{MUNICODE_API_BASE}/Clients/name" params = {"clientName": client_name, "stateAbbr": state_abbr} response = await self.client.get(url, params=params) response.raise_for_status() return response.json() async def get_client_content(self, client_id: int) -> Dict[str, Any]: """Get all products a client subscribes to.""" url = f"{MUNICODE_API_BASE}/ClientContent/{client_id}" response = await self.client.get(url) response.raise_for_status() return response.json() async def get_product_by_name(self, client_id: int, product_name: str) -> Dict[str, Any]: """Get product information by client and product name.""" url = f"{MUNICODE_API_BASE}/Products/name" params = {"clientId": client_id, "productName": product_name} response = await self.client.get(url, params=params) response.raise_for_status() return response.json() async def get_latest_job(self, job_id: int) -> Dict[str, Any]: """Get the latest job information.""" url = f"{MUNICODE_API_BASE}/Jobs/latest/{job_id}" response = await self.client.get(url) response.raise_for_status() return response.json() async def get_toc_children(self, job_id: int, product_id: int, node_id: str = "10121") -> List[Dict[str, Any]]: """Get children of a node in the document tree.""" url = f"{MUNICODE_API_BASE}/codesToc/children" params = { "jobId": job_id, "productId": product_id, "nodeId": node_id } response = await self.client.get(url, params=params) response.raise_for_status() return response.json() async def get_codes_content(self, job_id: int, product_id: int, node_id: str) -> Dict[str, Any]: """Get content of a specific node in the document tree.""" url = f"{MUNICODE_API_BASE}/CodesContent" params = { "jobId": job_id, "productId": product_id, "nodeId": node_id } response = await self.client.get(url, params=params) response.raise_for_status() return response.json() async def search_munidocs( self, client_id: int, search_text: str, page_num: int = 1, page_size: int = 10, titles_only: bool = False, is_advanced: bool = False ) -> Dict[str, Any]: """Search MuniDocs for a word or phrase.""" url = f"{MUNICODE_API_BASE}/search" params = { "clientId": client_id, "searchText": search_text, "pageNum": page_num, "pageSize": page_size, "titlesOnly": titles_only, "isAdvanced": is_advanced, "isAutocomplete": False, "mode": "standard", "sort": 0, "fragmentSize": 200, "contentTypeId": "", "stateId": 0 } response = await self.client.get(url, params=params) response.raise_for_status() return response.json() # Initialize the MCP server server = Server("municode") municode_client = MunicodeClient() @server.list_tools() async def handle_list_tools() -> List[Tool]: """List available tools.""" return [ Tool( name="get_states_info", description="Get information about a US state by its abbreviation", inputSchema={ "type": "object", "properties": { "state_abbr": { "type": "string", "description": "Two-character US state abbreviation (e.g., 'VA', 'TX', 'CA')" } }, "required": ["state_abbr"] } ), Tool( name="list_municipalities", description="List all municipalities in a state that use Municode", inputSchema={ "type": "object", "properties": { "state_abbr": { "type": "string", "description": "Two-character US state abbreviation (e.g., 'VA', 'TX', 'CA')" } }, "required": ["state_abbr"] } ), Tool( name="get_municipality_info", description="Get detailed information about a specific municipality", inputSchema={ "type": "object", "properties": { "municipality_name": { "type": "string", "description": "Name of the city, county, or municipality" }, "state_abbr": { "type": "string", "description": "Two-character US state abbreviation" } }, "required": ["municipality_name", "state_abbr"] } ), Tool( name="get_code_structure", description="Get the table of contents structure for a municipality's code", inputSchema={ "type": "object", "properties": { "municipality_name": { "type": "string", "description": "Name of the city, county, or municipality" }, "state_abbr": { "type": "string", "description": "Two-character US state abbreviation" }, "node_id": { "type": "string", "description": "Optional specific node ID to get children for (defaults to root)", "default": "10121" } }, "required": ["municipality_name", "state_abbr"] } ), Tool( name="get_code_section", description="Get the content of a specific section of municipal code", inputSchema={ "type": "object", "properties": { "municipality_name": { "type": "string", "description": "Name of the city, county, or municipality" }, "state_abbr": { "type": "string", "description": "Two-character US state abbreviation" }, "node_id": { "type": "string", "description": "Node ID of the specific code section to retrieve" } }, "required": ["municipality_name", "state_abbr", "node_id"] } ), Tool( name="search_municipal_codes", description="Search through municipal codes and ordinances", inputSchema={ "type": "object", "properties": { "municipality_name": { "type": "string", "description": "Name of the city, county, or municipality" }, "state_abbr": { "type": "string", "description": "Two-character US state abbreviation" }, "search_query": { "type": "string", "description": "Text to search for in the municipal codes" }, "page_size": { "type": "integer", "description": "Number of results per page (default: 10)", "default": 10 }, "page_number": { "type": "integer", "description": "Page number to retrieve (default: 1)", "default": 1 }, "titles_only": { "type": "boolean", "description": "Search only in titles (default: false)", "default": False } }, "required": ["municipality_name", "state_abbr", "search_query"] } ), Tool( name="get_municipality_url", description="Get the URL for a municipality's code library page", inputSchema={ "type": "object", "properties": { "municipality_name": { "type": "string", "description": "Name of the city, county, or municipality" }, "state_abbr": { "type": "string", "description": "Two-character US state abbreviation" } }, "required": ["municipality_name", "state_abbr"] } ) ] @server.call_tool() async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: """Handle tool calls.""" try: if name == "get_states_info": state_abbr = arguments["state_abbr"].upper() result = await municode_client.get_states(state_abbr) return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "list_municipalities": state_abbr = arguments["state_abbr"].upper() clients = await municode_client.get_clients_by_state(state_abbr) # Format the output for better readability formatted_clients = [] for client in clients: formatted_clients.append({ "name": client.get("ClientName", "Unknown"), "id": client.get("ClientID"), "population_range": client.get("PopRangeId"), "classification": client.get("ClassificationId"), "website": client.get("Website"), "city": client.get("City"), "zip_code": client.get("ZipCode") }) return [TextContent( type="text", text=f"Found {len(formatted_clients)} municipalities in {state_abbr}:\n\n" + json.dumps(formatted_clients, indent=2) )] elif name == "get_municipality_info": municipality_name = arguments["municipality_name"] state_abbr = arguments["state_abbr"].upper() client_info = await municode_client.get_client_by_name(municipality_name, state_abbr) client_id = client_info.get("ClientID") if client_id: client_content = await municode_client.get_client_content(client_id) result = { "client_info": client_info, "available_products": client_content } else: result = {"client_info": client_info} return [TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "get_code_structure": municipality_name = arguments["municipality_name"] state_abbr = arguments["state_abbr"].upper() node_id = arguments.get("node_id", "10121") # First get client info client_info = await municode_client.get_client_by_name(municipality_name, state_abbr) client_id = client_info.get("ClientID") if not client_id: return [TextContent(type="text", text=f"Municipality '{municipality_name}' not found in {state_abbr}")] # Get available products client_content = await municode_client.get_client_content(client_id) # Find the code of ordinances product code_product = None for product in client_content: if "code" in product.get("ProductName", "").lower(): code_product = product break if not code_product: return [TextContent(type="text", text="No code of ordinances found for this municipality")] job_id = code_product.get("Id") product_id = code_product.get("ProductID") # Get table of contents toc = await municode_client.get_toc_children(job_id, product_id, node_id) return [TextContent( type="text", text=f"Code structure for {municipality_name}, {state_abbr}:\n\n" + json.dumps(toc, indent=2) )] elif name == "get_code_section": municipality_name = arguments["municipality_name"] state_abbr = arguments["state_abbr"].upper() node_id = arguments["node_id"] # Get client and product info client_info = await municode_client.get_client_by_name(municipality_name, state_abbr) client_id = client_info.get("ClientID") if not client_id: return [TextContent(type="text", text=f"Municipality '{municipality_name}' not found in {state_abbr}")] client_content = await municode_client.get_client_content(client_id) code_product = None for product in client_content: if "code" in product.get("ProductName", "").lower(): code_product = product break if not code_product: return [TextContent(type="text", text="No code of ordinances found for this municipality")] job_id = code_product.get("Id") product_id = code_product.get("ProductID") # Get the content content = await municode_client.get_codes_content(job_id, product_id, node_id) return [TextContent( type="text", text=f"Content for node {node_id} in {municipality_name}, {state_abbr}:\n\n" + json.dumps(content, indent=2) )] elif name == "search_municipal_codes": municipality_name = arguments["municipality_name"] state_abbr = arguments["state_abbr"].upper() search_query = arguments["search_query"] page_size = arguments.get("page_size", 10) page_number = arguments.get("page_number", 1) titles_only = arguments.get("titles_only", False) # Get client info client_info = await municode_client.get_client_by_name(municipality_name, state_abbr) client_id = client_info.get("ClientID") if not client_id: return [TextContent(type="text", text=f"Municipality '{municipality_name}' not found in {state_abbr}")] # Perform search search_results = await municode_client.search_munidocs( client_id=client_id, search_text=search_query, page_num=page_number, page_size=page_size, titles_only=titles_only ) return [TextContent( type="text", text=f"Search results for '{search_query}' in {municipality_name}, {state_abbr}:\n\n" + json.dumps(search_results, indent=2) )] elif name == "get_municipality_url": municipality_name = arguments["municipality_name"] state_abbr = arguments["state_abbr"].lower() # Format the municipality name for URL (spaces to underscores, lowercase) formatted_name = municipality_name.lower().replace(" ", "_").replace(",", "") url = f"{MUNICODE_LIBRARY_BASE}/{state_abbr}/{formatted_name}/codes/code_of_ordinances" return [TextContent( type="text", text=f"Municode Library URL for {municipality_name}, {state_abbr.upper()}:\n{url}" )] else: return [TextContent(type="text", text=f"Unknown tool: {name}")] except Exception as e: logger.error(f"Error in tool {name}: {str(e)}") return [TextContent(type="text", text=f"Error: {str(e)}")] @server.list_resources() async def handle_list_resources() -> List[Resource]: """List available resources.""" return [ Resource( uri="municode://help", name="Municode MCP Server Help", description="Documentation for using the Municode MCP server", mimeType="text/plain" ) ] @server.read_resource() async def handle_read_resource(uri: str) -> str: """Handle resource reads.""" if uri == "municode://help": return """ # Municode MCP Server This server provides access to municipal codes and ordinances through the Municode digital library. ## Available Tools: 1. **get_states_info** - Get information about a US state by abbreviation 2. **list_municipalities** - List all municipalities in a state that use Municode 3. **get_municipality_info** - Get detailed information about a specific municipality 4. **get_code_structure** - Get the table of contents structure for a municipality's code 5. **get_code_section** - Get the content of a specific section of municipal code 6. **search_municipal_codes** - Search through municipal codes and ordinances 7. **get_municipality_url** - Get the URL for a municipality's code library page ## Example Usage: 1. First, list municipalities in your state: - Tool: list_municipalities - Args: {"state_abbr": "VA"} 2. Get detailed info about a municipality: - Tool: get_municipality_info - Args: {"municipality_name": "Norfolk", "state_abbr": "VA"} 3. Browse the code structure: - Tool: get_code_structure - Args: {"municipality_name": "Norfolk", "state_abbr": "VA"} 4. Search for specific topics: - Tool: search_municipal_codes - Args: {"municipality_name": "Norfolk", "state_abbr": "VA", "search_query": "zoning"} ## Notes: - State abbreviations should be 2-letter codes (VA, TX, CA, etc.) - Municipality names should match exactly as they appear in Municode - Some municipalities may not have all products/features available - The API uses unofficial endpoints that may change Based on the unofficial Municode API documentation. """ raise ValueError(f"Unknown resource: {uri}") async def main(): """Run the server.""" # Import here to avoid issues if mcp package is not available from mcp.server.stdio import stdio_server async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, InitializationOptions( server_name="municode", server_version="1.0.0", capabilities=ServerCapabilities( tools={"listChanged": True}, resources={"listChanged": True, "subscribe": True} ) ) ) if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: logger.info("Server stopped") finally: asyncio.run(municode_client.close())

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Skatterbrainz/MunicipalMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server