Skip to content

Commit 2fca652

Browse files
authored
Merge pull request #11 from i-norden/ian/dev
Update MCP indoor API for latest Cairn routes
2 parents 5e59245 + aff30cc commit 2fca652

4 files changed

Lines changed: 233 additions & 14 deletions

File tree

mcp/client.go

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ func (c *Client) GetFeature(collectionID, featureID string) (json.RawMessage, er
200200
return json.RawMessage(body), nil
201201
}
202202

203-
// UploadFile calls POST /upload with a multipart file upload.
204-
func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, error) {
203+
// UploadMultipart calls POST {path} with a multipart file upload.
204+
func (c *Client) UploadMultipart(path, filePath, fieldName string, extraFields map[string]string) (json.RawMessage, error) {
205205
f, err := os.Open(filePath)
206206
if err != nil {
207207
return nil, fmt.Errorf("open file: %w", err)
@@ -210,26 +210,26 @@ func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage,
210210

211211
var buf bytes.Buffer
212212
w := multipart.NewWriter(&buf)
213-
part, err := w.CreateFormFile("file", filepath.Base(filePath))
213+
part, err := w.CreateFormFile(fieldName, filepath.Base(filePath))
214214
if err != nil {
215215
return nil, err
216216
}
217217
if _, err := io.Copy(part, f); err != nil {
218218
return nil, err
219219
}
220-
if strings.TrimSpace(name) != "" {
221-
if err := w.WriteField("name", name); err != nil {
222-
return nil, err
220+
for key, value := range extraFields {
221+
if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
222+
continue
223223
}
224-
}
225-
if strings.TrimSpace(projectID) != "" {
226-
if err := w.WriteField("project_id", projectID); err != nil {
224+
if err := w.WriteField(key, value); err != nil {
227225
return nil, err
228226
}
229227
}
230-
w.Close()
228+
if err := w.Close(); err != nil {
229+
return nil, err
230+
}
231231

232-
req, err := http.NewRequest("POST", c.BaseURL+"/upload", &buf)
232+
req, err := http.NewRequest("POST", c.BaseURL+path, &buf)
233233
if err != nil {
234234
return nil, err
235235
}
@@ -239,11 +239,23 @@ func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage,
239239
return nil, err
240240
}
241241
if code != http.StatusOK && code != http.StatusCreated {
242-
return nil, fmt.Errorf("POST /upload returned %d: %s", code, truncate(body, 500))
242+
return nil, fmt.Errorf("POST %s returned %d: %s", path, code, truncate(body, 500))
243243
}
244244
return json.RawMessage(body), nil
245245
}
246246

247+
// UploadFile calls POST /upload with a multipart file upload.
248+
func (c *Client) UploadFile(filePath, name, projectID string) (json.RawMessage, error) {
249+
extraFields := map[string]string{}
250+
if strings.TrimSpace(name) != "" {
251+
extraFields["name"] = name
252+
}
253+
if strings.TrimSpace(projectID) != "" {
254+
extraFields["project_id"] = projectID
255+
}
256+
return c.UploadMultipart("/upload", filePath, "file", extraFields)
257+
}
258+
247259
// RunProcess calls POST /api/process.
248260
func (c *Client) RunProcess(payload interface{}) (json.RawMessage, error) {
249261
body, code, err := c.postJSON("/api/process", payload)

mcp/handlers.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ var indoorOperations = map[string]apiOperation{
166166
"get_stats": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/stats"},
167167
"validate_building": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/validate"},
168168
"get_accessibility": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analysis/accessibility"},
169+
"get_dead_zones": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analysis/dead-zones"},
170+
"get_reachable": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analysis/reachable"},
169171
"get_historical_analytics": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/analytics"},
170172
"ingest_sensor_data": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/sensors", Mutating: true},
171173
"get_sensor_data": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/sensors"},
@@ -174,6 +176,12 @@ var indoorOperations = map[string]apiOperation{
174176
"ingest_positions": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/positions", Mutating: true},
175177
"get_latest_positions": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/positions/latest"},
176178
"get_position_history": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/positions/{device_id}/history"},
179+
"list_scenarios": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/scenarios"},
180+
"create_scenario": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/scenarios", Mutating: true},
181+
"get_scenario": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/scenarios/{scenario_id}"},
182+
"update_scenario": {Method: "PUT", Path: "/api/indoor/buildings/{building_id}/scenarios/{scenario_id}", Mutating: true},
183+
"delete_scenario": {Method: "DELETE", Path: "/api/indoor/buildings/{building_id}/scenarios/{scenario_id}", Mutating: true},
184+
"get_stream_status": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/stream/status"},
177185
"create_booking": {Method: "POST", Path: "/api/indoor/buildings/{building_id}/bookings", Mutating: true},
178186
"list_bookings": {Method: "GET", Path: "/api/indoor/buildings/{building_id}/bookings"},
179187
"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
10131021
if op.Mutating && !requireConfirm(params) {
10141022
return "", fmt.Errorf("%s operation %q mutates data; pass confirm=true to proceed", scope, opName)
10151023
}
1024+
if scope == "indoor" && (opName == "import_building" || opName == "upload_floor_plan") {
1025+
data, err := handleIndoorMultipartOperation(client, opName, op.Path, params)
1026+
if err != nil {
1027+
return "", err
1028+
}
1029+
return formatJSON(data), nil
1030+
}
10161031

10171032
path, err := interpolatePath(op.Path, params)
10181033
if err != nil {
@@ -1034,6 +1049,26 @@ func handleScopedAPI(client *Client, params map[string]interface{}, ops map[stri
10341049
return formatJSON(data), nil
10351050
}
10361051

1052+
func handleIndoorMultipartOperation(client *Client, opName, pathTemplate string, params map[string]interface{}) (json.RawMessage, error) {
1053+
path, err := interpolatePath(pathTemplate, params)
1054+
if err != nil {
1055+
return nil, err
1056+
}
1057+
filePath, err := requireString(params, "file_path")
1058+
if err != nil {
1059+
return nil, err
1060+
}
1061+
extraFields := map[string]string{}
1062+
if opName == "upload_floor_plan" {
1063+
if bounds, err := optionalMultipartValue(params, "bounds"); err != nil {
1064+
return nil, err
1065+
} else if bounds != "" {
1066+
extraFields["bounds"] = bounds
1067+
}
1068+
}
1069+
return client.UploadMultipart(path, filePath, "file", extraFields)
1070+
}
1071+
10371072
func requireConfirm(params map[string]interface{}) bool {
10381073
v, ok := params["confirm"]
10391074
if !ok {
@@ -1055,6 +1090,33 @@ func extractBody(params map[string]interface{}) interface{} {
10551090
return nil
10561091
}
10571092

1093+
func optionalMultipartValue(params map[string]interface{}, key string) (string, error) {
1094+
if raw, ok := params[key]; ok && raw != nil {
1095+
return stringifyMultipartValue(raw)
1096+
}
1097+
if body, ok := params["body"].(map[string]interface{}); ok {
1098+
if raw, ok := body[key]; ok && raw != nil {
1099+
return stringifyMultipartValue(raw)
1100+
}
1101+
}
1102+
return "", nil
1103+
}
1104+
1105+
func stringifyMultipartValue(v interface{}) (string, error) {
1106+
switch value := v.(type) {
1107+
case string:
1108+
return value, nil
1109+
case map[string]interface{}, []interface{}:
1110+
data, err := json.Marshal(value)
1111+
if err != nil {
1112+
return "", err
1113+
}
1114+
return string(data), nil
1115+
default:
1116+
return stringify(v)
1117+
}
1118+
}
1119+
10581120
func extractQuery(params map[string]interface{}) (map[string]string, error) {
10591121
raw, ok := params["query"]
10601122
if !ok {

mcp/server_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,147 @@ func TestToolsCall_UploadDatasetScoped(t *testing.T) {
282282
}
283283
}
284284

285+
func TestToolsCall_IndoorImportBuildingUsesMultipartUpload(t *testing.T) {
286+
tmpDir := t.TempDir()
287+
filePath := filepath.Join(tmpDir, "campus.ifc")
288+
if err := os.WriteFile(filePath, []byte("ISO-10303-21;"), 0o600); err != nil {
289+
t.Fatalf("write temp indoor file: %v", err)
290+
}
291+
292+
mux := http.NewServeMux()
293+
mux.HandleFunc("POST /api/indoor/import", func(w http.ResponseWriter, r *http.Request) {
294+
if err := r.ParseMultipartForm(1 << 20); err != nil {
295+
w.WriteHeader(http.StatusBadRequest)
296+
fmt.Fprintf(w, `{"error":"parse=%v"}`, err)
297+
return
298+
}
299+
file, header, err := r.FormFile("file")
300+
if err != nil {
301+
w.WriteHeader(http.StatusBadRequest)
302+
fmt.Fprintf(w, `{"error":"file=%v"}`, err)
303+
return
304+
}
305+
defer file.Close()
306+
if header.Filename != "campus.ifc" {
307+
w.WriteHeader(http.StatusBadRequest)
308+
fmt.Fprintf(w, `{"error":"filename=%s"}`, header.Filename)
309+
return
310+
}
311+
w.Header().Set("Content-Type", "application/json")
312+
w.WriteHeader(http.StatusCreated)
313+
fmt.Fprint(w, `{"id":"building-1","name":"HQ"}`)
314+
})
315+
srv := testServer(t, mux)
316+
317+
resp := sendRequest(t, srv, "tools/call", 241, map[string]interface{}{
318+
"name": "indoor_api",
319+
"arguments": map[string]interface{}{
320+
"operation": "import_building",
321+
"file_path": filePath,
322+
"confirm": true,
323+
},
324+
})
325+
326+
if resp.Error != nil {
327+
t.Fatalf("unexpected error: %v", resp.Error)
328+
}
329+
result, _ := resp.Result.(map[string]interface{})
330+
if isErr, _ := result["isError"].(bool); isErr {
331+
t.Fatalf("expected success result, got error: %+v", result)
332+
}
333+
}
334+
335+
func TestToolsCall_IndoorUploadFloorPlanUsesMultipartBounds(t *testing.T) {
336+
tmpDir := t.TempDir()
337+
filePath := filepath.Join(tmpDir, "floor.png")
338+
if err := os.WriteFile(filePath, []byte("png-bytes"), 0o600); err != nil {
339+
t.Fatalf("write temp plan file: %v", err)
340+
}
341+
342+
mux := http.NewServeMux()
343+
mux.HandleFunc("POST /api/indoor/buildings/{id}/floors/{fid}/plan", func(w http.ResponseWriter, r *http.Request) {
344+
if got := r.PathValue("id"); got != "building-1" {
345+
w.WriteHeader(http.StatusBadRequest)
346+
fmt.Fprintf(w, `{"error":"building=%s"}`, got)
347+
return
348+
}
349+
if got := r.PathValue("fid"); got != "floor-2" {
350+
w.WriteHeader(http.StatusBadRequest)
351+
fmt.Fprintf(w, `{"error":"floor=%s"}`, got)
352+
return
353+
}
354+
if err := r.ParseMultipartForm(1 << 20); err != nil {
355+
w.WriteHeader(http.StatusBadRequest)
356+
fmt.Fprintf(w, `{"error":"parse=%v"}`, err)
357+
return
358+
}
359+
if got := r.FormValue("bounds"); got != "[[1,2],[3,4]]" {
360+
w.WriteHeader(http.StatusBadRequest)
361+
fmt.Fprintf(w, `{"error":"bounds=%s"}`, got)
362+
return
363+
}
364+
w.Header().Set("Content-Type", "application/json")
365+
fmt.Fprint(w, `{"status":"uploaded","floor_id":"floor-2"}`)
366+
})
367+
srv := testServer(t, mux)
368+
369+
resp := sendRequest(t, srv, "tools/call", 242, map[string]interface{}{
370+
"name": "indoor_api",
371+
"arguments": map[string]interface{}{
372+
"operation": "upload_floor_plan",
373+
"building_id": "building-1",
374+
"floor_id": "floor-2",
375+
"file_path": filePath,
376+
"bounds": []interface{}{[]interface{}{1.0, 2.0}, []interface{}{3.0, 4.0}},
377+
"confirm": true,
378+
},
379+
})
380+
381+
if resp.Error != nil {
382+
t.Fatalf("unexpected error: %v", resp.Error)
383+
}
384+
result, _ := resp.Result.(map[string]interface{})
385+
if isErr, _ := result["isError"].(bool); isErr {
386+
t.Fatalf("expected success result, got error: %+v", result)
387+
}
388+
}
389+
390+
func TestToolsCall_IndoorGetScenario(t *testing.T) {
391+
mux := http.NewServeMux()
392+
mux.HandleFunc("GET /api/indoor/buildings/{id}/scenarios/{sid}", func(w http.ResponseWriter, r *http.Request) {
393+
if got := r.PathValue("id"); got != "building-1" {
394+
w.WriteHeader(http.StatusBadRequest)
395+
fmt.Fprintf(w, `{"error":"building=%s"}`, got)
396+
return
397+
}
398+
if got := r.PathValue("sid"); got != "scenario-1" {
399+
w.WriteHeader(http.StatusBadRequest)
400+
fmt.Fprintf(w, `{"error":"scenario=%s"}`, got)
401+
return
402+
}
403+
w.Header().Set("Content-Type", "application/json")
404+
fmt.Fprint(w, `{"id":"scenario-1","name":"Evacuation Drill"}`)
405+
})
406+
srv := testServer(t, mux)
407+
408+
resp := sendRequest(t, srv, "tools/call", 243, map[string]interface{}{
409+
"name": "indoor_api",
410+
"arguments": map[string]interface{}{
411+
"operation": "get_scenario",
412+
"building_id": "building-1",
413+
"scenario_id": "scenario-1",
414+
},
415+
})
416+
417+
if resp.Error != nil {
418+
t.Fatalf("unexpected error: %v", resp.Error)
419+
}
420+
result, _ := resp.Result.(map[string]interface{})
421+
if isErr, _ := result["isError"].(bool); isErr {
422+
t.Fatalf("expected success result, got error: %+v", result)
423+
}
424+
}
425+
285426
func TestToolsCall_ExecuteSQL(t *testing.T) {
286427
mux := http.NewServeMux()
287428
mux.HandleFunc("POST /api/query/sql", func(w http.ResponseWriter, r *http.Request) {

mcp/tools.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,9 +607,10 @@ func AllTools() []Tool {
607607
"list_assets", "create_asset", "update_asset_position",
608608
"navigate", "find_nearest", "navigate_outdoor",
609609
"import_building", "export_imdf", "export_indoorgml",
610-
"get_occupancy", "get_stats", "validate_building", "get_accessibility", "get_historical_analytics",
610+
"get_occupancy", "get_stats", "validate_building", "get_accessibility", "get_dead_zones", "get_reachable", "get_historical_analytics",
611611
"ingest_sensor_data", "get_sensor_data", "get_sensor_heatmap", "get_sensor_timeseries",
612612
"ingest_positions", "get_latest_positions", "get_position_history",
613+
"list_scenarios", "create_scenario", "get_scenario", "update_scenario", "delete_scenario", "get_stream_status",
613614
"create_booking", "list_bookings", "cancel_booking", "checkin_booking",
614615
"list_geofences", "create_geofence", "get_geofence", "update_geofence", "delete_geofence",
615616
"get_evacuation_routes", "trigger_evacuation_alert",
@@ -629,8 +630,11 @@ func AllTools() []Tool {
629630
"device_id": {Type: "string", Description: "Path parameter for device-scoped operations."},
630631
"booking_id": {Type: "string", Description: "Path parameter for booking-scoped operations."},
631632
"geofence_id": {Type: "string", Description: "Path parameter for geofence-scoped operations."},
633+
"scenario_id": {Type: "string", Description: "Path parameter for scenario-scoped operations."},
634+
"file_path": {Type: "string", Description: "Local file path for multipart upload operations such as import_building and upload_floor_plan."},
635+
"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]]."},
632636
"query": {Type: "object", Description: "Optional query string key/value map."},
633-
"body": {Type: "object", Description: "Optional JSON request body."},
637+
"body": {Type: "object", Description: "Optional JSON request body. For upload_floor_plan, bounds may also be provided here."},
634638
"confirm": {Type: "boolean", Description: "Required true for mutating operations."},
635639
},
636640
Required: []string{"operation"},

0 commit comments

Comments
 (0)