Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 != "" {
Expand Down
144 changes: 143 additions & 1 deletion mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
},
},
Expand Down
Loading