package handler import ( "bytes" "context" "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "mime" "net/http" "strings" "time" "fire-safety-ymd/internal/domain" "fire-safety-ymd/internal/service" ) const ( mcpProtocolVersion = "2025-06-18" mcpServerName = "fire-safety-ymd-spatial-readonly" mcpServerVersion = "0.2.2" maximumMCPBody = int64(1024 * 1024) maximumToolTimeout = 30 * time.Second toolSearchPlaceCandidates = "fire_safety_search_place_candidates" toolResolveIncidentContext = "fire_safety_resolve_incident_context" toolFindNearbyWaterSources = "fire_safety_find_nearby_water_sources" toolFindCommandPostCandidates = "fire_safety_find_command_post_candidates" toolListNearbyAccessLines = "fire_safety_list_nearby_access_lines" toolGetResponsibleUnits = "fire_safety_get_responsible_units" toolFindNearbyRiskAreas = "fire_safety_find_nearby_risk_areas" ) // MCPSpatialService is the use-case surface exposed through the MCP handler. type MCPSpatialService interface { SearchPlaceCandidates(context.Context, string, int) (service.QueryResult[[]domain.PlaceCandidate], error) ResolveIncidentContext(context.Context, domain.Coordinate) (service.QueryResult[[]domain.IncidentContext], error) FindNearbyWaterSources(context.Context, domain.Coordinate, float64, int) (service.QueryResult[[]domain.WaterSource], error) FindCommandPostCandidates(context.Context, domain.Coordinate, float64, int) (service.QueryResult[[]domain.CommandPostCandidate], error) ListNearbyAccessLines(context.Context, domain.Coordinate, float64, int) (service.QueryResult[[]domain.AccessLine], error) GetResponsibleUnits(context.Context, domain.Coordinate) (service.QueryResult[[]domain.ResponsibleUnit], error) FindNearbyRiskAreas(context.Context, domain.Coordinate, float64, int) (service.QueryResult[[]domain.RiskArea], error) } // MCPOptions contains the independently authenticated MCP transport settings. type MCPOptions struct { AuthToken string MaxBodyBytes int64 ToolTimeout time.Duration Logger *log.Logger } // MCPHandler implements the stateless JSON response subset of MCP Streamable HTTP. type MCPHandler struct { service MCPSpatialService authToken string maxBodyBytes int64 toolTimeout time.Duration logger *log.Logger } // NewMCPHandler constructs a protected, read-only MCP HTTP handler. func NewMCPHandler(spatialService MCPSpatialService, options MCPOptions) (*MCPHandler, error) { if spatialService == nil { return nil, errors.New("MCP spatial service is required") } if !validMCPToken(options.AuthToken) { return nil, errors.New("MCP auth token must contain at least 32 printable ASCII characters") } if options.MaxBodyBytes <= 0 || options.MaxBodyBytes > maximumMCPBody { return nil, errors.New("MCP max body bytes is outside the supported range") } if options.ToolTimeout <= 0 || options.ToolTimeout > maximumToolTimeout { return nil, errors.New("MCP tool timeout is outside the supported range") } logger := options.Logger if logger == nil { logger = log.New(io.Discard, "", 0) } return &MCPHandler{ service: spatialService, authToken: options.AuthToken, maxBodyBytes: options.MaxBodyBytes, toolTimeout: options.ToolTimeout, logger: logger, }, nil } func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { started := time.Now() requestID := safeRequestID(r.Header.Get("X-Request-ID")) w.Header().Set("X-Request-ID", requestID) w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) w.WriteHeader(http.StatusMethodNotAllowed) return } if r.Header.Get("Origin") != "" { h.writeRPCError(w, http.StatusForbidden, nil, -32003, "MCP_ORIGIN_FORBIDDEN", "MCP endpoint does not accept browser-origin requests.") h.logResult(requestID, "transport", "forbidden_origin", started) return } if !h.validAuthorization(r.Header.Get("Authorization")) { w.Header().Set("WWW-Authenticate", `Bearer realm="fire-safety-ymd-mcp"`) h.writeRPCError(w, http.StatusUnauthorized, nil, -32001, "MCP_AUTH_INVALID", "MCP authentication failed.") h.logResult(requestID, "transport", "unauthorized", started) return } if !isJSONContentType(r.Header.Get("Content-Type")) { h.writeRPCError(w, http.StatusUnsupportedMediaType, nil, -32600, "MCP_CONTENT_TYPE_INVALID", "Content-Type must be application/json.") h.logResult(requestID, "transport", "unsupported_media_type", started) return } body, tooLarge, err := readBoundedBody(r.Body, h.maxBodyBytes) if err != nil { h.writeRPCError(w, http.StatusBadRequest, nil, -32700, "MCP_REQUEST_INVALID", "Unable to read MCP request.") h.logResult(requestID, "transport", "read_error", started) return } if tooLarge { h.writeRPCError(w, http.StatusRequestEntityTooLarge, nil, -32002, "MCP_REQUEST_BODY_TOO_LARGE", "MCP request body exceeds the configured limit.") h.logResult(requestID, "transport", "body_too_large", started) return } request, rpcFailure := decodeRPCRequest(body) if rpcFailure != nil { h.writeRPCError(w, rpcFailure.httpStatus, nil, rpcFailure.rpcCode, rpcFailure.code, rpcFailure.message) h.logResult(requestID, "transport", rpcFailure.code, started) return } if len(request.ID) == 0 && request.Method != "notifications/initialized" { w.WriteHeader(http.StatusAccepted) h.logResult(requestID, "notification", "ignored", started) return } switch request.Method { case "initialize": h.logResult(requestID, "initialize", h.handleInitialize(w, request), started) case "notifications/initialized": w.WriteHeader(http.StatusAccepted) h.logResult(requestID, "notifications/initialized", "accepted", started) case "tools/list": h.writeRPCResult(w, request.ID, map[string]any{"tools": mcpToolDefinitions()}) h.logResult(requestID, "tools/list", "success", started) case "tools/call": h.handleToolCall(w, r, request, requestID, started) default: h.writeRPCError(w, http.StatusOK, request.ID, -32601, "MCP_METHOD_NOT_FOUND", "MCP method not found.") h.logResult(requestID, "unknown", "method_not_found", started) } } func (h *MCPHandler) handleInitialize(w http.ResponseWriter, request rpcRequest) string { var params struct { ProtocolVersion json.RawMessage `json:"protocolVersion"` } result := "compatibility_success" if len(request.Params) != 0 && json.Unmarshal(request.Params, ¶ms) == nil { var protocolVersion string if json.Unmarshal(params.ProtocolVersion, &protocolVersion) == nil && protocolVersion == mcpProtocolVersion { result = "direct_success" } } h.writeRPCResult(w, request.ID, map[string]any{ "protocolVersion": mcpProtocolVersion, "capabilities": map[string]any{ "tools": map[string]any{"listChanged": false}, }, "serverInfo": map[string]any{ "name": mcpServerName, "title": "fire-safety-ymd spatial read-only MCP", "version": mcpServerVersion, }, "instructions": "Read-only planning support. Source records with invalid geometries are excluded, so results may be incomplete. Place-name matches are candidates that require user confirmation before coordinate-based analysis. Treat all records as potentially stale, verify resource availability and field safety, and never present access-line candidates as confirmed routes or responsibility records as live team locations.", }) return result } func (h *MCPHandler) handleToolCall(w http.ResponseWriter, r *http.Request, request rpcRequest, requestID string, started time.Time) { var params struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } if len(request.Params) == 0 || json.Unmarshal(request.Params, ¶ms) != nil || strings.TrimSpace(params.Name) == "" { h.writeRPCError(w, http.StatusOK, request.ID, -32602, "MCP_TOOL_PARAMS_INVALID", "MCP tool call parameters are invalid.") h.logResult(requestID, "tools/call", "invalid_params", started) return } if len(params.Arguments) == 0 || bytes.Equal(params.Arguments, []byte("null")) { params.Arguments = json.RawMessage(`{}`) } toolCtx, cancel := context.WithTimeout(r.Context(), h.toolTimeout) defer cancel() output, err := h.callTool(toolCtx, params.Name, params.Arguments) if err != nil { code, message := publicToolError(err) h.writeToolResult(w, request.ID, toolErrorPayload(code, message), true) h.logResult(requestID, safeToolName(params.Name), code, started) return } h.writeToolResult(w, request.ID, output, false) h.logResult(requestID, safeToolName(params.Name), "success", started) } func (h *MCPHandler) callTool(ctx context.Context, name string, arguments json.RawMessage) (any, error) { switch name { case toolSearchPlaceCandidates: placeName, limit, err := decodePlaceSearchArguments(arguments) if err != nil { return nil, err } return h.service.SearchPlaceCandidates(ctx, placeName, limit) case toolResolveIncidentContext: point, err := decodePointArguments(arguments) if err != nil { return nil, err } return h.service.ResolveIncidentContext(ctx, point) case toolFindNearbyWaterSources: point, radius, limit, err := decodeNearbyArguments(arguments, 30_000) if err != nil { return nil, err } return h.service.FindNearbyWaterSources(ctx, point, radius, limit) case toolFindCommandPostCandidates: point, radius, limit, err := decodeNearbyArguments(arguments, 20_000) if err != nil { return nil, err } return h.service.FindCommandPostCandidates(ctx, point, radius, limit) case toolListNearbyAccessLines: point, radius, limit, err := decodeNearbyArguments(arguments, 10_000) if err != nil { return nil, err } return h.service.ListNearbyAccessLines(ctx, point, radius, limit) case toolGetResponsibleUnits: point, err := decodePointArguments(arguments) if err != nil { return nil, err } return h.service.GetResponsibleUnits(ctx, point) case toolFindNearbyRiskAreas: point, radius, limit, err := decodeNearbyArguments(arguments, 10_000) if err != nil { return nil, err } return h.service.FindNearbyRiskAreas(ctx, point, radius, limit) default: return nil, errToolNotFound } } func decodePlaceSearchArguments(raw json.RawMessage) (string, int, error) { var arguments struct { PlaceName *string `json:"place_name"` Limit *int `json:"limit"` } if err := decodeStrictObject(raw, &arguments); err != nil || arguments.PlaceName == nil { return "", 0, fmt.Errorf("%w: place_name is required and unknown fields are not allowed", service.ErrInvalidArgument) } var limit int if arguments.Limit != nil { limit = *arguments.Limit if limit < 1 || limit > 20 { return "", 0, fmt.Errorf("%w: limit must be between 1 and 20", service.ErrInvalidArgument) } } return *arguments.PlaceName, limit, nil } func (h *MCPHandler) writeToolResult(w http.ResponseWriter, id json.RawMessage, output any, isError bool) { textContent, err := json.Marshal(output) if err != nil { output = toolErrorPayload("INTERNAL_ERROR", "MCP tool result could not be encoded.") textContent, _ = json.Marshal(output) isError = true } h.writeRPCResult(w, id, map[string]any{ "content": []map[string]string{{"type": "text", "text": string(textContent)}}, "structuredContent": output, "isError": isError, }) } func (h *MCPHandler) writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { writeJSON(w, http.StatusOK, rpcResponse{ JSONRPC: "2.0", ID: normalizedID(id), Result: result, }) } func (h *MCPHandler) writeRPCError(w http.ResponseWriter, status int, id json.RawMessage, rpcCode int, code, message string) { writeJSON(w, status, rpcResponse{ JSONRPC: "2.0", ID: normalizedID(id), Error: &rpcError{ Code: rpcCode, Message: message, Data: map[string]string{"code": code}, }, }) } func (h *MCPHandler) validAuthorization(value string) bool { expected := "Bearer " + h.authToken if len(value) != len(expected) { return false } return subtle.ConstantTimeCompare([]byte(value), []byte(expected)) == 1 } func validMCPToken(value string) bool { if len(value) < 32 || len(value) > 4096 { return false } for _, character := range value { if character <= 0x20 || character >= 0x7f { return false } } return true } func (h *MCPHandler) logResult(requestID, operation, result string, started time.Time) { h.logger.Printf("mcp_request request_id=%s operation=%s result=%s duration_ms=%d", requestID, operation, result, time.Since(started).Milliseconds()) } type rpcRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } type rpcResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } type rpcError struct { Code int `json:"code"` Message string `json:"message"` Data map[string]string `json:"data,omitempty"` } type rpcFailure struct { httpStatus int rpcCode int code string message string } func decodeRPCRequest(body []byte) (rpcRequest, *rpcFailure) { if !json.Valid(body) { return rpcRequest{}, &rpcFailure{httpStatus: http.StatusBadRequest, rpcCode: -32700, code: "MCP_REQUEST_INVALID", message: "MCP request JSON is invalid."} } trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 || trimmed[0] != '{' { return rpcRequest{}, &rpcFailure{httpStatus: http.StatusBadRequest, rpcCode: -32600, code: "MCP_REQUEST_INVALID", message: "MCP request must be one JSON-RPC object."} } var request rpcRequest if err := json.Unmarshal(trimmed, &request); err != nil || request.JSONRPC != "2.0" || strings.TrimSpace(request.Method) == "" { return rpcRequest{}, &rpcFailure{httpStatus: http.StatusBadRequest, rpcCode: -32600, code: "MCP_REQUEST_INVALID", message: "MCP request is not a valid JSON-RPC 2.0 request."} } if len(request.ID) > 0 && !validRPCID(request.ID) { return rpcRequest{}, &rpcFailure{httpStatus: http.StatusBadRequest, rpcCode: -32600, code: "MCP_REQUEST_INVALID", message: "MCP request has an invalid JSON-RPC id."} } return request, nil } func validRPCID(id json.RawMessage) bool { trimmed := bytes.TrimSpace(id) if bytes.Equal(trimmed, []byte("null")) { return true } if len(trimmed) == 0 { return false } if trimmed[0] == '"' { var value string return json.Unmarshal(trimmed, &value) == nil } var value json.Number decoder := json.NewDecoder(bytes.NewReader(trimmed)) decoder.UseNumber() return decoder.Decode(&value) == nil } func decodePointArguments(raw json.RawMessage) (domain.Coordinate, error) { var arguments struct { Longitude *float64 `json:"longitude"` Latitude *float64 `json:"latitude"` } if err := decodeStrictObject(raw, &arguments); err != nil || arguments.Longitude == nil || arguments.Latitude == nil { return domain.Coordinate{}, fmt.Errorf("%w: longitude and latitude are required", service.ErrInvalidArgument) } return domain.Coordinate{Longitude: *arguments.Longitude, Latitude: *arguments.Latitude}, nil } func decodeNearbyArguments(raw json.RawMessage, maximumRadius float64) (domain.Coordinate, float64, int, error) { var arguments struct { Longitude *float64 `json:"longitude"` Latitude *float64 `json:"latitude"` RadiusMeters *float64 `json:"radius_meters"` Limit *int `json:"limit"` } if err := decodeStrictObject(raw, &arguments); err != nil || arguments.Longitude == nil || arguments.Latitude == nil { return domain.Coordinate{}, 0, 0, fmt.Errorf("%w: longitude and latitude are required and unknown fields are not allowed", service.ErrInvalidArgument) } var radius float64 if arguments.RadiusMeters != nil { radius = *arguments.RadiusMeters if radius < 100 || radius > maximumRadius { return domain.Coordinate{}, 0, 0, fmt.Errorf("%w: radius_meters is outside the tool limit", service.ErrInvalidArgument) } } var limit int if arguments.Limit != nil { limit = *arguments.Limit if limit < 1 || limit > 20 { return domain.Coordinate{}, 0, 0, fmt.Errorf("%w: limit must be between 1 and 20", service.ErrInvalidArgument) } } return domain.Coordinate{Longitude: *arguments.Longitude, Latitude: *arguments.Latitude}, radius, limit, nil } func decodeStrictObject(raw json.RawMessage, target any) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || trimmed[0] != '{' { return errors.New("arguments must be an object") } decoder := json.NewDecoder(bytes.NewReader(trimmed)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return errors.New("arguments contain trailing JSON") } return nil } var errToolNotFound = errors.New("MCP tool not found") func publicToolError(err error) (string, string) { switch { case errors.Is(err, errToolNotFound): return "TOOL_NOT_FOUND", "MCP tool not found." case errors.Is(err, service.ErrInvalidArgument): return "INVALID_ARGUMENT", "MCP tool arguments are invalid." case errors.Is(err, service.ErrQueryTimeout), errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): return "QUERY_TIMEOUT", "Spatial query did not complete within its time limit." case errors.Is(err, service.ErrDataSourceUnavailable): return "DATA_SOURCE_UNAVAILABLE", "Spatial data source is unavailable." default: return "INTERNAL_ERROR", "MCP tool failed." } } func toolErrorPayload(code, message string) map[string]any { return map[string]any{ "status": "error", "data": []any{}, "metadata": map[string]any{ "generated_at": time.Now().UTC().Format(time.RFC3339Nano), "data_sources": []string{}, "spatial_reference": "EPSG:4326", "result_count": 0, }, "warnings": []string{}, "error": map[string]string{ "code": code, "message": message, }, } } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func normalizedID(id json.RawMessage) json.RawMessage { if len(id) == 0 { return json.RawMessage("null") } return id } func readBoundedBody(reader io.Reader, maximum int64) ([]byte, bool, error) { body, err := io.ReadAll(io.LimitReader(reader, maximum+1)) if err != nil { return nil, false, err } if int64(len(body)) > maximum { return nil, true, nil } return body, false, nil } func isJSONContentType(value string) bool { mediaType, _, err := mime.ParseMediaType(value) return err == nil && mediaType == "application/json" } func safeRequestID(candidate string) string { candidate = strings.TrimSpace(candidate) if candidate != "" && len(candidate) <= 128 { valid := true for _, character := range candidate { if !(character >= 'a' && character <= 'z') && !(character >= 'A' && character <= 'Z') && !(character >= '0' && character <= '9') && !strings.ContainsRune("._:-", character) { valid = false break } } if valid { return candidate } } random := make([]byte, 16) if _, err := rand.Read(random); err == nil { return hex.EncodeToString(random) } return "request-id-unavailable" } func safeToolName(name string) string { for _, known := range []string{ toolSearchPlaceCandidates, toolResolveIncidentContext, toolFindNearbyWaterSources, toolFindCommandPostCandidates, toolListNearbyAccessLines, toolGetResponsibleUnits, toolFindNearbyRiskAreas, } { if name == known { return known } } return "unknown_tool" } type mcpToolDefinition struct { Name string `json:"name"` Title string `json:"title"` Description string `json:"description"` InputSchema map[string]any `json:"inputSchema"` OutputSchema map[string]any `json:"outputSchema"` Annotations map[string]any `json:"annotations"` } func mcpToolDefinitions() []mcpToolDefinition { return []mcpToolDefinition{ { Name: toolSearchPlaceCandidates, Title: "按地名搜索位置候选", Description: "在现有森林防火记录的名称、镇街和村庄字段中搜索地名,返回有界候选和 WGS84 坐标。候选必须由用户确认;线面记录只返回代表点,不能直接当作演练点。", InputSchema: placeSearchInputSchema(), OutputSchema: outputEnvelopeSchema(placeCandidateItemSchema()), Annotations: readOnlyAnnotations(), }, { Name: toolResolveIncidentContext, Title: "定位演练点所属防火网格", Description: "根据 WGS84 坐标查询覆盖该点的防火网格和镇街上下文。只返回区域信息,不返回负责人或电话。", InputSchema: pointInputSchema(), OutputSchema: outputEnvelopeSchema(incidentContextItemSchema()), Annotations: readOnlyAnnotations(), }, { Name: toolFindNearbyWaterSources, Title: "查询附近候选水源", Description: "查询演练点附近的水源地和蓄水池并按距离排序。记录存在不代表当前可用,必须现场确认水量、取水条件和道路可达性。", InputSchema: nearbyInputSchema(30_000, 10_000), OutputSchema: outputEnvelopeSchema(waterSourceItemSchema()), Annotations: readOnlyAnnotations(), }, { Name: toolFindCommandPostCandidates, Title: "查询指挥部候选设施", Description: "查询演练点附近的防火检查站和瞭望哨候选点。只代表空间候选,不能直接确定为指挥部,需现场核验安全、通信、容量和可达性。", InputSchema: nearbyInputSchema(20_000, 10_000), OutputSchema: outputEnvelopeSchema(commandPostItemSchema()), Annotations: readOnlyAnnotations(), }, { Name: toolListNearbyAccessLines, Title: "查询附近防火通道候选", Description: "查询演练点附近已绘制的防火通道及最近接入点。不是路径规划工具,不代表道路当前可通行。", InputSchema: nearbyInputSchema(10_000, 5_000), OutputSchema: outputEnvelopeSchema(accessLineItemSchema()), Annotations: readOnlyAnnotations(), }, { Name: toolGetResponsibleUnits, Title: "查询责任防火队伍", Description: "根据坐标查询防火网格中记录的责任中队名称。不提供人员联系方式,也不表示队伍实时位置、战备状态或正式集结点。", InputSchema: pointInputSchema(), OutputSchema: outputEnvelopeSchema(responsibleUnitItemSchema()), Annotations: readOnlyAnnotations(), }, { Name: toolFindNearbyRiskAreas, Title: "查询周边风险区域", Description: "查询演练点周边的墓地坟区和林区工矿企业范围。结果用于提示进一步核验,不代表实时危险程度。", InputSchema: nearbyInputSchema(10_000, 3_000), OutputSchema: outputEnvelopeSchema(riskAreaItemSchema()), Annotations: readOnlyAnnotations(), }, } } func placeSearchInputSchema() map[string]any { return map[string]any{ "type": "object", "additionalProperties": false, "properties": map[string]any{ "place_name": map[string]any{ "type": "string", "minLength": 2, "maxLength": 100, "description": "Town, village, grid, facility, water source, access line, or risk-area name", }, "limit": map[string]any{ "type": "integer", "minimum": 1, "maximum": 20, "default": 10, "description": "Maximum number of candidates", }, }, "required": []string{"place_name"}, } } func readOnlyAnnotations() map[string]any { return map[string]any{ "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false, } } func pointInputSchema() map[string]any { return map[string]any{ "type": "object", "additionalProperties": false, "properties": map[string]any{ "longitude": numberSchema("WGS84 longitude", -180, 180), "latitude": numberSchema("WGS84 latitude", -90, 90), }, "required": []string{"longitude", "latitude"}, } } func nearbyInputSchema(maximumRadius, defaultRadius float64) map[string]any { properties := pointInputSchema()["properties"].(map[string]any) properties["radius_meters"] = map[string]any{ "type": "number", "minimum": 100, "maximum": maximumRadius, "default": defaultRadius, "description": "Search radius in meters", } properties["limit"] = map[string]any{ "type": "integer", "minimum": 1, "maximum": 20, "default": 10, "description": "Maximum number of results", } return map[string]any{ "type": "object", "additionalProperties": false, "properties": properties, "required": []string{"longitude", "latitude"}, } } func numberSchema(description string, minimum, maximum float64) map[string]any { return map[string]any{ "type": "number", "minimum": minimum, "maximum": maximum, "description": description, } } func stringSchema() map[string]any { return map[string]any{"type": "string", "maxLength": 4096} } func boolSchema() map[string]any { return map[string]any{"type": "boolean"} } func coordinateSchema() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "longitude": numberSchema("WGS84 longitude", -180, 180), "latitude": numberSchema("WGS84 latitude", -90, 90), }, "required": []string{"longitude", "latitude"}, } } func outputEnvelopeSchema(itemSchema map[string]any) map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "status": map[string]any{"type": "string", "enum": []string{"ok", "no_results", "error"}}, "data": map[string]any{"type": "array", "items": itemSchema}, "metadata": map[string]any{ "type": "object", "properties": map[string]any{ "generated_at": stringSchema(), "data_sources": map[string]any{"type": "array", "items": stringSchema()}, "spatial_reference": stringSchema(), "result_count": map[string]any{"type": "integer", "minimum": 0}, "search_radius_meters": map[string]any{"type": "number", "minimum": 0}, }, "required": []string{"generated_at", "data_sources", "spatial_reference", "result_count"}, }, "warnings": map[string]any{"type": "array", "items": stringSchema()}, "error": map[string]any{ "type": "object", "properties": map[string]any{"code": stringSchema(), "message": stringSchema()}, "required": []string{"code", "message"}, }, }, "required": []string{"status", "data", "metadata", "warnings"}, } } func incidentContextItemSchema() map[string]any { return objectItemSchema(map[string]any{ "grid_id": stringSchema(), "town": stringSchema(), "area_label": stringSchema(), }, []string{"grid_id"}) } func placeCandidateItemSchema() map[string]any { return objectItemSchema(map[string]any{ "source_record_id": stringSchema(), "place_type": stringSchema(), "name": stringSchema(), "town": stringSchema(), "village": stringSchema(), "matched_field": map[string]any{"type": "string", "enum": []string{"name", "town", "village"}}, "matched_text": stringSchema(), "match_kind": map[string]any{"type": "string", "enum": []string{"exact", "partial"}}, "location": coordinateSchema(), "location_kind": map[string]any{"type": "string", "enum": []string{"recorded_point", "representative_point"}}, }, []string{"source_record_id", "place_type", "matched_field", "matched_text", "match_kind", "location", "location_kind"}) } func waterSourceItemSchema() map[string]any { return objectItemSchema(map[string]any{ "source_record_id": stringSchema(), "category": stringSchema(), "name": stringSchema(), "town": stringSchema(), "village": stringSchema(), "location": coordinateSchema(), "distance_meters": map[string]any{"type": "number", "minimum": 0}, "capacity_cubic_meters": map[string]any{"type": "number"}, "resource_type": stringSchema(), "reported_status": stringSchema(), "source_timestamp_raw": map[string]any{"type": "integer"}, }, []string{"source_record_id", "category", "location", "distance_meters"}) } func commandPostItemSchema() map[string]any { return objectItemSchema(map[string]any{ "source_record_id": stringSchema(), "facility_type": stringSchema(), "name": stringSchema(), "town": stringSchema(), "village": stringSchema(), "location": coordinateSchema(), "distance_meters": map[string]any{"type": "number", "minimum": 0}, "reported_status": stringSchema(), "management_unit": stringSchema(), }, []string{"source_record_id", "facility_type", "location", "distance_meters"}) } func accessLineItemSchema() map[string]any { return objectItemSchema(map[string]any{ "source_record_id": stringSchema(), "name": stringSchema(), "town": stringSchema(), "distance_meters": map[string]any{"type": "number", "minimum": 0}, "nearest_point": coordinateSchema(), "length_meters": map[string]any{"type": "number"}, "source_updated_raw": stringSchema(), }, []string{"source_record_id", "distance_meters", "nearest_point"}) } func responsibleUnitItemSchema() map[string]any { return objectItemSchema(map[string]any{ "grid_id": stringSchema(), "town": stringSchema(), "area_label": stringSchema(), "fire_team": stringSchema(), "live_location_available": boolSchema(), "assembly_site_available": boolSchema(), }, []string{"grid_id", "live_location_available", "assembly_site_available"}) } func riskAreaItemSchema() map[string]any { return objectItemSchema(map[string]any{ "source_record_id": stringSchema(), "risk_type": stringSchema(), "name": stringSchema(), "town": stringSchema(), "village": stringSchema(), "direction": stringSchema(), "covers_point": boolSchema(), "distance_meters": map[string]any{"type": "number", "minimum": 0}, }, []string{"source_record_id", "risk_type", "covers_point", "distance_meters"}) } func objectItemSchema(properties map[string]any, required []string) map[string]any { return map[string]any{ "type": "object", "additionalProperties": false, "properties": properties, "required": required, } }