mcp: a plugin answers its own door, and the signpost moves to the host that moved it
Every per-app plugin binary answered POST /mcp with 308 to /v1/mcp, and then 404
there. Measured on bin/kms and bin/tasks before this change; the door a subsystem
owns was unreachable over HTTP.
The console's terminal handler sent it. Its reasoning was that a plugin serving
its own door at the framework default matches a real route and never reaches a
terminal handler — so anything arriving there had to be a caller who guessed the
default on a host that moved it. That is false for a whole class of app: zip
mounts the /mcp route only when there is something to expose (installMCP), and a
plugin whose typed ops live on the internal plane has an EMPTY edge registry.
kms is the honest example — its four secret ops are on cloud.Plane() precisely so
no route runs from the edge to a secret — so kms has no route at its own door,
fell through to the console, and was told its door lived at an address only a
host serves.
The cost is not cosmetic. The fleet composes tools/list by asking every child at
FrameworkMCPPath and reading any non-2xx as an outage (fleet/fleet.go ask), so
such a child drops out of the composed list AND is reported down — for the crime
of having no tools. An empty list is the honest answer and it is a 200.
Two changes, each in the place that holds the fact:
The console SERVES the door instead of redirecting it. The door is not the route:
it is zip.App.MCP, a frame in and a frame out, which exists whether or not
anything was mounted over it. Serving zip's own door is not a second surface;
re-implementing one, or redirecting to a door this process does not have, is.
The signpost moves to fleet.Mount, which is the one place that KNOWS the door
moved, because it is the call that registers the new address — and it registers
the signpost only when the two differ. A host still tells a caller who guessed
/mcp where /v1/mcp is; a plugin, which moved nothing, no longer claims it did.
Verified against freshly built binaries, not by reading code:
bin/kms POST /mcp initialize -> 200, protocolVersion 2025-06-18, serverInfo kms
POST /mcp tools/list -> 200 {"tools":[]} (was 308 -> 404)
bin/blueprint POST /mcp tools/list -> 200, 2 tools, both carrying lifted prose
POST /mcp tools/call -> 200, get_v1_blueprint_health returned live data
This commit is contained in:
@@ -4,6 +4,7 @@ package fleet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
@@ -72,12 +73,44 @@ type Door struct {
|
||||
// apps is the deployment's COMPOSED set (cmd/cloud's `composed`), never the whole
|
||||
// manifest: a deployment that does not run a subsystem must not offer its tools,
|
||||
// for the same reason it must not publish its routes.
|
||||
// It also signposts the address the door LEFT, and that belongs here rather than
|
||||
// in the console's terminal handler, which is where it used to live. The console
|
||||
// answered an unclaimed [manifest.FrameworkMCPPath] with "the door moved" on the
|
||||
// reasoning that a plugin serving its own door there would match a real route and
|
||||
// never reach it. Untrue: zip mounts that route only when the app has something
|
||||
// to expose (installMCP), so a plugin whose typed ops live on the internal plane
|
||||
// — kms, whose four secret ops are on cloud.Plane() precisely so no route runs
|
||||
// from the edge to a secret — has NO route at its own door, fell through to the
|
||||
// console, and was told its door was at an address only a host serves. Measured:
|
||||
// POST /mcp -> 308, then POST /v1/mcp -> 404, and the fleet reads that non-2xx as
|
||||
// an outage for a child that is serving fine.
|
||||
//
|
||||
// A signpost is only true where the door actually moved, and this is the one
|
||||
// place that knows it did — the same call that registers the target names it.
|
||||
func Mount(host *zip.App, path string, apps []string, at At) *Door {
|
||||
d := &Door{host: host, at: at, apps: apps, owner: map[string]string{}}
|
||||
host.Post(path, d.serve)
|
||||
if path != manifest.FrameworkMCPPath {
|
||||
host.All(manifest.FrameworkMCPPath, signpost(path))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// signpost answers the framework default on a host that moved its door.
|
||||
//
|
||||
// 308 preserves method AND body, so a POSTed initialize or tools/list arrives at
|
||||
// the real door instead of being retried as a GET or answered with the console
|
||||
// shell. The body is JSON for the same reason the hop exists at all: a caller who
|
||||
// reads bytes rather than following it must never get HTML here. It carries no
|
||||
// tool list — a signpost is not a second door.
|
||||
func signpost(door string) zip.Handler {
|
||||
body := map[string]string{"error": "the MCP door moved", "door": door}
|
||||
return func(c *zip.Ctx) error {
|
||||
c.SetHeader("Location", door)
|
||||
return c.JSON(http.StatusPermanentRedirect, body)
|
||||
}
|
||||
}
|
||||
|
||||
// message is one JSON-RPC 2.0 envelope, in the shape this door reads it.
|
||||
type message struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
|
||||
+27
-10
@@ -40,6 +40,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/brand"
|
||||
zapmcp "github.com/zap-proto/mcp"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -86,8 +87,13 @@ func consoleTitle(host string) string {
|
||||
// bytes of its own (see the package doc). A nil fsys means this process serves no
|
||||
// console: the catch-all still keeps the API namespaces honest and still answers
|
||||
// the agent door, and a console path gets a 503 that says so.
|
||||
// app is not only where the catch-all is registered: it is also where this
|
||||
// process's AGENT DOOR comes from. zip.App.MCP is that door as a value — a frame
|
||||
// in, a frame out — and the terminal handler answers with it at the address the
|
||||
// door lives (see mcp.go). Nothing is configured and nothing is duplicated; the
|
||||
// console is simply handed the door the app already has.
|
||||
func Mount(app *zip.App, fsys fs.FS) error {
|
||||
h, err := Handler(fsys)
|
||||
h, err := Handler(fsys, app.MCP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,11 +102,11 @@ func Mount(app *zip.App, fsys fs.FS) error {
|
||||
}
|
||||
|
||||
// Handler is the console as a stdlib http.Handler (correct Content-Type,
|
||||
// conditional GET, precompressed negotiation, SPA fallback) — the form Mount
|
||||
// adapts onto the zip router via zip.AdaptNetHTTP, and the form a test drives
|
||||
// directly.
|
||||
func Handler(fsys fs.FS) (http.Handler, error) {
|
||||
return newConsoleHandler(fsys)
|
||||
// conditional GET, precompressed negotiation, SPA fallback) plus this process's
|
||||
// agent door — the form Mount adapts onto the zip router via zip.AdaptNetHTTP,
|
||||
// and the form a test drives directly.
|
||||
func Handler(fsys fs.FS, door zapmcp.Handler) (http.Handler, error) {
|
||||
return newConsoleHandler(fsys, door)
|
||||
}
|
||||
|
||||
// consoleHandler serves a single-page app out of fsys: exact-file when it exists,
|
||||
@@ -115,6 +121,10 @@ func Handler(fsys fs.FS) (http.Handler, error) {
|
||||
// of ~10KB out of RAM and is always the release that is actually mounted.
|
||||
type consoleHandler struct {
|
||||
fsys fs.FS
|
||||
// door is this process's MCP door — zip's, handed in whole. The console does
|
||||
// not implement MCP and holds no tool list; it holds the one address a machine
|
||||
// door has and the value that answers there.
|
||||
door zapmcp.Handler
|
||||
}
|
||||
|
||||
// newConsoleHandler proves the source is a console before anything serves from
|
||||
@@ -122,14 +132,21 @@ type consoleHandler struct {
|
||||
// client-side route would 404 and the failure would surface as a broken product
|
||||
// rather than as a bad source. nil is the separate, stated case of "no bundle in
|
||||
// this process" and is not an error.
|
||||
func newConsoleHandler(fsys fs.FS) (*consoleHandler, error) {
|
||||
// The door is REQUIRED. A terminal handler with no door cannot answer the one
|
||||
// address in the process that is guaranteed not to be a console route, and the
|
||||
// SPA fallback is the wrong answer there in the most damaging possible way — the
|
||||
// bug this package was already carrying, pointing the other way.
|
||||
func newConsoleHandler(fsys fs.FS, door zapmcp.Handler) (*consoleHandler, error) {
|
||||
if door == nil {
|
||||
return nil, fmt.Errorf("webui: no MCP door: the terminal handler answers one and cannot invent it")
|
||||
}
|
||||
if fsys == nil {
|
||||
return &consoleHandler{}, nil
|
||||
return &consoleHandler{door: door}, nil
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "index.html"); err != nil {
|
||||
return nil, fmt.Errorf("webui: console source has no index.html: %w", err)
|
||||
}
|
||||
return &consoleHandler{fsys: fsys}, nil
|
||||
return &consoleHandler{fsys: fsys, door: door}, nil
|
||||
}
|
||||
|
||||
func (h *consoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -145,7 +162,7 @@ func (h *consoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// TERMINAL, so it only ever sees a path no route claimed in THIS process. A
|
||||
// plugin that serves its own door at FrameworkMCPPath matches a real route and
|
||||
// never reaches here; the host, which moved its door to MCPPath, does.
|
||||
if mcpDoor(w, r, upath) {
|
||||
if h.mcpDoor(w, r, upath) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ func TestRouteShell_ServedForExportedRoutes(t *testing.T) {
|
||||
"signin.html": {Data: []byte("<html><head><title>Hanzo Cloud Console</title></head><body>SIGNIN</body></html>")},
|
||||
"auth/callback.html": {Data: []byte("<html><head><title>Hanzo Cloud Console</title></head><body>CALLBACK</body></html>")},
|
||||
}
|
||||
h, err := newConsoleHandler(fsys)
|
||||
h, err := newConsoleHandler(fsys, testDoor())
|
||||
if err != nil {
|
||||
t.Fatalf("newConsoleHandler: %v", err)
|
||||
}
|
||||
|
||||
+65
-13
@@ -15,9 +15,11 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
zapmcp "github.com/zap-proto/mcp"
|
||||
)
|
||||
|
||||
// mcpDoor answers the two MCP addresses when — and only when — no route claimed
|
||||
@@ -30,21 +32,32 @@ import (
|
||||
// apart — so an agent POSTing JSON-RPC at the framework's default path was
|
||||
// answered in the console's voice and, on GET, with 200 text/html.
|
||||
//
|
||||
// Neither branch SERVES MCP. The door is zip's, projected from the typed-op
|
||||
// registry at manifest.MCPPath; these are the two things the front door owes a
|
||||
// caller who did not find it — where it is, and that it exists but not for this
|
||||
// method. A signpost is not a second surface: nothing here has a tool list.
|
||||
func mcpDoor(w http.ResponseWriter, r *http.Request, upath string) bool {
|
||||
// It used to answer the framework default with a REDIRECT to manifest.MCPPath,
|
||||
// on the reasoning that a plugin serving its own door there matched a real route
|
||||
// and never reached here. That was false, and it is the defect this file now
|
||||
// exists to close: zip mounts the /mcp route only when the app has something to
|
||||
// expose, so a plugin with no typed ops of its own — kms, whose four secret ops
|
||||
// are on the internal plane by design — reached here AT ITS OWN DOOR and was sent
|
||||
// to an address only a host serves. The signpost is the host's, and it lives with
|
||||
// the host's door now (fleet.Mount).
|
||||
func (h *consoleHandler) mcpDoor(w http.ResponseWriter, r *http.Request, upath string) bool {
|
||||
switch upath {
|
||||
case manifest.FrameworkMCPPath:
|
||||
// zip's default, unclaimed in this process => the door was moved. 308
|
||||
// preserves method AND body, so a POSTed initialize or tools/list arrives at
|
||||
// the real door instead of being answered with the SPA shell. The body is
|
||||
// JSON for the same reason the redirect exists at all: a caller who reads
|
||||
// bytes rather than following the hop must never get HTML here.
|
||||
w.Header().Set("Location", manifest.MCPPath)
|
||||
writeJSON(w, http.StatusPermanentRedirect,
|
||||
`{"error":"the MCP door moved","door":"`+manifest.MCPPath+`"}`)
|
||||
// THIS PROCESS'S OWN DOOR. Reaching a terminal handler here means zip
|
||||
// registered no route for it, never that the door is elsewhere — and the
|
||||
// door is not the route: it is [zip.App.MCP], a frame in and a frame out,
|
||||
// which exists whether or not anything was mounted over it. So answer it.
|
||||
// Serving zip's own door is not a second surface; re-implementing one, or
|
||||
// redirecting to a door this process does not have, would be.
|
||||
if r.Method != http.MethodPost {
|
||||
// The optional server→client SSE stream, which zip's door does not
|
||||
// have either. Same answer as below, for the same reason.
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
writeJSON(w, http.StatusMethodNotAllowed,
|
||||
`{"error":"the MCP door speaks JSON-RPC over POST","door":"`+manifest.FrameworkMCPPath+`"}`)
|
||||
return true
|
||||
}
|
||||
h.serveDoor(w, r)
|
||||
return true
|
||||
|
||||
case manifest.MCPPath:
|
||||
@@ -66,6 +79,45 @@ func mcpDoor(w http.ResponseWriter, r *http.Request, upath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// serveDoor is HTTP over the door, not a door of its own: read the JSON-RPC body
|
||||
// into a frame, hand it to [zip.App.MCP], write the answer back. zip's own /mcp
|
||||
// route is the identical adapter over the identical value — one door, and the
|
||||
// transport is a choice, which is the whole point of the frame-in/frame-out
|
||||
// signature. What is NOT duplicated is any tool: this file knows no tool names,
|
||||
// no schemas and no dispatch.
|
||||
func (h *consoleHandler) serveDoor(w http.ResponseWriter, r *http.Request) {
|
||||
var f zapmcp.Frame
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
writeFrame(w, &zapmcp.Frame{Kind: zapmcp.Response,
|
||||
Err: &zapmcp.Error{Code: zapmcp.CodeParse, Message: "parse error"}})
|
||||
return
|
||||
}
|
||||
ans := h.door(r.Context(), &f)
|
||||
if ans == nil {
|
||||
// A notification: nothing to say, and 202 says exactly that.
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeFrame(w, ans)
|
||||
}
|
||||
|
||||
// writeFrame writes one frame as the JSON-RPC message an HTTP client reads. The
|
||||
// rendering is [zapmcp.Frame]'s own, so the bytes an agent reads here and the
|
||||
// bytes it reads from zip's route describe the same value and cannot drift.
|
||||
//
|
||||
// A JSON-RPC error is still a 200: the transport delivered it. Only the envelope
|
||||
// says no.
|
||||
func writeFrame(w http.ResponseWriter, f *zapmcp.Frame) {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
http.Error(w, "the MCP door could not render its answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// writeJSON writes one short literal body with its content type — the machine
|
||||
// answers above are fixed strings, so there is nothing to encode.
|
||||
func writeJSON(w http.ResponseWriter, status int, body string) {
|
||||
|
||||
+128
-22
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
zapmcp "github.com/zap-proto/mcp"
|
||||
zip "github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,13 @@ type pingOut struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// testDoor is a process's agent door for the tests that drive the console
|
||||
// handler directly. It is a REAL zip.App's — the same value Mount hands in — so
|
||||
// no test is exercising a shape production does not have.
|
||||
func testDoor() zapmcp.Handler {
|
||||
return zip.New(zip.Config{AppName: "console", DisableStartupMessage: true}).MCP
|
||||
}
|
||||
|
||||
// hostApp is the HOST as cmd/cloud builds it: a typed op (so zip has a registry
|
||||
// to project), the MCP door moved to manifest.MCPPath, and the console mounted
|
||||
// LAST as the terminal handler — then Prepare(), which is when zip installs the
|
||||
@@ -125,29 +133,49 @@ func TestMCPDoorAnswersMCP(t *testing.T) {
|
||||
t.Logf("POST %s -> %d %s %.140s", manifest.MCPPath, got.status, got.ctype, got.body)
|
||||
}
|
||||
|
||||
// The bug, pinned from both sides: the framework default must never render the
|
||||
// console, and must send the caller to the door that exists — with the method
|
||||
// and body intact, which is what 308 (not 302) buys.
|
||||
func TestFrameworkPathSignpostsTheDoor(t *testing.T) {
|
||||
app := hostApp(t)
|
||||
for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodHead} {
|
||||
got := call(t, app, m, manifest.FrameworkMCPPath, toolsList)
|
||||
if got.status != http.StatusPermanentRedirect {
|
||||
t.Errorf("%s %s: status %d, want 308 — %.120s", m, manifest.FrameworkMCPPath, got.status, got.body)
|
||||
}
|
||||
if got.location != manifest.MCPPath {
|
||||
t.Errorf("%s %s: Location %q, want %q", m, manifest.FrameworkMCPPath, got.location, manifest.MCPPath)
|
||||
}
|
||||
if strings.Contains(got.ctype, "text/html") {
|
||||
t.Errorf("%s %s: content-type %q — the machine door must never be HTML", m, manifest.FrameworkMCPPath, got.ctype)
|
||||
}
|
||||
if strings.Contains(got.body, "<!doctype") || strings.Contains(got.body, "<!DOCTYPE") {
|
||||
t.Errorf("%s %s: answered with the console SPA shell", m, manifest.FrameworkMCPPath)
|
||||
}
|
||||
// The original bug, still pinned: the framework default must never render the
|
||||
// console. What it is answered WITH has changed — this process's own door, not a
|
||||
// redirect. The 308 belongs to a host that moved its door and is registered by
|
||||
// the same call that registers the target (fleet.Mount); the end-to-end host is
|
||||
// pinned in cmd/cloud/mcp_test.go, where both halves are composed. Here the rule
|
||||
// is the one the terminal handler can actually keep on its own.
|
||||
func TestFrameworkPathIsAnsweredAsADoor(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
app *zip.App
|
||||
}{
|
||||
{"door moved off the framework default", hostApp(t)},
|
||||
{"no door mounted at all", doorlessApp(t)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodHead} {
|
||||
got := call(t, tc.app, m, manifest.FrameworkMCPPath, toolsList)
|
||||
if strings.Contains(got.ctype, "text/html") {
|
||||
t.Errorf("%s %s: content-type %q — the machine door must never be HTML", m, manifest.FrameworkMCPPath, got.ctype)
|
||||
}
|
||||
if strings.Contains(got.body, "<!doctype") || strings.Contains(got.body, "<!DOCTYPE") {
|
||||
t.Errorf("%s %s: answered with the console SPA shell", m, manifest.FrameworkMCPPath)
|
||||
}
|
||||
// Never a hop to an address this process does not serve. That is the
|
||||
// defect: kms sent its own door to /v1/mcp and /v1/mcp 404'd.
|
||||
if got.location != "" {
|
||||
t.Errorf("%s %s: Location %q — the door is HERE; nothing to redirect to",
|
||||
m, manifest.FrameworkMCPPath, got.location)
|
||||
}
|
||||
if m == http.MethodPost {
|
||||
mcpResult(t, got, manifest.FrameworkMCPPath)
|
||||
continue
|
||||
}
|
||||
// The optional SSE stream, which this door does not have.
|
||||
if got.status != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s: status %d, want 405", m, manifest.FrameworkMCPPath, got.status)
|
||||
}
|
||||
if got.allow != http.MethodPost {
|
||||
t.Errorf("%s %s: Allow %q, want POST", m, manifest.FrameworkMCPPath, got.allow)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// And following the hop lands on the real door, speaking MCP.
|
||||
got := call(t, app, http.MethodPost, manifest.MCPPath, toolsList)
|
||||
mcpResult(t, got, manifest.MCPPath)
|
||||
}
|
||||
|
||||
// A GET on the door is a client asking for the optional SSE stream. There is
|
||||
@@ -184,6 +212,84 @@ func TestPluginKeepsItsOwnDoor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// doorlessApp is the shape kms actually has, and the one nothing here modelled:
|
||||
// a plugin whose REST surface is PLAIN handlers and whose four typed ops live on
|
||||
// the internal plane (cloud.Plane, apps/kms/secret_rpc.go) so that no route runs
|
||||
// from the edge to a secret. Its own registry is therefore EMPTY — and zip mounts
|
||||
// the /mcp route only when it has something to expose (zip mcp.go installMCP), so
|
||||
// this app never gets a door and the console catch-all is what answers at it.
|
||||
//
|
||||
// nil bundle on purpose: a child cannot bootstrap the console release, so every
|
||||
// per-app plugin binary runs exactly this way.
|
||||
func doorlessApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{AppName: "kms", DisableStartupMessage: true})
|
||||
app.Get("/v1/kms/health", func(c *zip.Ctx) error {
|
||||
return c.JSON(200, map[string]string{"status": "ok"})
|
||||
})
|
||||
if err := Mount(app, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// A door with nothing behind it is still a door.
|
||||
//
|
||||
// Measured on bin/kms: POST /mcp -> 308 Location /v1/mcp, and POST /v1/mcp -> 404.
|
||||
// The fleet asks every child at FrameworkMCPPath and reads any non-2xx as an
|
||||
// outage (fleet/fleet.go ask), so a child that redirects its own door to an
|
||||
// address it does not serve drops out of the composed tool list AND is reported
|
||||
// down — for the crime of having no tools. An empty list is the honest answer and
|
||||
// it is a 200.
|
||||
func TestDoorlessPluginAnswersItsOwnDoor(t *testing.T) {
|
||||
app := doorlessApp(t)
|
||||
|
||||
got := call(t, app, http.MethodPost, manifest.FrameworkMCPPath, toolsList)
|
||||
res := mcpResult(t, got, manifest.FrameworkMCPPath)
|
||||
if _, ok := res["tools"]; !ok {
|
||||
t.Fatalf("POST %s: result has no tools array — %.200s", manifest.FrameworkMCPPath, got.body)
|
||||
}
|
||||
t.Logf("doorless POST %s -> %d %s %.140s", manifest.FrameworkMCPPath, got.status, got.ctype, got.body)
|
||||
|
||||
// initialize is the handshake every MCP client opens with; a door that only
|
||||
// answered tools/list would fail before it ever asked.
|
||||
init := call(t, app, http.MethodPost, manifest.FrameworkMCPPath,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
|
||||
if res := mcpResult(t, init, manifest.FrameworkMCPPath); res["protocolVersion"] == nil {
|
||||
t.Errorf("initialize: no protocolVersion — %.200s", init.body)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration order was the suspect and it is innocent — pinned here so the next
|
||||
// reader does not re-run the investigation.
|
||||
//
|
||||
// The console catch-all is registered FIRST (Mount, from cloud.Serve) and zip's
|
||||
// door is a CONTROL route installed LAST, in prepare() at Listen (zip generation.go
|
||||
// materialise puts ctl on after every entry). Upstream fiber would let the earlier
|
||||
// /* win. The zap-proto fork does not: endpoint routes are inserted
|
||||
// most-specific-first within the run that follows the last middleware barrier
|
||||
// (fiber router_precedence.go insertRouteSorted), so the static /mcp sorts AHEAD
|
||||
// of the greedy /* however late it arrives.
|
||||
func TestCatchAllNeverShadowsTheDoor(t *testing.T) {
|
||||
app := pluginApp(t) // catch-all first; the door does not exist yet
|
||||
// Test() runs prepare(), which is where installMCP registers the control route.
|
||||
mcpResult(t, call(t, app, http.MethodPost, manifest.FrameworkMCPPath, toolsList),
|
||||
manifest.FrameworkMCPPath)
|
||||
|
||||
var order []string
|
||||
for _, r := range app.Fiber().GetRoutes(true) {
|
||||
if r.Method != http.MethodPost {
|
||||
continue
|
||||
}
|
||||
if r.Path == manifest.FrameworkMCPPath || r.Path == "/*" {
|
||||
order = append(order, r.Path)
|
||||
}
|
||||
}
|
||||
if len(order) != 2 || order[0] != manifest.FrameworkMCPPath {
|
||||
t.Fatalf("POST stack order %v — the door must sort ahead of the catch-all", order)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the contract, and the one a fix must not break: the console
|
||||
// still serves, at "/" and at a client-side deep link.
|
||||
func TestConsoleStillServes(t *testing.T) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// so every client-side route would 404 and the product would look broken while
|
||||
// the process looked healthy. Refuse it where the source is named.
|
||||
func TestBundleWithoutShellIsRefused(t *testing.T) {
|
||||
_, err := Handler(fstest.MapFS{"_next/static/chunk.js": {Data: []byte("//")}})
|
||||
_, err := Handler(fstest.MapFS{"_next/static/chunk.js": {Data: []byte("//")}}, testDoor())
|
||||
if err == nil {
|
||||
t.Fatal("Handler accepted a bundle with no index.html — every deep link would 404")
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestBundleWithoutShellIsRefused(t *testing.T) {
|
||||
// terminal handler. It must keep the API namespaces honest and must NOT invent a
|
||||
// page: an empty 200 of HTML is the one answer a front door may never give.
|
||||
func TestNoBundleIsA503_NotAShell(t *testing.T) {
|
||||
h, err := Handler(nil)
|
||||
h, err := Handler(nil, testDoor())
|
||||
if err != nil {
|
||||
t.Fatalf("Handler(nil): %v — no bundle is a stated case, not an error", err)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestReleaseSwapIsServedImmediately(t *testing.T) {
|
||||
"_next/static/chunks/aaaa1111.js": {Data: []byte("//a")},
|
||||
})
|
||||
|
||||
h, err := Handler(src)
|
||||
h, err := Handler(src, testDoor())
|
||||
if err != nil {
|
||||
t.Fatalf("Handler: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user