Files
fire-safety-ymd/internal/handler/mcp.go
2026-09-05 19:33:45 +08:00

848 lines
30 KiB
Go

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, &params) == 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, &params) != 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,
}
}