feat: cloud Mount() per HIP-0106 (#6)

* feat: cloud Mount() per HIP-0106

Adds pkg/mcp with the canonical Mount(*zip.App, cloud.Deps) entry so the
unified cloud binary can serve the HIP-0300 tool surface under /v1/mcp.

The route layer is pure-Go: it defines an Invoker contract and dispatches
GET /v1/mcp/tools, POST /v1/mcp/tools/:name, plus the standard search and
fetch shortcuts. The CGO-backed hanzomcp package implements Invoker; a
build without CGO mounts the routes and returns 503 until SetInvoker
registers a runtime. Adds schema/mcp.zap describing the typed surface.

* refactor: collapse pkg/go/ → root + flatten cloud import

Repo IS the package. Mount() at root. Consumers use
`github.com/hanzoai/mcp/go` (no /pkg/go/ nesting).
This commit is contained in:
2026-05-18 23:38:03 -07:00
committed by GitHub
parent 9fa8e5f446
commit 69a5fecf84
8 changed files with 464 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
package mcp
import "encoding/json"
// overrideAction rewrites the JSON object body so its "action" key is set
// to the given value. If the body is empty or not a JSON object, returns
// a minimal {"action": v} payload.
func overrideAction(body []byte, v string) []byte {
var m map[string]any
if len(body) == 0 || json.Unmarshal(body, &m) != nil {
out, _ := json.Marshal(map[string]any{"action": v})
return out
}
m["action"] = v
out, _ := json.Marshal(m)
return out
}
+28
View File
@@ -1,3 +1,31 @@
module github.com/hanzoai/mcp/go
go 1.26.3
require (
github.com/hanzoai/cloud v0.0.0-20260519044114-66d5d2a6312c
github.com/hanzoai/zip v0.1.0
github.com/luxfi/log v1.4.3
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/gofiber/fiber/v3 v3.2.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
replace github.com/hanzoai/cloud => ../../cloud
+54
View File
@@ -0,0 +1,54 @@
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8=
github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hanzoai/zip v0.1.0 h1:rsGLHl0cr7EBvp2EQk6KoEizBpOvgU7Uv0JP9GJ7cnw=
github.com/hanzoai/zip v0.1.0/go.mod h1:3G+k2wy5bQ1wld66m0OPH8/LJ0kgIeNi1KPymMmlbf4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"sync"
)
// Invoker is the contract between mcp's HTTP routes and the underlying
// tool runtime. The CGO-backed hanzomcp package implements this; a
// pure-Go cloud build with no MCP backend registers nil and the routes
// return 503.
type Invoker interface {
// CallTool dispatches a tool by name with JSON-encoded params and
// returns the JSON-encoded result envelope.
CallTool(ctx context.Context, name string, paramsJSON []byte) ([]byte, error)
// ListTools returns JSON-encoded definitions for the available tools.
ListTools(ctx context.Context) ([]byte, error)
}
// ErrNoInvoker is returned when the route layer is mounted but no Invoker
// has been registered. Callers see HTTP 503.
var ErrNoInvoker = errors.New("mcp: no invoker registered (build without MCP runtime)")
var (
mu sync.RWMutex
current Invoker
)
// SetInvoker registers the active Invoker. Pass nil to deactivate
// (routes return 503). Safe for concurrent use.
func SetInvoker(inv Invoker) {
mu.Lock()
current = inv
mu.Unlock()
}
// get returns the active Invoker or the null implementation if none
// registered.
func get() Invoker {
mu.RLock()
inv := current
mu.RUnlock()
if inv == nil {
return nullInvoker{}
}
return inv
}
// nullInvoker is the standby when no real backend is wired. Returns
// ErrNoInvoker so route handlers respond with 503.
type nullInvoker struct{}
func (nullInvoker) CallTool(context.Context, string, []byte) ([]byte, error) {
return nil, ErrNoInvoker
}
func (nullInvoker) ListTools(context.Context) ([]byte, error) {
return nil, ErrNoInvoker
}
// errorEnvelope is the JSON shape returned when the runtime fails or
// no invoker is registered.
type errorEnvelope struct {
Success bool `json:"success"`
Error string `json:"error"`
}
func encodeError(msg string) []byte {
b, _ := json.Marshal(errorEnvelope{Success: false, Error: msg})
return b
}
+9
View File
@@ -0,0 +1,9 @@
// Package mcp mounts the Hanzo MCP tool surface under /v1/mcp per HIP-0106.
//
// Hanzo MCP exposes the HIP-0300 unified tool catalog (fs, exec, code, git,
// fetch, workspace, computer, think, memory, hanzo, plan, tasks, mode) over
// HTTP. The Go bridge to the native tool runtime is the CGO package
// github.com/hanzoai/mcp/go/hanzomcp — keeping the route layer pure-Go means
// the unified cloud binary builds without CGO. A CGO-enabled build can
// register an invoker via SetInvoker before app.Listen.
package mcp
+119
View File
@@ -0,0 +1,119 @@
package mcp_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/mcp/go"
"github.com/hanzoai/zip"
)
type stubInvoker struct {
tools []byte
last string
}
func (s *stubInvoker) CallTool(_ context.Context, name string, params []byte) ([]byte, error) {
s.last = name
return []byte(`{"success":true,"content":{"tool":"` + name + `","params":` + string(params) + `}}`), nil
}
func (s *stubInvoker) ListTools(context.Context) ([]byte, error) {
return s.tools, nil
}
func newTestApp(t *testing.T) (*zip.App, *stubInvoker) {
t.Helper()
app := zip.New(zip.Config{DisableStartupMessage: true})
if err := mcp.Mount(app, cloud.Deps{}); err != nil {
t.Fatalf("Mount: %v", err)
}
inv := &stubInvoker{tools: []byte(`{"tools":[{"name":"fs","description":"","inputSchema":""}]}`)}
mcp.SetInvoker(inv)
return app, inv
}
func roundTrip(t *testing.T, app *zip.App, method, target string, body string) (*http.Response, []byte) {
t.Helper()
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, bodyReader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
out, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return resp, out
}
func TestMountHealthWithInvoker(t *testing.T) {
app, _ := newTestApp(t)
defer mcp.SetInvoker(nil)
resp, body := roundTrip(t, app, http.MethodGet, "/v1/mcp/health", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
}
func TestMountHealthNoInvoker(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
if err := mcp.Mount(app, cloud.Deps{}); err != nil {
t.Fatalf("Mount: %v", err)
}
mcp.SetInvoker(nil)
resp, _ := roundTrip(t, app, http.MethodGet, "/v1/mcp/health", "")
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", resp.StatusCode)
}
}
func TestMountListTools(t *testing.T) {
app, _ := newTestApp(t)
defer mcp.SetInvoker(nil)
resp, body := roundTrip(t, app, http.MethodGet, "/v1/mcp/tools", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("invalid json: %v", err)
}
}
func TestMountCallTool(t *testing.T) {
app, inv := newTestApp(t)
defer mcp.SetInvoker(nil)
resp, body := roundTrip(t, app, http.MethodPost, "/v1/mcp/tools/think", `{"action":"think","thought":"hi"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
if inv.last != "think" {
t.Fatalf("invoker.last=%q want think", inv.last)
}
}
func TestMountSearchShortcut(t *testing.T) {
app, inv := newTestApp(t)
defer mcp.SetInvoker(nil)
resp, body := roundTrip(t, app, http.MethodPost, "/v1/mcp/search", `{"query":"hello"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
if inv.last != "fs" {
t.Fatalf("expected fs invocation, got %q", inv.last)
}
}
+115
View File
@@ -0,0 +1,115 @@
package mcp
import (
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/zip"
luxlog "github.com/luxfi/log"
)
// Mount registers Hanzo MCP's HTTP surface per HIP-0106.
//
// Routes (all under /v1/mcp):
//
// GET /v1/mcp/tools — list tool definitions
// POST /v1/mcp/tools/:name — invoke tool {name} with JSON body as params
// POST /v1/mcp/search — search shortcut (delegates to the fs tool)
// POST /v1/mcp/fetch — fetch shortcut (delegates to the fetch tool)
// GET /v1/mcp/health — runtime liveness
//
// Identity comes from gateway-minted X-Org-Id / X-User-Id headers per
// HIP-0026. The MCP runtime is single-process today (see hanzomcp), so
// per-org scoping happens at the tool-action level (e.g. fs root, exec
// cwd) rather than the route layer.
func Mount(app *zip.App, deps cloud.Deps) error {
log := deps.Logger
if log == nil {
log = luxlog.New("module", "mcp")
}
log.Info("mcp: mounting routes", "prefix", "/v1/mcp")
g := app.Group("/v1/mcp")
g.Get("/health", handleHealth)
g.Get("/tools", handleListTools)
g.Post("/tools/:name", handleCallTool)
g.Post("/search", handleSearch)
g.Post("/fetch", handleFetch)
return nil
}
func init() {
cloud.Register("mcp", 160, func(app any, deps cloud.Deps) error {
return Mount(app.(*zip.App), deps)
})
}
// handleHealth returns 200 when an invoker is registered, 503 otherwise.
func handleHealth(c *zip.Ctx) error {
inv := get()
if _, err := inv.ListTools(c.Context()); err != nil {
c.SetHeader("X-MCP-Backend", "none")
return c.JSON(http.StatusServiceUnavailable, map[string]any{
"status": "no-runtime",
"error": err.Error(),
})
}
c.SetHeader("X-MCP-Backend", "ready")
return c.JSON(http.StatusOK, map[string]any{"status": "ok"})
}
// handleListTools returns the registered tool catalog.
func handleListTools(c *zip.Ctx) error {
raw, err := get().ListTools(c.Context())
if err != nil {
return c.Bytes(http.StatusServiceUnavailable, encodeError(err.Error()))
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(http.StatusOK, raw)
}
// handleCallTool dispatches a tool by name. The request body is forwarded
// verbatim as the params JSON.
func handleCallTool(c *zip.Ctx) error {
name := c.Param("name")
if name == "" {
return zip.ErrBadRequest("missing tool name")
}
body := c.Body()
if len(body) == 0 {
body = []byte("{}")
}
raw, err := get().CallTool(c.Context(), name, body)
if err != nil {
return c.Bytes(http.StatusServiceUnavailable, encodeError(err.Error()))
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(http.StatusOK, raw)
}
// handleSearch is the standard MCP search shortcut. It delegates to the
// fs tool's search_text action.
func handleSearch(c *zip.Ctx) error {
// Inject action=search_text into the body. We keep this dumb — the
// caller is expected to send a body the fs tool understands; we only
// fill in the action when omitted.
raw, err := get().CallTool(c.Context(), "fs", overrideAction(c.Body(), "search_text"))
if err != nil {
return c.Bytes(http.StatusServiceUnavailable, encodeError(err.Error()))
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(http.StatusOK, raw)
}
// handleFetch is the standard MCP fetch shortcut. It delegates to the
// fetch tool's get action.
func handleFetch(c *zip.Ctx) error {
raw, err := get().CallTool(c.Context(), "fetch", overrideAction(c.Body(), "get"))
if err != nil {
return c.Bytes(http.StatusServiceUnavailable, encodeError(err.Error()))
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(http.StatusOK, raw)
}
+48
View File
@@ -0,0 +1,48 @@
# Hanzo MCP — ZAP Schema (HIP-0106 / HIP-0300)
#
# Server: hanzoai/mcp mounted at /v1/mcp inside the unified cloud binary.
#
# Code generation:
# zapc generate mcp.zap --lang go --out ./go/pkg/mcp/zap/
# zapc generate mcp.zap --lang ts --out ./src/zap/
# zapc generate mcp.zap --lang py --out ./py/zap/
# zapc generate mcp.zap --lang rust --out ./rust/src/zap/
# ── Tool catalog ─────────────────────────────────────────────────────────
struct ToolDef
name Text
description Text
inputSchema Text # JSON schema, serialized as Text to avoid recursion
struct ToolList
tools List(ToolDef)
# ── Invocation ──────────────────────────────────────────────────────────
struct CallRequest
name Text
params Text # caller-supplied JSON params for the tool action
struct CallResult
success bool
content Text # tool-specific JSON content
error Text # populated when success=false
# ── Health ──────────────────────────────────────────────────────────────
struct Health
status Text # "ok" | "no-runtime"
backend Text # "ready" | "none"
# ── Service interface ───────────────────────────────────────────────────
interface MCP
# List tool definitions registered with the runtime.
list () -> (list ToolList)
# Invoke a tool by name with caller-supplied JSON params.
call (request CallRequest) -> (result CallResult)
# Health probe; reports whether the runtime is wired.
health () -> (status Health)