Firewalla MSP MCP Server Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a read-only Firewalla MSP MCP server (fzymgc-house/firewalla-mcp, Go) deployed in-cluster behind agentgateway at mcp-gw.fzymgc.house/mcp/firewalla-ro, so agents can triage Firewalla state (boxes/devices/alarms/flows/rules/target-lists) via MCP.
Architecture: A self-built Go MCP server (engram pattern: own repo, distroless image, CI-published) wraps Firewalla MSP API v2. Cluster wiring mirrors clickhouse-mcp: Deployment + Service + ExternalSecret + CiliumNetworkPolicy, exposed only through agentgateway (AgentgatewayBackend/HTTPRoute/AgentgatewayPolicy). Three auth layers: client→gateway VK, gateway→server static bearer, server→MSP personal access token.
Tech Stack: Go 1.23 + github.com/modelcontextprotocol/go-sdk (StreamableHTTP), Task + goreleaser + cobra, distroless; ArgoCD + Kustomize; External Secrets + Vault; Cilium; Firewalla MSP API v2.
Spec: docs/engineering/specs/2026-06-20-firewalla-msp-mcp-server-design.md · Design bead: hl-zply (impl hl-vbvu.1, epic hl-vbvu).
Two-repo note: Phase A tasks (1–8) are executed in the new fzymgc-house/firewalla-mcp repo. Phase B tasks (9–14) are executed in this repo (selfhosted-cluster). Phase B depends on Phase A having published an image. Each task names its repo explicitly.
Phase A — Server repo fzymgc-house/firewalla-mcp
Section titled “Phase A — Server repo fzymgc-house/firewalla-mcp”Task 1: Bootstrap the repo
Section titled “Task 1: Bootstrap the repo”Repo: fzymgc-house/firewalla-mcp (create empty on GitHub first: gh repo create fzymgc-house/firewalla-mcp --private --description "MCP server for the Firewalla MSP API").
Files:
- Create:
go.mod,Taskfile.yml,.gitignore,README.md,LICENSE - Create dirs:
cmd/firewalla-mcp/,internal/{config,msp,tools,server}/ - Step 1: Initialize the module
Run: go mod init github.com/fzymgc-house/firewalla-mcp && go get github.com/modelcontextprotocol/go-sdk@latest github.com/spf13/cobra@latest golang.org/x/time@latest
Expected: go.mod lists the three deps; go.sum written.
- Step 2: Create the directory skeleton with package files
Create internal/config/config.go, internal/msp/client.go, internal/tools/tools.go, internal/server/server.go, each with just package <name> for now.
- Step 3: Write
Taskfile.yml
version: "3"tasks: test: cmds: - go test ./... lint: cmds: - go vet ./... - test -z "$(gofmt -l .)" build: cmds: - go build -o bin/firewalla-mcp ./cmd/firewalla-mcp- Step 4: Verify it compiles
Run: go build ./... && task test
Expected: build succeeds; go test ./... reports no test files (no failures).
- Step 5: Commit
Commit (jj): jj commit -m "feat: bootstrap firewalla-mcp module skeleton" (see references/vcs-preamble.md).
Task 2: Config loading + validation
Section titled “Task 2: Config loading + validation”Files:
- Modify:
internal/config/config.go - Test:
internal/config/config_test.go - Step 1: Write the failing test
package config
import "testing"
func TestLoadRequiresMSPCreds(t *testing.T) { t.Setenv("FIREWALLA_MSP_ID", "") t.Setenv("FIREWALLA_MSP_TOKEN", "") if _, err := Load(); err == nil { t.Fatal("expected error when MSP id/token missing") }}
func TestLoadDefaults(t *testing.T) { t.Setenv("FIREWALLA_MSP_ID", "demo.firewalla.net") t.Setenv("FIREWALLA_MSP_TOKEN", "tok") c, err := Load() if err != nil { t.Fatalf("unexpected error: %v", err) } if c.HTTPAddr != ":8080" { t.Fatalf("HTTPAddr default = %q, want :8080", c.HTTPAddr) } if c.BaseURL != "https://demo.firewalla.net/v2" { t.Fatalf("BaseURL = %q", c.BaseURL) }}- Step 2: Run test to verify it fails
Run: go test ./internal/config/ -run TestLoad -v
Expected: FAIL — undefined: Load.
- Step 3: Write minimal implementation
package config
import ( "fmt" "os")
type Config struct { MSPID string // e.g. demo.firewalla.net MSPToken string BoxID string BaseURL string // derived: https://<MSPID>/v2 HTTPAddr string AuthToken string // gateway->server bearer; empty disables the check LogLevel string}
func Load() (*Config, error) { c := &Config{ MSPID: os.Getenv("FIREWALLA_MSP_ID"), MSPToken: os.Getenv("FIREWALLA_MSP_TOKEN"), BoxID: os.Getenv("FIREWALLA_BOX_ID"), HTTPAddr: envOr("HTTP_ADDR", ":8080"), AuthToken: os.Getenv("MCP_AUTH_TOKEN"), LogLevel: envOr("LOG_LEVEL", "info"), } if c.MSPID == "" || c.MSPToken == "" { return nil, fmt.Errorf("FIREWALLA_MSP_ID and FIREWALLA_MSP_TOKEN are required") } c.BaseURL = "https://" + c.MSPID + "/v2" return c, nil}
func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def}- Step 4: Run test to verify it passes
Run: go test ./internal/config/ -v
Expected: PASS (both tests).
- Step 5: Commit
jj commit -m "feat(config): env loading with MSP cred validation"
Task 3: MSP client core — request + auth header + error mapping
Section titled “Task 3: MSP client core — request + auth header + error mapping”Files:
- Modify:
internal/msp/client.go - Test:
internal/msp/client_test.go - Step 1: Write the failing test
package msp
import ( "context" "net/http" "net/http/httptest" "testing")
func TestGetSendsTokenHeader(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.WriteHeader(200) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close()
c := New(srv.URL, "secrettoken") var out map[string]any if err := c.get(context.Background(), "/devices", nil, &out); err != nil { t.Fatalf("get: %v", err) } if gotAuth != "Token secrettoken" { t.Fatalf("Authorization = %q, want %q", gotAuth, "Token secrettoken") }}
func TestGetMapsUnauthorized(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) })) defer srv.Close() c := New(srv.URL, "bad") err := c.get(context.Background(), "/devices", nil, nil) if err == nil || !IsAuthError(err) { t.Fatalf("want auth error, got %v", err) }}- Step 2: Run test to verify it fails
Run: go test ./internal/msp/ -v
Expected: FAIL — undefined: New.
- Step 3: Write minimal implementation
package msp
import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time")
type Client struct { base string // e.g. https://demo.firewalla.net/v2 token string hc *http.Client}
func New(base, token string) *Client { return &Client{base: base, token: token, hc: &http.Client{Timeout: 30 * time.Second}}}
type APIError struct { Status int Body string}
func (e *APIError) Error() string { return fmt.Sprintf("firewalla msp api: status %d: %s", e.Status, e.Body) }
func IsAuthError(err error) bool { var ae *APIError return errors.As(err, &ae) && ae.Status == http.StatusUnauthorized}
// get performs a GET against base+path with the given query, decoding JSON into out (out may be nil).func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error { u := c.base + path if len(q) > 0 { u += "?" + q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("Authorization", "Token "+c.token) resp, err := c.hc.Do(req) if err != nil { return fmt.Errorf("firewalla msp request: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return &APIError{Status: resp.StatusCode, Body: string(body)} } if out != nil { if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("decode firewalla msp response: %w", err) } } return nil}- Step 4: Run test to verify it passes
Run: go test ./internal/msp/ -v
Expected: PASS.
- Step 5: Commit
jj commit -m "feat(msp): base GET client with token auth + typed APIError"
Task 4: MSP client — rate limit + retry on 429/5xx
Section titled “Task 4: MSP client — rate limit + retry on 429/5xx”Files:
- Modify:
internal/msp/client.go - Test:
internal/msp/retry_test.go - Step 1: Write the failing test
package msp
import ( "context" "net/http" "net/http/httptest" "sync/atomic" "testing")
func TestGetRetriesOn429ThenSucceeds(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if atomic.AddInt32(&calls, 1) == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(429) return } w.WriteHeader(200) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() c := New(srv.URL, "t") if err := c.get(context.Background(), "/devices", nil, nil); err != nil { t.Fatalf("get: %v", err) } if calls != 2 { t.Fatalf("calls = %d, want 2 (one 429 retry)", calls) }}- Step 2: Run test to verify it fails
Run: go test ./internal/msp/ -run TestGetRetries -v
Expected: FAIL — only 1 call (no retry yet); error returned on 429.
- Step 3: Write minimal implementation
Add a golang.org/x/time/rate limiter to Client and wrap the request in a bounded retry loop. Replace New and the request portion of get:
import ( "strconv" "golang.org/x/time/rate")
// add field: lim *rate.Limiter (100 req/min)func New(base, token string) *Client { return &Client{ base: base, token: token, hc: &http.Client{Timeout: 30 * time.Second}, lim: rate.NewLimiter(rate.Limit(100.0/60.0), 10), }}
const maxRetries = 3
// doWithRetry executes req-building fn up to maxRetries on 429/5xx.func (c *Client) doWithRetry(ctx context.Context, build func() (*http.Request, error)) (*http.Response, error) { var last error for attempt := 0; attempt <= maxRetries; attempt++ { if err := c.lim.Wait(ctx); err != nil { return nil, err } req, err := build() if err != nil { return nil, err } resp, err := c.hc.Do(req) if err != nil { last = err continue } if resp.StatusCode == 429 || resp.StatusCode >= 500 { retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")) resp.Body.Close() last = &APIError{Status: resp.StatusCode} select { case <-time.After(retryAfter): case <-ctx.Done(): return nil, ctx.Err() } continue } return resp, nil } return nil, last}
func parseRetryAfter(h string) time.Duration { if h == "" { return 250 * time.Millisecond } if secs, err := strconv.Atoi(h); err == nil { return time.Duration(secs) * time.Second } return 250 * time.Millisecond}Then rewrite get to build the request via a closure and call doWithRetry:
func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error { resp, err := c.doWithRetry(ctx, func() (*http.Request, error) { u := c.base + path if len(q) > 0 { u += "?" + q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Token "+c.token) return req, nil }) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return &APIError{Status: resp.StatusCode, Body: string(body)} } if out != nil { if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("decode firewalla msp response: %w", err) } } return nil}- Step 4: Run test to verify it passes
Run: go test ./internal/msp/ -v
Expected: PASS (all msp tests, including Task 3’s).
- Step 5: Commit
jj commit -m "feat(msp): client-side rate limit + bounded retry on 429/5xx"
Task 5: MSP client — cursor pagination helper
Section titled “Task 5: MSP client — cursor pagination helper”Files:
- Modify:
internal/msp/client.go - Test:
internal/msp/paginate_test.go - Step 1: Write the failing test
package msp
import ( "context" "fmt" "net/http" "net/http/httptest" "testing")
func TestPagedFollowsCursor(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) if r.URL.Query().Get("cursor") == "" { fmt.Fprint(w, `{"count":1,"results":[{"id":"a"}],"next_cursor":"c2"}`) } else { fmt.Fprint(w, `{"count":1,"results":[{"id":"b"}],"next_cursor":null}`) } })) defer srv.Close() c := New(srv.URL, "t") items, err := getPaged[map[string]any](c, context.Background(), "/flows", nil, 100) if err != nil { t.Fatalf("getPaged: %v", err) } if len(items) != 2 { t.Fatalf("len = %d, want 2", len(items)) }}- Step 2: Run test to verify it fails
Run: go test ./internal/msp/ -run TestPaged -v
Expected: FAIL — undefined: getPaged.
- Step 3: Write minimal implementation
type page[T any] struct { Count int `json:"count"` Results []T `json:"results"` NextCursor string `json:"next_cursor"`}
// getPaged follows next_cursor until exhausted or max items collected.func getPaged[T any](c *Client, ctx context.Context, path string, q url.Values, max int) ([]T, error) { if q == nil { q = url.Values{} } var all []T for { var p page[T] if err := c.get(ctx, path, q, &p); err != nil { return nil, err } all = append(all, p.Results...) if p.NextCursor == "" || len(all) >= max { break } q.Set("cursor", p.NextCursor) } if len(all) > max { all = all[:max] } return all, nil}Note:
next_cursoris JSONnullwhen absent;stringunmarshals JSONnullto"", which the loop treats as “done”. Verified against the Flow/Alarm response shape in the spec §2.
- Step 4: Run test to verify it passes
Run: go test ./internal/msp/ -v
Expected: PASS.
- Step 5: Commit
jj commit -m "feat(msp): generic cursor-pagination helper"
Task 6: MSP typed read methods
Section titled “Task 6: MSP typed read methods”Files:
- Create:
internal/msp/methods.go - Test:
internal/msp/methods_test.go - Step 1: Write the failing test
package msp
import ( "context" "net/http" "net/http/httptest" "testing")
func TestListDevicesAndQueryFlows(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) switch r.URL.Path { case "/devices": _, _ = w.Write([]byte(`[{"id":"AA:BB","name":"phone"}]`)) case "/flows": _, _ = w.Write([]byte(`{"count":1,"results":[{"protocol":"tcp"}],"next_cursor":null}`)) default: w.WriteHeader(404) } })) defer srv.Close() c := New(srv.URL, "t") devs, err := c.ListDevices(context.Background(), DeviceFilter{}) if err != nil || len(devs) != 1 { t.Fatalf("ListDevices: %v (%d)", err, len(devs)) } flows, err := c.QueryFlows(context.Background(), FlowQuery{Limit: 10}) if err != nil || len(flows) != 1 { t.Fatalf("QueryFlows: %v (%d)", err, len(flows)) }}- Step 2: Run test to verify it fails
Run: go test ./internal/msp/ -run TestListDevices -v
Expected: FAIL — undefined: DeviceFilter.
- Step 3: Write minimal implementation
/v2/devices returns a bare JSON array (per spec §2 examples); /v2/flows, /v2/alarms return {count,results,next_cursor}; /v2/boxes, /v2/rules, /v2/target-lists return arrays or {results} — model both with json.RawMessage passthrough where shape is uncertain, returning raw maps to keep the tool layer thin.
package msp
import ( "context" "net/url" "strconv")
type Box = map[string]anytype Device = map[string]anytype Alarm = map[string]anytype Flow = map[string]anytype Rule = map[string]anytype TargetList = map[string]any
type DeviceFilter struct{ Box, Group string }
func (c *Client) ListBoxes(ctx context.Context) ([]Box, error) { var out []Box return out, c.get(ctx, "/boxes", nil, &out)}
func (c *Client) ListDevices(ctx context.Context, f DeviceFilter) ([]Device, error) { q := url.Values{} if f.Box != "" { q.Set("box", f.Box) } if f.Group != "" { q.Set("group", f.Group) } var out []Device return out, c.get(ctx, "/devices", q, &out)}
type FlowQuery struct { Query, GroupBy, SortBy, Cursor string Limit int}
func (q FlowQuery) values() url.Values { v := url.Values{} set(v, "query", q.Query) set(v, "groupBy", q.GroupBy) set(v, "sortBy", q.SortBy) if q.Limit > 0 { v.Set("limit", strconv.Itoa(q.Limit)) } return v}
func (c *Client) QueryFlows(ctx context.Context, q FlowQuery) ([]Flow, error) { max := q.Limit if max == 0 { max = 200 } return getPaged[Flow](c, ctx, "/flows", q.values(), max)}
type AlarmQuery = FlowQuery // identical query-string surface
func (c *Client) ListAlarms(ctx context.Context, q AlarmQuery) ([]Alarm, error) { max := q.Limit if max == 0 { max = 200 } return getPaged[Alarm](c, ctx, "/alarms", q.values(), max)}
func (c *Client) GetAlarm(ctx context.Context, gid, aid string) (Alarm, error) { var out Alarm return out, c.get(ctx, "/alarms/"+gid+"/"+aid, nil, &out)}
func (c *Client) ListRules(ctx context.Context, query string) ([]Rule, error) { q := url.Values{} set(q, "query", query) var out struct { Results []Rule `json:"results"` } if err := c.get(ctx, "/rules", q, &out); err != nil { return nil, err } return out.Results, nil}
func (c *Client) ListTargetLists(ctx context.Context) ([]TargetList, error) { var out []TargetList return out, c.get(ctx, "/target-lists", nil, &out)}
func set(v url.Values, k, val string) { if val != "" { v.Set(k, val) }}
get_device/get_rule/get_target_listby id are implemented in the tool layer (Task 7) as a filter over the list calls, per spec §12 (the v2 API has no guaranteed/:idGET for these).
- Step 4: Run test to verify it passes
Run: go test ./internal/msp/ -v
Expected: PASS.
- Step 5: Commit
jj commit -m "feat(msp): typed read methods for boxes/devices/alarms/flows/rules/target-lists"
Task 7: MCP server — tools, bearer middleware, /health, StreamableHTTP
Section titled “Task 7: MCP server — tools, bearer middleware, /health, StreamableHTTP”Files:
- Modify:
internal/tools/tools.go,internal/server/server.go,cmd/firewalla-mcp/main.go - Test:
internal/tools/tools_test.go,internal/server/server_test.go - Step 1: Write the failing test (bearer middleware)
package server
import ( "net/http" "net/http/httptest" "testing")
func TestBearerMiddlewareRejectsMissingToken(t *testing.T) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) h := requireBearer("expected", next)
r := httptest.NewRequest("POST", "/mcp", nil) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Fatalf("no token: code = %d, want 401", w.Code) }
r2 := httptest.NewRequest("POST", "/mcp", nil) r2.Header.Set("Authorization", "Bearer expected") w2 := httptest.NewRecorder() h.ServeHTTP(w2, r2) if w2.Code != 200 { t.Fatalf("good token: code = %d, want 200", w2.Code) }}- Step 2: Run test to verify it fails
Run: go test ./internal/server/ -v
Expected: FAIL — undefined: requireBearer.
- Step 3: Write minimal implementation
internal/server/server.go:
package server
import ( "net/http"
"github.com/fzymgc-house/firewalla-mcp/internal/config" "github.com/fzymgc-house/firewalla-mcp/internal/msp" "github.com/fzymgc-house/firewalla-mcp/internal/tools" "github.com/modelcontextprotocol/go-sdk/mcp")
// requireBearer rejects requests lacking "Authorization: Bearer <want>".// If want is empty, the check is disabled (local/dev).func requireBearer(want string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if want != "" && r.Header.Get("Authorization") != "Bearer "+want { w.WriteHeader(http.StatusUnauthorized) return } next.ServeHTTP(w, r) })}
// New builds the MCP server and the HTTP mux (/, /mcp, /health).func New(cfg *config.Config) http.Handler { client := msp.New(cfg.BaseURL, cfg.MSPToken) srv := mcp.NewServer(&mcp.Implementation{Name: "firewalla-mcp", Version: "v0.1.0"}, nil) tools.Register(srv, client, cfg.BoxID)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil)
mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) mux.Handle("/mcp", requireBearer(cfg.AuthToken, handler)) return mux}- Step 4: Write the tools test
package tools
import ( "context" "net/http" "net/http/httptest" "testing"
"github.com/fzymgc-house/firewalla-mcp/internal/msp")
func TestListDevicesTool(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`[{"id":"AA:BB","name":"phone"}]`)) })) defer srv.Close() c := msp.New(srv.URL, "t") out, err := listDevices(context.Background(), c, listDevicesIn{}) if err != nil { t.Fatalf("listDevices: %v", err) } if len(out.Devices) != 1 { t.Fatalf("devices = %d, want 1", len(out.Devices)) }}- Step 5: Write the tools implementation
internal/tools/tools.go — register all read tools. Each tool is a typed handler delegating to the client. Full set (uniform pattern):
package tools
import ( "context"
"github.com/fzymgc-house/firewalla-mcp/internal/msp" "github.com/modelcontextprotocol/go-sdk/mcp")
type listDevicesIn struct { Box string `json:"box,omitempty" jsonschema:"filter by box id"` Group string `json:"group,omitempty" jsonschema:"filter by group id"`}type listDevicesOut struct { Devices []msp.Device `json:"devices"`}
func listDevices(ctx context.Context, c *msp.Client, in listDevicesIn) (listDevicesOut, error) { d, err := c.ListDevices(ctx, msp.DeviceFilter{Box: in.Box, Group: in.Group}) return listDevicesOut{Devices: d}, err}
// Register wires all read-only tools onto srv.func Register(srv *mcp.Server, c *msp.Client, defaultBox string) { mcp.AddTool(srv, &mcp.Tool{Name: "list_boxes", Description: "List Firewalla boxes"}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, struct{ Boxes []msp.Box `json:"boxes"` }, error) { b, err := c.ListBoxes(ctx) return nil, struct{ Boxes []msp.Box `json:"boxes"` }{b}, err })
mcp.AddTool(srv, &mcp.Tool{Name: "list_devices", Description: "List devices (optionally filtered by box/group)"}, func(ctx context.Context, _ *mcp.CallToolRequest, in listDevicesIn) (*mcp.CallToolResult, listDevicesOut, error) { out, err := listDevices(ctx, c, in) return nil, out, err })
mcp.AddTool(srv, &mcp.Tool{Name: "list_alarms", Description: "List alarms"}, func(ctx context.Context, _ *mcp.CallToolRequest, in struct { Query, GroupBy, SortBy string `json:",omitempty"` Limit int `json:"limit,omitempty"` }) (*mcp.CallToolResult, struct{ Alarms []msp.Alarm `json:"alarms"` }, error) { a, err := c.ListAlarms(ctx, msp.AlarmQuery{Query: in.Query, GroupBy: in.GroupBy, SortBy: in.SortBy, Limit: in.Limit}) return nil, struct{ Alarms []msp.Alarm `json:"alarms"` }{a}, err })
mcp.AddTool(srv, &mcp.Tool{Name: "get_alarm", Description: "Get one alarm by box gid + alarm id"}, func(ctx context.Context, _ *mcp.CallToolRequest, in struct{ Gid, Aid string }) (*mcp.CallToolResult, struct{ Alarm msp.Alarm `json:"alarm"` }, error) { a, err := c.GetAlarm(ctx, in.Gid, in.Aid) return nil, struct{ Alarm msp.Alarm `json:"alarm"` }{a}, err })
mcp.AddTool(srv, &mcp.Tool{Name: "query_flows", Description: "Query network flows"}, func(ctx context.Context, _ *mcp.CallToolRequest, in struct { Query, GroupBy, SortBy string `json:",omitempty"` Limit int `json:"limit,omitempty"` }) (*mcp.CallToolResult, struct{ Flows []msp.Flow `json:"flows"` }, error) { f, err := c.QueryFlows(ctx, msp.FlowQuery{Query: in.Query, GroupBy: in.GroupBy, SortBy: in.SortBy, Limit: in.Limit}) return nil, struct{ Flows []msp.Flow `json:"flows"` }{f}, err })
mcp.AddTool(srv, &mcp.Tool{Name: "list_rules", Description: "List rules (e.g. query=box.id:<gid>)"}, func(ctx context.Context, _ *mcp.CallToolRequest, in struct{ Query string `json:"query,omitempty"` }) (*mcp.CallToolResult, struct{ Rules []msp.Rule `json:"rules"` }, error) { r, err := c.ListRules(ctx, in.Query) return nil, struct{ Rules []msp.Rule `json:"rules"` }{r}, err })
mcp.AddTool(srv, &mcp.Tool{Name: "list_target_lists", Description: "List target lists"}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, struct{ TargetLists []msp.TargetList `json:"target_lists"` }, error) { tl, err := c.ListTargetLists(ctx) return nil, struct{ TargetLists []msp.TargetList `json:"target_lists"` }{tl}, err })}
get_device,get_rule,get_target_list(by-id filters over the list methods) are added in the sameRegisterfollowing this pattern; include them when implementing — each filters the corresponding list by anidinput and returns the first match or a not-found error. (Kept out of this block only to avoid three near-identical 6-line repeats; implement them, do not stub them.)
- Step 6: Write
cmd/firewalla-mcp/main.go
package main
import ( "log" "net/http"
"github.com/fzymgc-house/firewalla-mcp/internal/config" "github.com/fzymgc-house/firewalla-mcp/internal/server")
func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } log.Printf("firewalla-mcp listening on %s", cfg.HTTPAddr) if err := http.ListenAndServe(cfg.HTTPAddr, server.New(cfg)); err != nil { log.Fatal(err) }}- Step 7: Run tests + build
Run: go test ./... && go build ./...
Expected: PASS; binary builds.
- Step 8: Commit
jj commit -m "feat(server): read-only MCP tools, bearer middleware, /health, StreamableHTTP"
Task 8: Dockerfile, goreleaser, CI release
Section titled “Task 8: Dockerfile, goreleaser, CI release”Files:
-
Create:
Dockerfile,.goreleaser.yaml,.github/workflows/release.yml -
Step 1: Write the distroless Dockerfile
FROM golang:1.23 AS buildWORKDIR /srcCOPY . .RUN CGO_ENABLED=0 go build -o /out/firewalla-mcp ./cmd/firewalla-mcp
FROM gcr.io/distroless/static-debian12:nonrootCOPY --from=build /out/firewalla-mcp /firewalla-mcpEXPOSE 8080USER nonroot:nonrootENTRYPOINT ["/firewalla-mcp"]-
Step 2: Write
.goreleaser.yaml(image →ghcr.io/fzymgc-house/firewalla-mcp, multi-arch, digest output). Mirror the engram repo’s.goreleaser.yamlko/docker config. -
Step 3: Write the release workflow (
.github/workflows/release.yml): on tagv*,go test ./..., thengoreleaser releasepublishing to GHCR. Mirror engram’s workflow. -
Step 4: Build the image locally to verify
Run: docker build -t firewalla-mcp:dev . && docker run --rm -e FIREWALLA_MSP_ID=x.firewalla.net -e FIREWALLA_MSP_TOKEN=t -p 8080:8080 firewalla-mcp:dev & then curl -s localhost:8080/health
Expected: ok.
- Step 5: Commit + tag the first release
jj commit -m "build: distroless image + goreleaser + GHCR release workflow", push, then tag v0.1.0 to publish ghcr.io/fzymgc-house/firewalla-mcp:v0.1.0. Record the published image digest for Phase B.
Phase B — Cluster wiring selfhosted-cluster
Section titled “Phase B — Cluster wiring selfhosted-cluster”All Phase B tasks run in this repo. Pin the image to the digest published in Task 8.
Task 9: Populate Vault (manual CLI)
Section titled “Task 9: Populate Vault (manual CLI)”Files: none (Vault state). No Terraform — ESO uses the shared wildcard secret/data/* policy.
- Step 1: Write the Firewalla MSP secret
Run (with a Vault token that can write secret/):
vault kv put secret/fzymgc-house/cluster/firewalla-mcp \ msp_id="<yourdomain>.firewalla.net" \ token="<msp_personal_access_token>" \ box_id="<box_gid>"- Step 2: Add the gateway bearer + VK to the agentgateway path
vault kv patch secret/fzymgc-house/cluster/agentgateway \ firewalla_mcp_auth_token="$(openssl rand -hex 32)" \ vk_firewalla="$(openssl rand -hex 32)"- Step 3: Verify
Run: vault kv get secret/fzymgc-house/cluster/firewalla-mcp and vault kv get -field=vk_firewalla secret/fzymgc-house/cluster/agentgateway
Expected: properties present. (No commit — Vault state only. Record the vk_firewalla value for the MCP client config in services.md, Task 13.)
Task 10: app-configs/firewalla-mcp manifests
Section titled “Task 10: app-configs/firewalla-mcp manifests”Files:
-
Create:
argocd/app-configs/firewalla-mcp/{namespace,external-secret,deployment,service,networkpolicy,kustomization}.yaml -
Step 1: namespace.yaml
---apiVersion: v1kind: Namespacemetadata: name: firewalla-mcp- Step 2: external-secret.yaml (mirrors
clickhouse-mcp/external-secret.yaml)
---apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: firewalla-mcp-secrets namespace: firewalla-mcpspec: refreshInterval: 15m secretStoreRef: name: vault kind: ClusterSecretStore target: name: firewalla-mcp-secrets creationPolicy: Owner data: - secretKey: msp_id remoteRef: { key: fzymgc-house/cluster/firewalla-mcp, property: msp_id } - secretKey: token remoteRef: { key: fzymgc-house/cluster/firewalla-mcp, property: token } - secretKey: box_id remoteRef: { key: fzymgc-house/cluster/firewalla-mcp, property: box_id } - secretKey: mcp_auth_token remoteRef: { key: fzymgc-house/cluster/agentgateway, property: firewalla_mcp_auth_token }- Step 3: deployment.yaml (single read-only deployment; image digest-pinned from Task 8; securityContext mirrors clickhouse-mcp)
---apiVersion: apps/v1kind: Deploymentmetadata: name: firewalla-mcp namespace: firewalla-mcp labels: { app: firewalla-mcp }spec: replicas: 1 selector: matchLabels: { app: firewalla-mcp } template: metadata: labels: { app: firewalla-mcp } spec: containers: - name: mcp-server image: ghcr.io/fzymgc-house/firewalla-mcp:v0.1.0@sha256:<DIGEST_FROM_TASK_8> ports: [{ containerPort: 8080, name: http }] env: - { name: HTTP_ADDR, value: ":8080" } - name: FIREWALLA_MSP_ID valueFrom: { secretKeyRef: { name: firewalla-mcp-secrets, key: msp_id } } - name: FIREWALLA_MSP_TOKEN valueFrom: { secretKeyRef: { name: firewalla-mcp-secrets, key: token } } - name: FIREWALLA_BOX_ID valueFrom: { secretKeyRef: { name: firewalla-mcp-secrets, key: box_id } } - name: MCP_AUTH_TOKEN valueFrom: { secretKeyRef: { name: firewalla-mcp-secrets, key: mcp_auth_token } } resources: requests: { cpu: 50m, memory: 64Mi } limits: { cpu: 200m, memory: 256Mi } securityContext: allowPrivilegeEscalation: false capabilities: { drop: [ALL] } readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65532 readinessProbe: httpGet: { path: /health, port: 8080 } initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: { path: /health, port: 8080 } initialDelaySeconds: 15 periodSeconds: 30- Step 4: service.yaml (mirrors clickhouse-mcp service, port 8080)
---apiVersion: v1kind: Servicemetadata: name: firewalla-mcp namespace: firewalla-mcp labels: { app: firewalla-mcp }spec: type: ClusterIP selector: { app: firewalla-mcp } ports: - { name: http, port: 8080, targetPort: 8080, protocol: TCP }- Step 5: networkpolicy.yaml (CiliumNetworkPolicy; ingress from agentgateway on 8080; egress to internet 443 for the cloud MSP + DNS)
apiVersion: cilium.io/v2kind: CiliumNetworkPolicymetadata: name: firewalla-mcp-default namespace: firewalla-mcpspec: endpointSelector: {} ingress: - fromEndpoints: - matchLabels: { io.kubernetes.pod.namespace: agentgateway } toPorts: - ports: [{ port: "8080", protocol: TCP }] egress: - toEndpoints: - matchLabels: io.kubernetes.pod.namespace: kube-system k8s-app: kube-dns toPorts: - ports: [{ port: "53", protocol: UDP }] # Cloud MSP (<domain>.firewalla.net) reached over the internet on 443. - toEntities: [world] toPorts: - ports: [{ port: "443", protocol: TCP }]- Step 6: kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: firewalla-mcpresources: - namespace.yaml - external-secret.yaml - deployment.yaml - service.yaml - networkpolicy.yaml- Step 7: Validate
Run: kubectl kustomize argocd/app-configs/firewalla-mcp
Expected: renders all five resources with no error.
- Step 8: Commit
jj commit -m "feat(firewalla-mcp): namespace + deployment + service + ESO + netpol [hl-vbvu.1]"
Task 11: agentgateway backend/route/policies + secrets
Section titled “Task 11: agentgateway backend/route/policies + secrets”Files:
- Create:
argocd/app-configs/agentgateway/mcp-firewalla.yaml - Modify:
argocd/app-configs/agentgateway/secrets.yaml,argocd/app-configs/agentgateway/kustomization.yaml - Step 1: mcp-firewalla.yaml (mirrors
mcp-clickhouse.yamlro half)
---apiVersion: agentgateway.dev/v1alpha1kind: AgentgatewayBackendmetadata: { name: mcp-firewalla-ro, namespace: agentgateway }spec: mcp: targets: - name: firewalla-ro static: host: firewalla-mcp.firewalla-mcp.svc.cluster.local port: 8080 path: /mcp protocol: StreamableHTTP---apiVersion: gateway.networking.k8s.io/v1kind: HTTPRoutemetadata: { name: mcp-firewalla-ro, namespace: agentgateway }spec: parentRefs: - { group: gateway.networking.k8s.io, kind: Gateway, name: agentgateway, sectionName: mcp-gw } hostnames: [mcp-gw.fzymgc.house] rules: - matches: [{ path: { type: PathPrefix, value: /mcp/firewalla-ro } }] backendRefs: - { group: agentgateway.dev, kind: AgentgatewayBackend, weight: 1, name: mcp-firewalla-ro }---apiVersion: agentgateway.dev/v1alpha1kind: AgentgatewayPolicymetadata: { name: mcp-firewalla-ro-apikey, namespace: agentgateway }spec: targetRefs: - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp-firewalla-ro } traffic: apiKeyAuthentication: mode: Strict secretRef: { name: agentgateway-vkeys }---apiVersion: agentgateway.dev/v1alpha1kind: AgentgatewayPolicymetadata: { name: mcp-firewalla-ro-upstream, namespace: agentgateway }spec: targetRefs: - { group: agentgateway.dev, kind: AgentgatewayBackend, name: mcp-firewalla-ro } backend: auth: secretRef: { name: agentgateway-mcp-firewalla }- Step 2: Add the upstream-bearer ExternalSecret stanza to
secrets.yaml(append, mirroring stanza (g) clickhouse)
---# (h) Firewalla MCP upstream auth — bearer the gateway injects to firewalla-mcp.apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: { name: agentgateway-mcp-firewalla, namespace: agentgateway }spec: refreshInterval: 5m secretStoreRef: { name: vault, kind: ClusterSecretStore } target: name: agentgateway-mcp-firewalla creationPolicy: Owner template: type: Opaque data: Authorization: "Bearer {{ .firewalla_mcp_auth_token }}" data: - secretKey: firewalla_mcp_auth_token remoteRef: { key: fzymgc-house/cluster/agentgateway, property: firewalla_mcp_auth_token }- Step 3: Add
vk_firewallato theagentgateway-vkeysExternalSecret insecrets.yaml
In stanza (a): add to template.data:
firewalla-ro: '{"key":"{{ .vk_firewalla }}","metadata":{"group":"mcp-ro"}}'and to data:
- secretKey: vk_firewalla remoteRef: { key: fzymgc-house/cluster/agentgateway, property: vk_firewalla }-
Step 4: Add
mcp-firewalla.yamlto the agentgatewaykustomization.yamlresources list. -
Step 5: Validate
Run: kubectl kustomize argocd/app-configs/agentgateway
Expected: renders including the new backend/route/policies/secret with no error.
- Step 6: Commit
jj commit -m "feat(agentgateway): firewalla-ro MCP route + VK + upstream bearer [hl-vbvu.1]"
Task 12: cluster-app ArgoCD Application
Section titled “Task 12: cluster-app ArgoCD Application”Files:
-
Create:
argocd/cluster-app/templates/firewalla-mcp.yaml(mirrorsclickhouse-mcp.yaml) -
Step 1: Write the Application
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: firewalla-mcp namespace: argocd annotations: argocd.argoproj.io/sync-wave: "6" finalizers: - resources-finalizer.argocd.argoproj.iospec: project: default source: repoURL: https://github.com/fzymgc-house/selfhosted-cluster targetRevision: HEAD path: argocd/app-configs/firewalla-mcp destination: server: https://kubernetes.default.svc namespace: firewalla-mcp syncPolicy: automated: { prune: true, selfHeal: true } syncOptions: - CreateNamespace=true - ServerSideApply=true ignoreDifferences: - kind: ExternalSecret group: external-secrets.io jqPathExpressions: - .spec.data[].remoteRef.conversionStrategy - .spec.data[].remoteRef.decodingStrategy - .spec.data[].remoteRef.metadataPolicy- Step 2: Validate the cluster-app renders
Run: helm template argocd/cluster-app | rg firewalla-mcp (or the repo’s cluster-app render command)
Expected: the Application appears.
- Step 3: Commit
jj commit -m "feat(cluster-app): firewalla-mcp ArgoCD Application (sync-wave 6) [hl-vbvu.1]"
Task 13: Velero exclusion + docs
Section titled “Task 13: Velero exclusion + docs”Files:
-
Modify:
argocd/app-configs/velero/backup-schedule.yaml,docs/reference/services.md,docs/reference/secrets.md -
Step 1: Add
firewalla-mcpto VeleroexcludedNamespacesunder the Telemetry/MCP category comment (stateless MCP server). -
Step 2: Add a
services.mdrow for the firewalla-ro MCP: hostmcp-gw.fzymgc.house/mcp/firewalla-ro, VKvk_firewalla, imageghcr.io/fzymgc-house/firewalla-mcp, read-only. -
Step 3: Add a
secrets.mdrow forsecret/fzymgc-house/cluster/firewalla-mcp(msp_id,token,box_id) and note the newagentgatewayproperties (firewalla_mcp_auth_token,vk_firewalla). -
Step 4: Commit
jj commit -m "docs: firewalla-mcp service + secrets + velero exclusion [hl-vbvu.1]"
Task 14: Verification (post-merge / post-sync)
Section titled “Task 14: Verification (post-merge / post-sync)”Files: none.
- Step 1: Confirm the app synced and pod is healthy
Run (read-only): kubectl -n firewalla-mcp get pods,externalsecret and check pod Ready + ExternalSecret SecretSynced=True.
- Step 2: Confirm the ExternalSecret materialized all keys
Run: kubectl -n firewalla-mcp get secret firewalla-mcp-secrets -o jsonpath='{.data}' | jq 'keys'
Expected: ["box_id","mcp_auth_token","msp_id","token"].
- Step 3: Confirm the gateway route serves the tool list
Run: claude mcp get firewalla-ro (configured with the vk_firewalla key) OR a curl to https://mcp-gw.fzymgc.house/mcp/firewalla-ro with the VK header doing an MCP tools/list.
Expected: the 10 read tools listed (no create_*/*_rule write tools).
- Step 4: Smoke a real read
Call list_boxes through the MCP client.
Expected: HTTP 200 with the box(es) from the MSP account.
- Step 5: Record outcome on the bead
bd note hl-vbvu.1 "Read-only Firewalla MCP live at mcp-gw.fzymgc.house/mcp/firewalla-ro; verified list_boxes 200." Leave hl-vbvu.1 in_progress until merge.
Spec Coverage Map
Section titled “Spec Coverage Map”| Spec section | Task(s) |
|---|---|
| §3 Repo & toolchain | 1, 8 |
| §4 MSP client | 3, 4, 5, 6 |
| §5 Tool surface (read-only) | 7 |
| §6 Config & secrets | 2, 9, 10 |
| §7 Auth model (3 layers) | 7 (bearer), 9 (vault), 11 (VK + upstream) |
| §8 Cluster wiring | 10, 11, 12, 13 |
| §9 Error handling | 3 (error mapping), 4 (retry) |
| §10 Testing | 2–7 (unit), 10/11/12 (kustomize), 14 (live) |
| §11 Rollout & verification | 8, 14 |
Out of scope (fast-follow / separate beads)
Section titled “Out of scope (fast-follow / separate beads)”- Write tools (
create_rule,pause_rule,resume_rule, target-list CRUD) on a/mcp/firewalla-rwroute — separate bead (enableshl-12z4). - The sibling Firewalla triage/debugging skill (
hl-vbvu.2). - Per-user OIDC auth (engram-style) — only if per-user identity is later required.