From c3d3966c3a578ab01ff1acc68796fb206c523558 Mon Sep 17 00:00:00 2001 From: i-norden Date: Thu, 12 Mar 2026 20:48:13 -0400 Subject: [PATCH] Expose raster analysis routes in agent --- README.md | 5 +- SKILL.md | 4 ++ mcp/client.go | 12 ++++ mcp/handlers.go | 23 ++++++++ mcp/server_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++++- mcp/tools.go | 16 +++++ 6 files changed, 202 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b659c5..7d726e9 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Add to `.mcp.json`: | `get_feature` | Single feature by ID | | `upload_dataset` | Upload a spatial data file | | `run_process` | Single synchronous geoprocessing operation | +| `run_raster_process` | Generic synchronous raster processing via file-path inputs | | `preflight_process` | Validate and normalize a processing request | | `submit_process_job` | Submit an async processing job | | `submit_process_batch` | Submit dependent async processing jobs | @@ -125,7 +126,9 @@ Add to `.mcp.json`: | `import_stac_asset` | Import a STAC asset as a local dataset | | `search_stac` | Search local STAC with bbox/datetime/CQL2 filters | | `indoor_api` | Allowlisted Indoor GIS endpoint access (buildings/floors/spaces/navigation/analytics/sensors/booking/geofences/simulations). Mutations require `confirm=true`. | -| `map_api` | Allowlisted map endpoint access (publish/unpublish/stats/embed config, raster metadata/values, OGC feature edit ops). Mutations require `confirm=true`. | +| `map_api` | Allowlisted map endpoint access (publish/unpublish/stats/embed config, raster metadata/JSON analysis/export ops including contour/viewshed/profile/KDE, OGC feature edit ops). Mutations require `confirm=true`. | + +Use `list_operations` for the live vector-processing catalog. Raster operations do not currently have a live catalog endpoint, so `run_raster_process` documents the current backend families directly: terrain, hydrology, distance/cost, spectral/change, classification, and raster-vector conversion. For dataset-name-based raster JSON routes, `map_api` now also exposes contour, viewshed, profile, and KDE. ## Example Workflows diff --git a/SKILL.md b/SKILL.md index c7581f8..f1f6200 100644 --- a/SKILL.md +++ b/SKILL.md @@ -60,6 +60,10 @@ Use `list_analysis_operations` for advanced analysis catalog endpoints under `/a The async process workflow is available through `submit_process_job`, `submit_process_batch`, `list_process_jobs`, `get_process_job`, `cancel_process_job`, and `rerun_process_job`. +For raster analysis, use `run_raster_process` with file paths when you need the generic `/api/raster/process` endpoint. Typical operation families include terrain, hydrology, distance/cost, spectral/change, classification, and raster-vector conversion. + +For registered raster datasets and JSON-returning raster endpoints, use `map_api` with operations such as `get_raster_info`, `get_raster_stats`, `get_raster_histogram`, `get_raster_dimensions`, `get_raster_values`, `raster_zonal_stats`, `export_raster_band`, `raster_contour`, `raster_viewshed`, `raster_profile`, and `raster_kde`. + ## Data Catalog & STAC Roteiro includes a built-in data catalog and supports importing from remote STAC (SpatioTemporal Asset Catalog) servers. diff --git a/mcp/client.go b/mcp/client.go index 1bf285b..b373b4d 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -229,6 +229,18 @@ func (c *Client) RunProcess(payload interface{}) (json.RawMessage, error) { return json.RawMessage(body), nil } +// RunRasterProcess calls POST /api/raster/process. +func (c *Client) RunRasterProcess(payload interface{}) (json.RawMessage, error) { + body, code, err := c.postJSON("/api/raster/process", payload) + if err != nil { + return nil, err + } + if code != http.StatusOK { + return nil, fmt.Errorf("POST /api/raster/process returned %d: %s", code, truncate(body, 500)) + } + return json.RawMessage(body), nil +} + // PreflightProcess calls POST /api/process/preflight. func (c *Client) PreflightProcess(payload interface{}) (json.RawMessage, error) { body, code, err := c.postJSON("/api/process/preflight", payload) diff --git a/mcp/handlers.go b/mcp/handlers.go index 897b937..d290e65 100644 --- a/mcp/handlers.go +++ b/mcp/handlers.go @@ -38,6 +38,8 @@ func HandleToolCall(client *Client, name string, args json.RawMessage) (string, return handleUploadDataset(client, params) case "run_process": return handleRunProcess(client, params) + case "run_raster_process": + return handleRunRasterProcess(client, params) case "preflight_process": return handlePreflightProcess(client, params) case "submit_process_job": @@ -204,6 +206,12 @@ var mapOperations = map[string]apiOperation{ "get_raster_histogram": {Method: "GET", Path: "/raster/{name}/histogram"}, "get_raster_dimensions": {Method: "GET", Path: "/raster/{name}/dimensions"}, "get_raster_values": {Method: "GET", Path: "/raster/{name}/values"}, + "raster_zonal_stats": {Method: "POST", Path: "/raster/{name}/zonal-stats"}, + "export_raster_band": {Method: "POST", Path: "/raster/{name}/export"}, + "raster_contour": {Method: "POST", Path: "/raster/{name}/contour"}, + "raster_viewshed": {Method: "POST", Path: "/raster/{name}/viewshed"}, + "raster_profile": {Method: "POST", Path: "/raster/{name}/profile"}, + "raster_kde": {Method: "POST", Path: "/api/raster/kde"}, "create_feature": {Method: "POST", Path: "/collections/{collection_id}/items", Mutating: true}, "update_feature": {Method: "PUT", Path: "/collections/{collection_id}/items/{feature_id}", Mutating: true}, "delete_feature": {Method: "DELETE", Path: "/collections/{collection_id}/items/{feature_id}", Mutating: true}, @@ -312,6 +320,15 @@ func handleRunProcess(client *Client, params map[string]interface{}) (string, er return formatJSON(data), nil } +func handleRunRasterProcess(client *Client, params map[string]interface{}) (string, error) { + normalizeRasterProcessPayload(params) + data, err := client.RunRasterProcess(params) + if err != nil { + return "", err + } + return formatJSON(data), nil +} + func handlePreflightProcess(client *Client, params map[string]interface{}) (string, error) { normalizeProcessPayload(params) data, err := client.PreflightProcess(params) @@ -462,6 +479,12 @@ func normalizeProcessPayload(params map[string]interface{}) { } } +func normalizeRasterProcessPayload(params map[string]interface{}) { + if _, ok := params["params"]; !ok || params["params"] == nil { + params["params"] = map[string]interface{}{} + } +} + func handleDiffDatasets(client *Client, params map[string]interface{}) (string, error) { if _, ok := params["left"]; !ok { if base, ok := params["base"].(string); ok && base != "" { diff --git a/mcp/server_test.go b/mcp/server_test.go index 68cc202..9968369 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -94,7 +94,7 @@ func TestToolsList(t *testing.T) { } for _, want := range []string{ "list_datasets", "get_dataset_info", "query_features", "get_feature", - "upload_dataset", "run_process", "preflight_process", "submit_process_job", + "upload_dataset", "run_process", "run_raster_process", "preflight_process", "submit_process_job", "submit_process_batch", "list_process_jobs", "get_process_job", "cancel_process_job", "rerun_process_job", "run_pipeline", "convert_format", "diff_datasets", "execute_sql", "list_spatial_tables", "get_duckdb_info", @@ -673,6 +673,148 @@ func TestToolsCall_PreflightProcess(t *testing.T) { } } +func TestToolsCall_RunRasterProcess(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/raster/process", func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if body["operation"] != "slope" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"missing operation"}`) + return + } + if _, ok := body["params"]; !ok { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"missing params"}`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"width":2,"height":2,"data":[1,2,3,4]}`) + }) + srv := testServer(t, mux) + + resp := sendRequest(t, srv, "tools/call", 231, map[string]interface{}{ + "name": "run_raster_process", + "arguments": map[string]interface{}{ + "operation": "slope", + "input_path": "/data/dem.tif", + }, + }) + + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + result, _ := resp.Result.(map[string]interface{}) + content, _ := result["content"].([]interface{}) + first, _ := content[0].(map[string]interface{}) + text, _ := first["text"].(string) + if !strings.Contains(text, `"width": 2`) { + t.Errorf("response should contain raster result, got: %s", text) + } +} + +func TestToolsCall_MapAPIExportRasterBand(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /raster/{name}/export", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("name") != "elevation" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"message":"exported to /tmp/elevation.tif"}`) + }) + srv := testServer(t, mux) + + resp := sendRequest(t, srv, "tools/call", 232, map[string]interface{}{ + "name": "map_api", + "arguments": map[string]interface{}{ + "operation": "export_raster_band", + "name": "elevation", + "body": map[string]interface{}{ + "output_path": "elevation.tif", + "band": 0, + }, + }, + }) + + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + result, _ := resp.Result.(map[string]interface{}) + content, _ := result["content"].([]interface{}) + first, _ := content[0].(map[string]interface{}) + text, _ := first["text"].(string) + if !strings.Contains(text, "exported to /tmp/elevation.tif") { + t.Errorf("response should contain export message, got: %s", text) + } +} + +func TestToolsCall_MapAPIContour(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /raster/{name}/contour", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("name") != "elevation" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"type":"FeatureCollection","features":[]}`) + }) + srv := testServer(t, mux) + + resp := sendRequest(t, srv, "tools/call", 233, map[string]interface{}{ + "name": "map_api", + "arguments": map[string]interface{}{ + "operation": "raster_contour", + "name": "elevation", + "body": map[string]interface{}{ + "interval": 10, + }, + }, + }) + + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + result, _ := resp.Result.(map[string]interface{}) + content, _ := result["content"].([]interface{}) + first, _ := content[0].(map[string]interface{}) + text, _ := first["text"].(string) + if !strings.Contains(text, "FeatureCollection") { + t.Errorf("response should contain contour GeoJSON, got: %s", text) + } +} + +func TestToolsCall_MapAPIKDE(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/raster/kde", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"width":2,"height":2,"data":[0.1,0.2,0.3,0.4]}`) + }) + srv := testServer(t, mux) + + resp := sendRequest(t, srv, "tools/call", 234, map[string]interface{}{ + "name": "map_api", + "arguments": map[string]interface{}{ + "operation": "raster_kde", + "body": map[string]interface{}{ + "dataset": "points", + "bandwidth": 50, + }, + }, + }) + + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + result, _ := resp.Result.(map[string]interface{}) + content, _ := result["content"].([]interface{}) + first, _ := content[0].(map[string]interface{}) + text, _ := first["text"].(string) + if !strings.Contains(text, `"width": 2`) { + t.Errorf("response should contain kde grid, got: %s", text) + } +} + func TestToolsCall_SubmitProcessJob(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("POST /api/process/jobs", func(w http.ResponseWriter, r *http.Request) { diff --git a/mcp/tools.go b/mcp/tools.go index 6f92821..56a0581 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -131,6 +131,21 @@ func AllTools() []Tool { Required: []string{"operation"}, }, }, + { + Name: "run_raster_process", + Description: "Run a generic raster processing operation via /api/raster/process using file paths. Current backend operations include terrain (slope, aspect, profile_curvature, plan_curvature, general_curvature), hydrology (fill, flow_direction, flow_accumulation, watershed, stream_order, snap_pour_point, basin_labels), distance/cost tools, spectral/change tools, classification, and raster-vector conversion.", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]PropertySchema{ + "operation": {Type: "string", Description: "Raster operation name such as 'slope', 'fill', 'cost_distance', 'spectral_index', 'kmeans', 'raster_to_polygons', or 'rasterize'."}, + "input_path": {Type: "string", Description: "Input raster file path. Retrieve dataset paths with list_datasets or get_dataset_info when needed."}, + "output_path": {Type: "string", Description: "Optional output GeoTIFF path for file-writing operations."}, + "band": {Type: "string", Description: "Optional raster band index."}, + "params": {Type: "object", Description: "Operation-specific parameters, matching the backend raster analysis docs."}, + }, + Required: []string{"operation"}, + }, + }, { Name: "preflight_process", Description: "Validate and normalize a processing request via /api/process/preflight before executing it. Useful for checking required params, CRS constraints, and normalized dataset references.", @@ -613,6 +628,7 @@ func AllTools() []Tool { "publish_map", "list_published_maps", "unpublish_map", "get_published_map_stats", "update_map_embed_config", "get_public_map", "get_raster_info", "get_raster_stats", "get_raster_histogram", "get_raster_dimensions", "get_raster_values", + "raster_zonal_stats", "export_raster_band", "raster_contour", "raster_viewshed", "raster_profile", "raster_kde", "create_feature", "update_feature", "delete_feature", }, },