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
36 changes: 24 additions & 12 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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},
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
141 changes: 141 additions & 0 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"},
Expand Down
Loading