QGIS MCP Server

#!/usr/bin/env python3 """ QGIS MCP Client - Simple client to connect to the QGIS MCP server """ import logging from contextlib import asynccontextmanager import socket import json from typing import AsyncIterator, Dict, Any from mcp.server.fastmcp import FastMCP, Context logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger("QgisMCPServer") class QgisMCPServer: def __init__(self, host='localhost', port=9876): self.host = host self.port = port self.socket = None def connect(self): """Connect to the QGIS MCP server""" try: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.connect((self.host, self.port)) return True except Exception as e: print(f"Error connecting to server: {str(e)}") return False def disconnect(self): """Disconnect from the server""" if self.socket: self.socket.close() self.socket = None def send_command(self, command_type, params=None): """Send a command to the server and get the response""" if not self.socket: print("Not connected to server") return None # Create command command = { "type": command_type, "params": params or {} } try: # Send the command self.socket.sendall(json.dumps(command).encode('utf-8')) # Receive the response response_data = b'' while True: chunk = self.socket.recv(4096) if not chunk: break response_data += chunk # Try to decode as JSON to see if it's complete try: json.loads(response_data.decode('utf-8')) break # Valid JSON, we have the full message except json.JSONDecodeError: continue # Keep receiving # Parse and return the response return json.loads(response_data.decode('utf-8')) except Exception as e: print(f"Error sending command: {str(e)}") return None _qgis_connection = None def get_qgis_connection(): """Get or create a persistent Qgis connection""" global _qgis_connection # If we have an existing connection, check if it's still valid if _qgis_connection is not None: # Test if the connection is still alive with a simple ping try: # Just try to send a small message to check if the socket is still connected _qgis_connection.sock.sendall(b'') return _qgis_connection except Exception as e: # Connection is dead, close it and create a new one logger.warning(f"Existing connection is no longer valid: {str(e)}") try: _qgis_connection.disconnect() except Exception: pass _qgis_connection = None # Create a new connection if needed if _qgis_connection is None: _qgis_connection = QgisMCPServer(host="localhost", port=9876) if not _qgis_connection.connect(): logger.error("Failed to connect to Qgis") _qgis_connection = None raise Exception("Could not connect to Qgis. Make sure the Qgis plugin is running.") logger.info("Created new persistent connection to Qgis") return _qgis_connection @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Manage server startup and shutdown lifecycle""" # We don't need to create a connection here since we're using the global connection # for resources and tools try: # Just log that we're starting up logger.info("QgisMCPServer server starting up") # Try to connect to Qgis on startup to verify it's available try: # This will initialize the global connection if needed qgis = get_qgis_connection() logger.info("Successfully connected to Qgis on startup") except Exception as e: logger.warning(f"Could not connect to Qgis on startup: {str(e)}") logger.warning("Make sure the Qgis addon is running before using Qgis resources or tools") # Return an empty context - we're using the global connection yield {} finally: # Clean up the global connection on shutdown global _qgis_connection if _qgis_connection: logger.info("Disconnecting from Qgis on shutdown") _qgis_connection.disconnect() _qgis_connection = None logger.info("QgisMCPServer server shut down") mcp = FastMCP( "Qgis_mcp", description="Qgis integration through the Model Context Protocol", lifespan=server_lifespan ) @mcp.tool() def ping(ctx: Context) -> str: """Simple ping command to check server connectivity""" qgis = get_qgis_connection() result = qgis.send_command("ping") return json.dumps(result, indent=2) @mcp.tool() def get_qgis_info(ctx: Context) -> str: """Get QGIS information""" qgis = get_qgis_connection() result = qgis.send_command("get_qgis_info") return json.dumps(result, indent=2) @mcp.tool() def load_project(ctx: Context, path: str) -> str: """Load a QGIS project from the specified path.""" qgis = get_qgis_connection() result = qgis.send_command("load_project", {"path": path}) return json.dumps(result, indent=2) @mcp.tool() def create_new_project(ctx: Context, path: str) -> str: """Create a new project a save it""" qgis = get_qgis_connection() result = qgis.send_command("create_new_project", {"path": path}) return json.dumps(result, indent=2) @mcp.tool() def get_project_info(ctx: Context) -> str: """Get current project information""" qgis = get_qgis_connection() result = qgis.send_command("get_project_info") return json.dumps(result, indent=2) @mcp.tool() def add_vector_layer(ctx: Context, path: str, provider: str = "ogr", name: str = None) -> str: """Add a vector layer to the project.""" qgis = get_qgis_connection() params = {"path": path, "provider": provider} if name: params["name"] = name result = qgis.send_command("add_vector_layer", params) return json.dumps(result, indent=2) @mcp.tool() def add_raster_layer(ctx: Context, path: str, provider: str = "gdal", name: str = None) -> str: """Add a raster layer to the project.""" qgis = get_qgis_connection() params = {"path": path, "provider": provider} if name: params["name"] = name result = qgis.send_command("add_raster_layer", params) return json.dumps(result, indent=2) @mcp.tool() def get_layers(ctx: Context) -> str: """Retrieve all layers in the current project.""" qgis = get_qgis_connection() result = qgis.send_command("get_layers") return json.dumps(result, indent=2) @mcp.tool() def remove_layer(ctx: Context, layer_id: str) -> str: """Remove a layer from the project by its ID.""" qgis = get_qgis_connection() result = qgis.send_command("remove_layer", {"layer_id": layer_id}) return json.dumps(result, indent=2) @mcp.tool() def zoom_to_layer(ctx: Context, layer_id: str) -> str: """Zoom to the extent of a specified layer.""" qgis = get_qgis_connection() result = qgis.send_command("zoom_to_layer", {"layer_id": layer_id}) return json.dumps(result, indent=2) @mcp.tool() def get_layer_features(ctx: Context, layer_id: str, limit: int = 10) -> str: """Retrieve features from a vector layer with an optional limit.""" qgis = get_qgis_connection() result = qgis.send_command("get_layer_features", {"layer_id": layer_id, "limit": limit}) return json.dumps(result, indent=2) @mcp.tool() def execute_processing(ctx: Context, algorithm: str, parameters: dict) -> str: """Execute a processing algorithm with the given parameters.""" qgis = get_qgis_connection() result = qgis.send_command("execute_processing", {"algorithm": algorithm, "parameters": parameters}) return json.dumps(result, indent=2) @mcp.tool() def save_project(ctx: Context, path: str = None) -> str: """Save the current project to the given path, or to the current project path if not specified.""" qgis = get_qgis_connection() params = {} if path: params["path"] = path result = qgis.send_command("save_project", params) return json.dumps(result, indent=2) @mcp.tool() def render_map(ctx: Context, path: str, width: int = 800, height: int = 600) -> str: """Render the current map view to an image file with the specified dimensions.""" qgis = get_qgis_connection() result = qgis.send_command("render_map", {"path": path, "width": width, "height": height}) return json.dumps(result, indent=2) @mcp.tool() def execute_code(ctx: Context, code: str) -> str: """Execute arbitrary PyQGIS code provided as a string.""" qgis = get_qgis_connection() result = qgis.send_command("execute_code", {"code": code}) return json.dumps(result, indent=2) def main(): """Run the MCP server""" mcp.run() if __name__ == "__main__": main()