Skip to content

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”

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).


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_cursor is JSON null when absent; string unmarshals JSON null to "", 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"


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]any
type Device = map[string]any
type Alarm = map[string]any
type Flow = map[string]any
type Rule = map[string]any
type 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_list by 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 /:id GET 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 same Register following this pattern; include them when implementing — each filters the corresponding list by an id input 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 build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/firewalla-mcp ./cmd/firewalla-mcp
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/firewalla-mcp /firewalla-mcp
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/firewalla-mcp"]
  • Step 2: Write .goreleaser.yaml (image → ghcr.io/fzymgc-house/firewalla-mcp, multi-arch, digest output). Mirror the engram repo’s .goreleaser.yaml ko/docker config.

  • Step 3: Write the release workflow (.github/workflows/release.yml): on tag v*, go test ./..., then goreleaser release publishing 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.

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/):

Terminal window
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
Terminal window
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: v1
kind: Namespace
metadata:
name: firewalla-mcp
  • Step 2: external-secret.yaml (mirrors clickhouse-mcp/external-secret.yaml)
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: firewalla-mcp-secrets
namespace: firewalla-mcp
spec:
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/v1
kind: Deployment
metadata:
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: v1
kind: Service
metadata:
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/v2
kind: CiliumNetworkPolicy
metadata:
name: firewalla-mcp-default
namespace: firewalla-mcp
spec:
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/v1beta1
kind: Kustomization
namespace: firewalla-mcp
resources:
- 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.yaml ro half)
---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata: { 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/v1
kind: HTTPRoute
metadata: { 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/v1alpha1
kind: AgentgatewayPolicy
metadata: { 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/v1alpha1
kind: AgentgatewayPolicy
metadata: { 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/v1
kind: ExternalSecret
metadata: { 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_firewalla to the agentgateway-vkeys ExternalSecret in secrets.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.yaml to the agentgateway kustomization.yaml resources 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]"


Files:

  • Create: argocd/cluster-app/templates/firewalla-mcp.yaml (mirrors clickhouse-mcp.yaml)

  • Step 1: Write the Application

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: firewalla-mcp
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "6"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
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]"


Files:

  • Modify: argocd/app-configs/velero/backup-schedule.yaml, docs/reference/services.md, docs/reference/secrets.md

  • Step 1: Add firewalla-mcp to Velero excludedNamespaces under the Telemetry/MCP category comment (stateless MCP server).

  • Step 2: Add a services.md row for the firewalla-ro MCP: host mcp-gw.fzymgc.house/mcp/firewalla-ro, VK vk_firewalla, image ghcr.io/fzymgc-house/firewalla-mcp, read-only.

  • Step 3: Add a secrets.md row for secret/fzymgc-house/cluster/firewalla-mcp (msp_id, token, box_id) and note the new agentgateway properties (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 sectionTask(s)
§3 Repo & toolchain1, 8
§4 MSP client3, 4, 5, 6
§5 Tool surface (read-only)7
§6 Config & secrets2, 9, 10
§7 Auth model (3 layers)7 (bearer), 9 (vault), 11 (VK + upstream)
§8 Cluster wiring10, 11, 12, 13
§9 Error handling3 (error mapping), 4 (retry)
§10 Testing2–7 (unit), 10/11/12 (kustomize), 14 (live)
§11 Rollout & verification8, 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-rw route — separate bead (enables hl-12z4).
  • The sibling Firewalla triage/debugging skill (hl-vbvu.2).
  • Per-user OIDC auth (engram-style) — only if per-user identity is later required.