diff --git a/mcp/client.go b/mcp/client.go index d934272..acb7634 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -200,8 +200,8 @@ func (c *Client) GetFeature(collectionID, featureID string) (json.RawMessage, er return json.RawMessage(body), nil } -// UploadFile calls POST /upload with a multipart file upload. -func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, error) { +// UploadMultipart calls POST {path} with a multipart file upload. +func (c *Client) UploadMultipart(path, filePath, fieldName string, extraFields map[string]string) (json.RawMessage, error) { f, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("open file: %w", err) @@ -210,26 +210,26 @@ func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, var buf bytes.Buffer w := multipart.NewWriter(&buf) - part, err := w.CreateFormFile("file", filepath.Base(filePath)) + part, err := w.CreateFormFile(fieldName, filepath.Base(filePath)) if err != nil { return nil, err } if _, err := io.Copy(part, f); err != nil { return nil, err } - if strings.TrimSpace(name) != "" { - if err := w.WriteField("name", name); err != nil { - return nil, err + for key, value := range extraFields { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + continue } - } - if strings.TrimSpace(projectID) != "" { - if err := w.WriteField("project_id", projectID); err != nil { + if err := w.WriteField(key, value); err != nil { return nil, err } } - w.Close() + if err := w.Close(); err != nil { + return nil, err + } - req, err := http.NewRequest("POST", c.BaseURL+"/upload", &buf) + req, err := http.NewRequest("POST", c.BaseURL+path, &buf) if err != nil { return nil, err } @@ -239,11 +239,23 @@ func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, return nil, err } if code != http.StatusOK && code != http.StatusCreated { - return nil, fmt.Errorf("POST /upload returned %d: %s", code, truncate(body, 500)) + return nil, fmt.Errorf("POST %s returned %d: %s", path, code, truncate(body, 500)) } return json.RawMessage(body), nil } +// UploadFile calls POST /upload with a multipart file upload. +func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, error) { + extraFields := map[string]string{} + if strings.TrimSpace(name) != "" { + extraFields["name"] = name + } + if strings.TrimSpace(projectID) != "" { + extraFields["project_id"] = projectID + } + return c.UploadMultipart("/upload", filePath, "file", extraFields) +} + // RunProcess calls POST /api/process. func (c *Client) RunProcess(payload interface{}) (json.RawMessage, error) { body, code, err := c.postJSON("/api/process", payload) diff --git a/mcp/handlers.go b/mcp/handlers.go index d190857..f203dfc 100644 --- a/mcp/handlers.go +++ b/mcp/handlers.go @@ -166,6 +166,8 @@ var indoorOperations = map[string]apiOperation{ "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"}, @@ -174,6 +176,12 @@ var indoorOperations = map[string]apiOperation{ "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}, @@ -1013,6 +1021,13 @@ 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 { @@ -1034,6 +1049,26 @@ 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 { @@ -1055,6 +1090,33 @@ 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 { diff --git a/mcp/server_test.go b/mcp/server_test.go index 0ff2fe4..ef089e3 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -282,6 +282,147 @@ 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) { diff --git a/mcp/tools.go b/mcp/tools.go index 9d2d9d2..54e5b18 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -607,9 +607,10 @@ func AllTools() []Tool { "list_assets", "create_asset", "update_asset_position", "navigate", "find_nearest", "navigate_outdoor", "import_building", "export_imdf", "export_indoorgml", - "get_occupancy", "get_stats", "validate_building", "get_accessibility", "get_historical_analytics", + "get_occupancy", "get_stats", "validate_building", "get_accessibility", "get_dead_zones", "get_reachable", "get_historical_analytics", "ingest_sensor_data", "get_sensor_data", "get_sensor_heatmap", "get_sensor_timeseries", "ingest_positions", "get_latest_positions", "get_position_history", + "list_scenarios", "create_scenario", "get_scenario", "update_scenario", "delete_scenario", "get_stream_status", "create_booking", "list_bookings", "cancel_booking", "checkin_booking", "list_geofences", "create_geofence", "get_geofence", "update_geofence", "delete_geofence", "get_evacuation_routes", "trigger_evacuation_alert", @@ -629,8 +630,11 @@ func AllTools() []Tool { "device_id": {Type: "string", Description: "Path parameter for device-scoped operations."}, "booking_id": {Type: "string", Description: "Path parameter for booking-scoped operations."}, "geofence_id": {Type: "string", Description: "Path parameter for geofence-scoped operations."}, + "scenario_id": {Type: "string", Description: "Path parameter for scenario-scoped operations."}, + "file_path": {Type: "string", Description: "Local file path for multipart upload operations such as import_building and upload_floor_plan."}, + "bounds": {Type: "string", Description: "Optional JSON string or simple value for multipart upload fields. For upload_floor_plan this should be a JSON array like [[west,south],[east,north]]."}, "query": {Type: "object", Description: "Optional query string key/value map."}, - "body": {Type: "object", Description: "Optional JSON request body."}, + "body": {Type: "object", Description: "Optional JSON request body. For upload_floor_plan, bounds may also be provided here."}, "confirm": {Type: "boolean", Description: "Required true for mutating operations."}, }, Required: []string{"operation"},