Files
zeekayandHanzo Dev 9902761419 feat(mcp): publish_dashboard tool + unified-API endpoints (api.hanzo.ai only)
- publish_dashboard: PUT the caller's dashboard doc as the ORG-SHARED default
  (/v1/world/dashboard/shared; backend enforces org-admin, 403 passes through).
  routeTool is method+body-aware now (was GET-only).
- Endpoints: the standalone mcp/zap.world.hanzo.ai hosts are retired (owner
  directive: ONE public surface). Self-describe + docs now point at
  https://api.hanzo.ai/v1/world/mcp and wss://api.hanzo.ai/v1/world/zap —
  the ingress rewrites /v1/world/{mcp,zap} to this binary's native paths.

Build + vet + all package tests green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-23 22:29:06 -07:00

443 lines
14 KiB
Go

package main
// MCP (Model Context Protocol) HTTP handler.
//
// Streamable-HTTP transport: single endpoint that accepts JSON-RPC 2.0
// POST requests and responds with either application/json or text/event-stream.
// Spec: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
//
// Clients connect with:
//
// POST https://api.hanzo.ai/v1/world/mcp
// Authorization: Bearer <IAM_TOKEN>
// {"jsonrpc":"2.0","id":1,"method":"initialize", ...}
//
// Supported methods: initialize, tools/list, tools/call, ping.
// Tools proxy to the worldmonitor backend /v1/world/* endpoints.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/world-zap/auth"
)
const (
mcpProtocolVersion = "2024-11-05"
mcpServerName = "hanzo-world"
mcpServerVersion = version // from main.go
)
// jsonrpcRequest is a minimal JSON-RPC 2.0 request.
type jsonrpcRequest struct {
Jsonrpc string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonrpcResponse struct {
Jsonrpc string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
type jsonrpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
// mcpTool describes one MCP tool exposed to agents.
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
// mcpContent is the common content block type for tool results.
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type mcpToolResult struct {
Content []mcpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// mcpHandler wires tools to an HTTP endpoint.
type mcpHandler struct {
deps *serverDeps
tools []mcpTool
backend string
client *http.Client
}
func newMCPHandler(d *serverDeps) *mcpHandler {
return &mcpHandler{
deps: d,
tools: mcpToolCatalog,
backend: strings.TrimRight(d.cfg.BackendBase, "/"),
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (h *mcpHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Optional SSE stream for server→client notifications. We don't
// push unsolicited events today; return a simple heartbeat stream
// so clients that GET /mcp first don't blow up.
sseHeartbeat(w, r)
case http.MethodPost:
h.handleJSONRPC(w, r)
case http.MethodOptions:
corsPreflight(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *mcpHandler) handleJSONRPC(w http.ResponseWriter, r *http.Request) {
// Auth (skip for initialize + ping so agents can probe)
token := auth.ExtractToken(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB
if err != nil {
writeJSONRPCError(w, nil, -32700, "parse error: "+err.Error())
return
}
var req jsonrpcRequest
if err := json.Unmarshal(body, &req); err != nil {
writeJSONRPCError(w, nil, -32700, "invalid json")
return
}
switch req.Method {
case "initialize":
writeJSONRPCResult(w, req.ID, map[string]any{
"protocolVersion": mcpProtocolVersion,
"serverInfo": map[string]any{"name": mcpServerName, "version": mcpServerVersion},
"capabilities": map[string]any{
"tools": map[string]any{"listChanged": false},
},
})
case "ping":
writeJSONRPCResult(w, req.ID, map[string]any{})
case "tools/list":
if err := h.authenticate(r.Context(), token); err != nil {
writeJSONRPCError(w, req.ID, -32001, "unauthenticated: "+err.Error())
return
}
writeJSONRPCResult(w, req.ID, map[string]any{"tools": h.tools})
case "tools/call":
principal, err := h.authenticatePrincipal(r.Context(), token)
if err != nil {
writeJSONRPCError(w, req.ID, -32001, "unauthenticated: "+err.Error())
return
}
var call struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &call); err != nil {
writeJSONRPCError(w, req.ID, -32602, "invalid params")
return
}
// Rate limit by plan (reuse the same token bucket used for ZAP).
decision := h.deps.limiter.TryAcquire(principal.UserID, principal.Plan)
if !decision.Allowed {
writeJSONRPCError(w, req.ID, -32005, fmt.Sprintf("rate_limited retry_in=%s", decision.RetryIn))
return
}
result, err := h.dispatchTool(r.Context(), token, call.Name, call.Arguments)
if err != nil {
writeJSONRPCResult(w, req.ID, mcpToolResult{
Content: []mcpContent{{Type: "text", Text: err.Error()}},
IsError: true,
})
return
}
writeJSONRPCResult(w, req.ID, result)
case "notifications/initialized":
// No-op notification.
w.WriteHeader(http.StatusAccepted)
default:
writeJSONRPCError(w, req.ID, -32601, "method not found: "+req.Method)
}
}
// authenticate validates the bearer token and returns nothing on success.
func (h *mcpHandler) authenticate(ctx context.Context, token string) error {
_, err := h.authenticatePrincipal(ctx, token)
return err
}
// authenticatePrincipal returns the IAM-resolved principal.
func (h *mcpHandler) authenticatePrincipal(ctx context.Context, token string) (*auth.Principal, error) {
if token == "" {
return nil, fmt.Errorf("missing token")
}
p, err := h.deps.auth.Validate(ctx, token)
if err != nil {
return nil, err
}
return &p, nil
}
// dispatchTool maps a tool name + args to a backend call.
func (h *mcpHandler) dispatchTool(ctx context.Context, token, name string, args map[string]any) (mcpToolResult, error) {
method, path, body, err := routeTool(name, args)
if err != nil {
return mcpToolResult{}, err
}
// Build request to worldmonitor backend
reqURL := h.backend + path
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, rd)
if err != nil {
return mcpToolResult{}, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := h.client.Do(req)
if err != nil {
return mcpToolResult{}, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap
if resp.StatusCode >= 400 {
return mcpToolResult{
Content: []mcpContent{{Type: "text", Text: fmt.Sprintf("upstream %d: %s", resp.StatusCode, string(respBody))}},
IsError: true,
}, nil
}
// Return the raw JSON body as a text block. Agents can parse.
return mcpToolResult{
Content: []mcpContent{{Type: "text", Text: string(respBody)}},
}, nil
}
// routeTool maps a tool name to a /v1/world/* query path with arguments.
func routeTool(name string, args map[string]any) (method, path string, body []byte, err error) {
q := url.Values{}
add := func(k string) {
if v, ok := args[k]; ok {
q.Set(k, fmt.Sprint(v))
}
}
switch name {
case "get_events":
add("region")
add("layers")
add("since")
add("limit")
return http.MethodGet, "/v1/world/events?" + q.Encode(), nil, nil
case "query_conflicts":
add("country")
add("since")
add("severity")
return http.MethodGet, "/v1/world/conflicts?" + q.Encode(), nil, nil
case "query_infrastructure":
add("type")
add("near")
return http.MethodGet, "/v1/world/infra?" + q.Encode(), nil, nil
case "track_vessel":
add("mmsi")
add("imo")
add("name")
return http.MethodGet, "/v1/world/vessel?" + q.Encode(), nil, nil
case "list_live_news":
add("category")
add("limit")
return http.MethodGet, "/v1/world/news?" + q.Encode(), nil, nil
case "get_markets":
add("ticker")
add("category")
return http.MethodGet, "/v1/world/markets?" + q.Encode(), nil, nil
case "list_feeds":
return http.MethodGet, "/v1/world/feeds", nil, nil
case "ask_analyst":
// POST passthrough; we encode question into a POST body via a
// secondary path. Keep GET semantics simple — proxy to chat with the
// question as a system input.
add("question")
return http.MethodGet, "/v1/world/analyst?" + q.Encode(), nil, nil
case "publish_dashboard":
// PUT the caller's dashboard doc as the ORG-SHARED default
// (/v1/world/dashboard/shared). The backend enforces org-admin —
// non-admins get its 403 passed straight through.
doc, ok := args["dashboard"]
if !ok {
return "", "", nil, fmt.Errorf("publish_dashboard: missing required argument 'dashboard'")
}
b, merr := json.Marshal(doc)
if merr != nil {
return "", "", nil, fmt.Errorf("publish_dashboard: dashboard not serializable: %w", merr)
}
return http.MethodPut, "/v1/world/dashboard/shared", b, nil
default:
return "", "", nil, fmt.Errorf("unknown tool: %s", name)
}
}
// mcpToolCatalog describes the 8 tools exposed to agents. Input schemas are
// minimal — the backend does the real validation.
var mcpToolCatalog = []mcpTool{
{
Name: "get_events",
Description: "Fetch current global events from OSINT feeds, optionally filtered by region or layer.",
InputSchema: schemaObject(map[string]any{
"region": schemaString("Geographic region hint (e.g. 'Middle East')"),
"layers": schemaString("Comma-separated layer names (e.g. 'conflicts,fires')"),
"since": schemaString("ISO-8601 timestamp — events after this moment"),
"limit": schemaNumber("Max events to return (default 50)"),
}, nil),
},
{
Name: "query_conflicts",
Description: "Look up recent conflict/violence events (ACLED-style).",
InputSchema: schemaObject(map[string]any{
"country": schemaString("ISO country code"),
"since": schemaString("ISO-8601 timestamp"),
"severity": schemaString("minor|major|critical"),
}, nil),
},
{
Name: "query_infrastructure",
Description: "Query critical infrastructure (undersea cables, military bases, nuclear sites, power grids).",
InputSchema: schemaObject(map[string]any{
"type": schemaString("cable|base|nuclear|power|telecom"),
"near": schemaString("lat,lon,radiusKm (e.g. '48.85,2.35,50')"),
}, []string{"type"}),
},
{
Name: "track_vessel",
Description: "Track a maritime vessel by MMSI, IMO, or name.",
InputSchema: schemaObject(map[string]any{
"mmsi": schemaString("9-digit MMSI"),
"imo": schemaString("7-digit IMO"),
"name": schemaString("vessel name"),
}, nil),
},
{
Name: "list_live_news",
Description: "Aggregate live news items from configured sources.",
InputSchema: schemaObject(map[string]any{
"category": schemaString("world|markets|tech|finance|happy"),
"limit": schemaNumber("Max items (default 20)"),
}, nil),
},
{
Name: "get_markets",
Description: "Fetch market quotes — equities, fx, commodities, crypto.",
InputSchema: schemaObject(map[string]any{
"ticker": schemaString("Single ticker symbol"),
"category": schemaString("equities|fx|commodities|crypto"),
}, nil),
},
{
Name: "list_feeds",
Description: "List all feed channels the caller may subscribe to (ZAP topics, MCP tools).",
InputSchema: schemaObject(nil, nil),
},
{
Name: "ask_analyst",
Description: "Ask the Zen AI analyst a grounded question about the current map context.",
InputSchema: schemaObject(map[string]any{
"question": schemaString("Natural-language question"),
}, []string{"question"}),
},
{
Name: "publish_dashboard",
Description: "Publish a dashboard config as the caller's ORG-SHARED default (org-admin only; every org member hydrates it as their base layout).",
InputSchema: schemaObject(map[string]any{
"dashboard": map[string]any{
"type": "object",
"description": "The dashboard doc (opaque blob — same shape the world.hanzo.ai frontend syncs to /v1/world/dashboard).",
},
}, []string{"dashboard"}),
},
}
// schemaObject builds a minimal JSON Schema for a tool's input.
func schemaObject(props map[string]any, required []string) map[string]any {
if props == nil {
props = map[string]any{}
}
m := map[string]any{
"type": "object",
"properties": props,
}
if len(required) > 0 {
m["required"] = required
}
return m
}
func schemaString(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func schemaNumber(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
func writeJSONRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
resp := jsonrpcResponse{Jsonrpc: "2.0", ID: id, Result: result}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func writeJSONRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
resp := jsonrpcResponse{Jsonrpc: "2.0", ID: id, Error: &jsonrpcError{Code: code, Message: msg}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// sseHeartbeat serves a no-op SSE stream used by clients that GET /mcp first.
func sseHeartbeat(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
fmt.Fprint(w, ": connected\n\n")
fl.Flush()
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-r.Context().Done():
return
case <-t.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
}
}
}
func corsPreflight(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Mcp-Session-Id")
w.WriteHeader(http.StatusNoContent)
}