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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ Add to `.mcp.json`:
| `browse_stac_items` | List items in a remote STAC collection |
| `import_stac_asset` | Import a STAC asset as a local dataset, optionally with namespace/catalog metadata and project attachment |
| `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/JSON analysis/export ops including contour/viewshed/profile/KDE/slope/aspect, geodesic area/length, raster classification via k-means/ISODATA/max-likelihood/random-forest, 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, KDE, slope, and aspect. Geodesic area/length and raster classification (k-means, ISODATA, maximum-likelihood, random-forest) are also available via `map_api`.
Expand Down
142 changes: 0 additions & 142 deletions mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,6 @@ func HandleToolCall(client *Client, name string, args json.RawMessage) (string,
return handleImportSTACAsset(client, params)
case "search_stac":
return handleSearchSTAC(client, params)
case "indoor_api":
return handleIndoorAPI(client, params)
case "map_api":
return handleMapAPI(client, params)
default:
Expand All @@ -127,87 +125,6 @@ type apiOperation struct {
Mutating bool
}

var indoorOperations = map[string]apiOperation{
"list_campuses": {Method: "GET", Path: "/api/indoor/campuses"},
"create_campus": {Method: "POST", Path: "/api/indoor/campuses", Mutating: true},
"get_campus": {Method: "GET", Path: "/api/indoor/campuses/{campus_id}"},
"update_campus": {Method: "PUT", Path: "/api/indoor/campuses/{campus_id}", Mutating: true},
"delete_campus": {Method: "DELETE", Path: "/api/indoor/campuses/{campus_id}", Mutating: true},
"list_campus_buildings": {Method: "GET", Path: "/api/indoor/campuses/{campus_id}/buildings"},
"list_buildings": {Method: "GET", Path: "/api/indoor/buildings"},
"create_building": {Method: "POST", Path: "/api/indoor/buildings", Mutating: true},
"get_building": {Method: "GET", Path: "/api/indoor/buildings/{building_id}"},
"update_building": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}", Mutating: true},
"delete_building": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}", Mutating: true},
"list_floors": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/floors"},
"create_floor": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/floors", Mutating: true},
"upload_floor_plan": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/floors/{floor_id}/plan", Mutating: true},
"list_floor_spaces": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/floors/{level}/spaces"},
"create_space": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/spaces", Mutating: true},
"get_space": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/spaces/{space_id}"},
"update_space": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}/spaces/{space_id}", Mutating: true},
"delete_space": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}/spaces/{space_id}", Mutating: true},
"list_transitions": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/transitions"},
"list_boundaries": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/boundaries"},
"create_boundary": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/boundaries", Mutating: true},
"get_boundary": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/boundaries/{boundary_id}"},
"update_boundary": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}/boundaries/{boundary_id}", Mutating: true},
"delete_boundary": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}/boundaries/{boundary_id}", Mutating: true},
"list_assets": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/assets"},
"create_asset": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/assets", Mutating: true},
"update_asset_position": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}/assets/{asset_id}", Mutating: true},
"navigate": {Method: "POST", Path: "/api/indoor/navigate"},
"find_nearest": {Method: "POST", Path: "/api/indoor/navigate/nearest"},
"navigate_outdoor": {Method: "POST", Path: "/api/indoor/navigate/outdoor"},
"import_building": {Method: "POST", Path: "/api/indoor/import", Mutating: true},
"export_imdf": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/export/imdf"},
"export_indoorgml": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/export/indoorgml"},
"get_occupancy": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/occupancy"},
"get_stats": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/stats"},
"validate_building": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/validate"},
"get_accessibility": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analysis/accessibility"},
"get_dead_zones": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analysis/dead-zones"},
"get_reachable": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analysis/reachable"},
"get_historical_analytics": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analytics"},
"ingest_sensor_data": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/sensors", Mutating: true},
"get_sensor_data": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/sensors"},
"get_sensor_heatmap": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/sensors/heatmap"},
"get_sensor_timeseries": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/sensors/timeseries"},
"ingest_positions": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/positions", Mutating: true},
"get_latest_positions": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/positions/latest"},
"get_position_history": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/positions/{device_id}/history"},
"list_scenarios": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/scenarios"},
"create_scenario": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/scenarios", Mutating: true},
"get_scenario": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/scenarios/{scenario_id}"},
"update_scenario": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}/scenarios/{scenario_id}", Mutating: true},
"delete_scenario": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}/scenarios/{scenario_id}", Mutating: true},
"get_stream_status": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/stream/status"},
"create_booking": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/bookings", Mutating: true},
"list_bookings": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/bookings"},
"cancel_booking": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}/bookings/{booking_id}", Mutating: true},
"checkin_booking": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/bookings/{booking_id}/checkin", Mutating: true},
"list_geofences": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/geofences"},
"create_geofence": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/geofences", Mutating: true},
"get_geofence": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/geofences/{geofence_id}"},
"update_geofence": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}/geofences/{geofence_id}", Mutating: true},
"delete_geofence": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}/geofences/{geofence_id}", Mutating: true},
"get_evacuation_routes": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/evacuation"},
"trigger_evacuation_alert": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/evacuation/alert", Mutating: true},
"topology_resilience": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/topology/resilience"},
"topology_track": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/topology/track"},
"topology_compare": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/topology/compare"},
"spectral_analysis": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/spectral"},
"evacuation_optimal": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/evacuation/optimal"},
"simulate_fire_evacuation": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/simulate/fire-evacuation"},
"simulate_airflow": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/simulate/airflow"},
"simulate_acoustics": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/simulate/acoustics"},
"simulate_thermal": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/simulate/thermal"},
"simulate_rf": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/simulate/rf"},
"navigate_manifold": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/navigate/manifold"},
"forecast": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/forecast"},
"simulate_infection_risk": {Method: "POST", Path: "/api/indoor/floors/{floor_id}/simulate/infection-risk"},
}

var mapOperations = map[string]apiOperation{
"publish_map": {Method: "POST", Path: "/api/maps/publish", Mutating: true},
"list_published_maps": {Method: "GET", Path: "/api/maps/published"},
Expand Down Expand Up @@ -1001,10 +918,6 @@ func handleSearchSTAC(client *Client, params map[string]interface{}) (string, er
return formatJSON(data), nil
}

func handleIndoorAPI(client *Client, params map[string]interface{}) (string, error) {
return handleScopedAPI(client, params, indoorOperations, "indoor")
}

func handleMapAPI(client *Client, params map[string]interface{}) (string, error) {
return handleScopedAPI(client, params, mapOperations, "map")
}
Expand All @@ -1021,14 +934,6 @@ func handleScopedAPI(client *Client, params map[string]interface{}, ops map[stri
if op.Mutating && !requireConfirm(params) {
return "", fmt.Errorf("%s operation %q mutates data; pass confirm=true to proceed", scope, opName)
}
if scope == "indoor" && (opName == "import_building" || opName == "upload_floor_plan") {
data, err := handleIndoorMultipartOperation(client, opName, op.Path, params)
if err != nil {
return "", err
}
return formatJSON(data), nil
}

path, err := interpolatePath(op.Path, params)
if err != nil {
return "", err
Expand All @@ -1049,26 +954,6 @@ func handleScopedAPI(client *Client, params map[string]interface{}, ops map[stri
return formatJSON(data), nil
}

func handleIndoorMultipartOperation(client *Client, opName, pathTemplate string, params map[string]interface{}) (json.RawMessage, error) {
path, err := interpolatePath(pathTemplate, params)
if err != nil {
return nil, err
}
filePath, err := requireString(params, "file_path")
if err != nil {
return nil, err
}
extraFields := map[string]string{}
if opName == "upload_floor_plan" {
if bounds, err := optionalMultipartValue(params, "bounds"); err != nil {
return nil, err
} else if bounds != "" {
extraFields["bounds"] = bounds
}
}
return client.UploadMultipart(path, filePath, "file", extraFields)
}

func requireConfirm(params map[string]interface{}) bool {
v, ok := params["confirm"]
if !ok {
Expand All @@ -1090,33 +975,6 @@ func extractBody(params map[string]interface{}) interface{} {
return nil
}

func optionalMultipartValue(params map[string]interface{}, key string) (string, error) {
if raw, ok := params[key]; ok && raw != nil {
return stringifyMultipartValue(raw)
}
if body, ok := params["body"].(map[string]interface{}); ok {
if raw, ok := body[key]; ok && raw != nil {
return stringifyMultipartValue(raw)
}
}
return "", nil
}

func stringifyMultipartValue(v interface{}) (string, error) {
switch value := v.(type) {
case string:
return value, nil
case map[string]interface{}, []interface{}:
data, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(data), nil
default:
return stringify(v)
}
}

func extractQuery(params map[string]interface{}) (map[string]string, error) {
raw, ok := params["query"]
if !ok {
Expand Down
141 changes: 0 additions & 141 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,147 +282,6 @@ func TestToolsCall_UploadDatasetScoped(t *testing.T) {
}
}

func TestToolsCall_IndoorImportBuildingUsesMultipartUpload(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "campus.ifc")
if err := os.WriteFile(filePath, []byte("ISO-10303-21;"), 0o600); err != nil {
t.Fatalf("write temp indoor file: %v", err)
}

mux := http.NewServeMux()
mux.HandleFunc("POST /api/indoor/import", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"parse=%v"}`, err)
return
}
file, header, err := r.FormFile("file")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"file=%v"}`, err)
return
}
defer file.Close()
if header.Filename != "campus.ifc" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"filename=%s"}`, header.Filename)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"id":"building-1","name":"HQ"}`)
})
srv := testServer(t, mux)

resp := sendRequest(t, srv, "tools/call", 241, map[string]interface{}{
"name": "indoor_api",
"arguments": map[string]interface{}{
"operation": "import_building",
"file_path": filePath,
"confirm": true,
},
})

if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
result, _ := resp.Result.(map[string]interface{})
if isErr, _ := result["isError"].(bool); isErr {
t.Fatalf("expected success result, got error: %+v", result)
}
}

func TestToolsCall_IndoorUploadFloorPlanUsesMultipartBounds(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "floor.png")
if err := os.WriteFile(filePath, []byte("png-bytes"), 0o600); err != nil {
t.Fatalf("write temp plan file: %v", err)
}

mux := http.NewServeMux()
mux.HandleFunc("POST /api/indoor/buildings/{id}/floors/{fid}/plan", func(w http.ResponseWriter, r *http.Request) {
if got := r.PathValue("id"); got != "building-1" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"building=%s"}`, got)
return
}
if got := r.PathValue("fid"); got != "floor-2" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"floor=%s"}`, got)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"parse=%v"}`, err)
return
}
if got := r.FormValue("bounds"); got != "[[1,2],[3,4]]" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"bounds=%s"}`, got)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status":"uploaded","floor_id":"floor-2"}`)
})
srv := testServer(t, mux)

resp := sendRequest(t, srv, "tools/call", 242, map[string]interface{}{
"name": "indoor_api",
"arguments": map[string]interface{}{
"operation": "upload_floor_plan",
"building_id": "building-1",
"floor_id": "floor-2",
"file_path": filePath,
"bounds": []interface{}{[]interface{}{1.0, 2.0}, []interface{}{3.0, 4.0}},
"confirm": true,
},
})

if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
result, _ := resp.Result.(map[string]interface{})
if isErr, _ := result["isError"].(bool); isErr {
t.Fatalf("expected success result, got error: %+v", result)
}
}

func TestToolsCall_IndoorGetScenario(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/indoor/buildings/{id}/scenarios/{sid}", func(w http.ResponseWriter, r *http.Request) {
if got := r.PathValue("id"); got != "building-1" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"building=%s"}`, got)
return
}
if got := r.PathValue("sid"); got != "scenario-1" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"scenario=%s"}`, got)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"scenario-1","name":"Evacuation Drill"}`)
})
srv := testServer(t, mux)

resp := sendRequest(t, srv, "tools/call", 243, map[string]interface{}{
"name": "indoor_api",
"arguments": map[string]interface{}{
"operation": "get_scenario",
"building_id": "building-1",
"scenario_id": "scenario-1",
},
})

if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
result, _ := resp.Result.(map[string]interface{})
if isErr, _ := result["isError"].(bool); isErr {
t.Fatalf("expected success result, got error: %+v", result)
}
}

func TestToolsCall_ExecuteSQL(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/query/sql", func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading