Putting AI-Hands on Routers: Building a GenieACS MCP Server in Go

Putting AI-Hands on Routers: Building a GenieACS MCP Server in Go
Sunset in Greenwich—London, UK. Spring 2024 © Sergio Fernández
When a new capability feels like magic, the only reasonable reaction is to ask ‘how can I wire this into my own toys?’

From a spark in Claude’s doc to a fully-automated Go release pipeline

Back in March Claude dropped its “Model Context Protocol” white-paper—the spec that gives large-language models real-time hands instead of canned function-calling.

I was hooked the moment I read the handshake sequence: initialize → listTools → callTool. One month later every SaaS out there seemed to have an “/mcp” endpoint… except my long-time companion GenieACS (the open-source TR-069 / USP ACS I packaged into a Docker image that has quietly amassed > 241k pulls).

So I rolled up my sleeves and built GenieACS-MCP: a tiny Go service that turns any GenieACS instance into an MCP v1 server.

Below is the full story—equal parts LLM nerdery, Go learning journey, and DevOps dopamine.

TL;DR

  • Goal: Expose GenieACS (TR-069/USP ACS) to large-language models using Claude’s Model Context Protocol (MCP) so an AI can list devices, reboot them, push firmware, etc. in real time.
  • What I built: genieacs-mcp—a ~500-line Go side-car that registers 4 resources and 3 tools with MCP v1. Runs as a tiny 5 MB static container; talks to GenieACS only through its public REST/NBI.
  • DevOps: GoReleaser + GitHub Actions turn a git tag into multi-arch binaries and Docker images automatically. One docker run … drumsergio/genieacs-mcp:x.y.z and you’re live.

1. A very short MCP primer

MCP is a JSON-RPC dialect that:

  1. Lets an AI model ask “what tools do you have?”
  2. Describes each tool with a JSON schema (args, types, docs).
  3. Lets the model call those tools, either over HTTP or a streaming transport.

Think of it as capability-based RPC designed for LLMs: the model can only do what the server explicitly advertises.

For GenieACS that means:

Kind URI / Tool ID What it gives the LLM
Resource genieacs://device/{id} Full device document
Resource genieacs://file/{name} Firmware / file metadata
Resource genieacs://tasks/{id} Historical + pending CWMP tasks
Resource genieacs://devices/list Lightweight catalogue
Tool reboot_device Queue a Reboot CWMP task
Tool download_firmware Push a file to the CPE
Tool refresh_parameter Force a single parameter read

That’s just seven entry points—small enough for an LLM to reason about, but powerful enough for 90 % of daily ACS work.


2. Why Go?

I write Python & Bash for a living, but after building first a Redis PoC Operator, the two things I wanted to do were:

  • Expand my knowledge with Go
  • GoReleaser: build once, ship multi-arch binaries & Docker images with a single tag push.

Building an MCP server in Go let me score both.


3. Anatomy of the project

cmd/server/main.go            ← wires everything
internal/resources/*          ← read-only resource handlers
internal/tools/*              ← tool definitions + handlers
client/acs.go                 ← tiny HTTP client for GenieACS NBI
config/config.go              ← dot-env + env vars
version/version.go            ← ldflags at build-time
.github/workflows/release.yml ← GitHub actions releaser
.goreleaser.yaml              ← goreleaser definition
Dockerfile                    ← small Dockerfile

Resources

A resource is either a template ({id}) or a concrete URI.

tpl := mcp.NewResourceTemplate(
    "genieacs://device/{id}",
    "GenieACS device JSON",
    mcp.WithTemplateDescription("Raw device document as returned by NBI"),
    mcp.WithTemplateMIMEType("application/json"),
)

The Inspector UI shows templates separately; you paste an ID and hit “Read”.

Tools

Tools are even simpler:

tool := mcp.NewTool("reboot_device",
    mcp.WithDescription("Reboot a CPE via GenieACS"),
    mcp.WithString("device_id", mcp.Required()),
)
handler := func(ctx context.Context, req mcp.CallToolRequest)
    (*mcp.CallToolResult, error) {
        id, _ := req.RequireString("device_id")
        body, err := acs.RebootDevice(id)
        if err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }
        return mcp.NewToolResultText(fmt.Sprintf(
            "Reboot queued. GenieACS said: %s", body)), nil
}
s.AddTool(tool, handler)

The ACS client

The only “clever” part is client/acs.go; GenieACS has a REST-ish NBI, so a 60-line helper layer was enough.


4. GitOps all the way down

GoReleaser is the glue that turns a simple git tag into a fully-fledged, reproducible release pipeline: it vendors the Go tool-chain in a container, compiles your code for every target you declare, injects version metadata via ldflags, signs the artifacts, assembles Software-Bill-of-Materials (SBOM) and provenance files, builds OCI images, and finally publishes everything—checksums, release notes and all—to GitHub and Docker Hub. Because the entire recipe lives in a single YAML file checked into the repo, every contributor can audit or tweak the process, and every release is 100 % deterministic: clone → tag → watch the bot do the rest.

  1. Branch → PR → merge → tag vx.y.z
  2. GitHub Action runs GoReleaser:
    • Cross-compiles Windows, Linux, macOS binaries
    • Builds drumsergio/genieacs-mcp:{tag}-{arch} images
    • Creates a multi-arch manifest & GitHub release page
  3. The Docker image already has sane defaults, so spinning a new instance is:
docker run -d --name acs-mcp \
  -e ACS_URL=http://genieacs:7557 \
  -p 8080:8080 drumsergio/genieacs-mcp:latest

…and yes, the entire flow exists just because I wanted to see GoReleaser’s emoji-filled logs fly past 😎.


5. Pros, cons & lessons

Pros

  • Tiny binary (static busybox image is ~5 MB).
  • Zero coupling with GenieACS internals; only uses the public NBI.
  • LLM-friendly interface: seven concise operations.
  • Observability out of the box: MCP Inspector shows every JSON-RPC call, perfect for debugging.

Cons / Next steps

  • GenieACS’s download task wants the GridFS _id; you still would need to get it manually.
  • No streaming yet—SSE transport would allow live progress events like the push firmware operation.
  • Strictly stateless; long-running tasks (firmware upgrade) don’t push updates back to the agent.
  • Security is still a big lacking topic as highlighted by most MCP enthusiasts, and this first iteration of this server is clearly an example of it
  • Improve error handling. Right now, an error even sends a 200 JSON-RPC success envelope
  • Tests. There are no tests at all right now

6. What’s next?

  1. Add an SSE transport.
  2. Auto-discover firmware names (genieacs://files/list resource).
  3. Maybe feed the device schema to the AI so it can ask “what’s the current DSL line rate?” directly.
  4. Many other features are yet to be implemented. PRs are more than welcome.

7. Test it yourself

  1. docker-compose up -d the stack in genieacs-docker.
  2. npx @modelcontextprotocol/inspector http://localhost:8080/mcp
  3. initialize → readResource genieacs://devices/list
  4. Pick a device ID, call reboot_device.
  5. Stare at your router’s LEDs blinking.

Final thoughts

Claude’s MCP spec felt like science fiction in March; by May it is just code.

That’s the fun of this AI-agent moment: if an interface doesn’t exist yet, you can probably write it in a weekend and ship it worldwide with a single tag push.

GenieACS now speaks LLM and GoReleaser earns another ⭐ in my toolbox.

Happy hacking.

Sergio Fernández

Sergio Fernández

Senior Cloud DevOps Engineer specializing in Kubernetes.
Murcia, Spain