848 lines
30 KiB
Go
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, ¶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,
|
|
}
|
|
}
|