Local Development
This page covers developing the Gig'MCP gateway itself: building from source, running the test suite, the dev compose stack, and how the repo is laid out. For contributing a server to the registry instead, see Submitting a server.
Prerequisites
- Go 1.26+ —
go.moddeclaresgo 1.26.2. - Docker — the full test suite and the compose stack run in containers. The sandbox runtime is Linux-only; on macOS everything Linux-specific runs inside Docker.
- pnpm — for the Next.js dashboard in
web/. - A sibling checkout of the registry repo. The gateway's
go.modhas areplacedirective pointinggithub.com/gigmcp/registry/schemaat../gigmcp-registry/schema, and the compose file'sadditional_contextssupplies../gigmcp-registryto the Docker build the same way. Clone both repos next to each other:
git clone https://github.com/gigmcp/gigmcp
git clone https://github.com/gigmcp/registry gigmcp-registry
Without ../gigmcp-registry in place, neither go build nor docker compose up --build will work.
Running tests
The Makefile has three targets:
| Target | What it does |
|---|---|
make dev-image | Builds the dev container image (gigmcp-dev) from Dockerfile.dev |
make test | Full suite — including sandbox, egress, and vault tests — inside a Linux container |
make test-local | Host-side go test ./... — Linux-only tests self-skip on macOS |
make test # full suite, in Docker
make test-local # fast host-side subset
make test runs the dev image with several relaxed Docker security options, because the tests exercise the real sandbox:
docker run --rm \
--security-opt seccomp=unconfined \
--security-opt apparmor=unconfined \
--security-opt systempaths=unconfined \
--cap-add NET_ADMIN \
-v $(PWD):/src -w /src \
-v $(abspath $(PWD)/../gigmcp-registry):/gigmcp-registry \
-v gigmcp-gomod:/go/pkg/mod \
-v gigmcp-gocache:/root/.cache/go-build \
gigmcp-dev go test ./...
Why each option is needed:
seccomp=unconfined/apparmor=unconfined— bubblewrap needs unprivileged user namespaces, which Docker's default profiles block.systempaths=unconfined— unmasks/procso bwrap can mount a fresh procfs in the sandbox's PID namespace.NET_ADMIN— the egress proxy creates veth pairs and moves them into sandbox network namespaces. NoSYS_ADMINor privileged mode is needed.
Go module and build caches persist across runs in named volumes (gigmcp-gomod, gigmcp-gocache).
Running the stack
The production-shaped stack is docker-compose.yml. The two required environment variables are GIG_BEARER_TOKEN and GIG_MASTER_KEY — see Configuration for the full list.
GIG_BEARER_TOKEN=$(openssl rand -hex 24) \
GIG_MASTER_KEY=$(openssl rand -hex 32) \
docker compose up --build -d
This starts three services:
| Service | Port | Role |
|---|---|---|
issuer-proxy | 8080 (published) | alpine/socat sidecar that owns the network namespace the gateway joins; also forwards localhost:8082 to the dev Zitadel IdP |
gateway | — (shares the sidecar's netns) | The Go gateway, with cap_add: NET_ADMIN and the same relaxed security options as make test |
web | 3000 | The Next.js dashboard; built with GATEWAY_INTERNAL_URL=http://issuer-proxy:8080 baked in at build time |
The dev OIDC fixture
docker-compose.dev.yml is a dev-only fixture: a local Zitadel identity provider for OIDC development. Not for production.
docker compose -f docker-compose.dev.yml up -d
- Console:
http://localhost:8082/ui/console - Credentials:
admin/Password1! - Pinned to
ghcr.io/zitadel/zitadel:v2.71.0(the last v2 release that bundles Console and Login UI in one container), backed bypostgres:17-alpine.
Both compose files live in the same directory, so they share one compose project and one default network — service DNS resolves across them. That's how the issuer-proxy sidecar can forward localhost:8082 to the zitadel service: the gateway's OIDC issuer URL is http://localhost:8082 (the exact string go-oidc verifies against the discovery document), but inside the gateway container localhost is the container itself.
:::warning Start order matters
Bring up docker-compose.dev.yml (Zitadel) first, and never pass --remove-orphans to the main compose file — it would tear down the Zitadel containers, which belong to the same project. Starting the sidecar before the gateway also avoids racing the gateway's fatal 10-second OIDC discovery at boot.
:::
OIDC is optional: leaving GIG_OIDC_ISSUER and friends unset disables /api, while /mcp keeps working. See Authentication.
Repo tour
gigmcp/
├── cmd/
│ ├── gateway/ # main entrypoint: installs GIG_INSTALL refs from the signed
│ │ # index, spawns each stored server in an egress-isolated
│ │ # bubblewrap sandbox behind the embedded MITM proxy
│ ├── bootstrap/ # trusted in-sandbox init: completes per-sandbox network
│ │ # setup inside the netns, drops ALL capabilities,
│ │ # installs the seccomp filter, execs the untrusted server
│ ├── echo-mcp/ # trivial stdio MCP server used by the walking skeleton
│ └── fetch-mcp/ # test MCP server with a single `fetch` tool (asserts
│ # placeholder→real-key swap at the proxy)
├── internal/ # gateway packages (see table below)
├── web/ # Next.js dashboard (pnpm workspace)
├── docker-compose.yml # gateway + dashboard + issuer-proxy sidecar
├── docker-compose.dev.yml # dev-only Zitadel OIDC fixture
├── Dockerfile / Dockerfile.dev
├── Makefile
└── DESIGN.md # vision, threat model, 20 design decisions
internal/ packages
| Package | Responsibility |
|---|---|
api | REST control plane: JSON endpoints behind the session middleware, mounted under /api/ (REST API reference) |
auth | Sessions, profile tokens, the OIDC login flow, impersonation middleware, RBAC — the Go gateway is the sole auth authority |
config | Reads gateway configuration from the environment (compose-env-first) |
echo | The trivial echo MCP server backing cmd/echo-mcp |
gateway | Aggregates backend MCP servers behind one MCP server, re-exposing every backend tool as <backend>_<tool> |
netmgr | Host-side per-sandbox networking via netlink: allocates a /30, creates a veth pair, moves the peer into the sandbox netns — CAP_NET_ADMIN only |
oci | Daemonless image handling: pulls digest-pinned images and extracts the entrypoint binary (pure go-containerregistry) |
proxy | The embedded MITM egress proxy: runtime ECDSA P-256 CA, per-host leaf certs, placeholder→real-key injection (egress proxy) |
registry | Fetches and verifies the signed index; installs servers: resolve → pull by digest → verify → extract → record |
sandbox | Builds bubblewrap commands: fresh user/PID/net/mount/IPC/UTS/cgroup namespaces, --clearenv, read-only binds, private procfs (sandbox isolation) |
seccomp | Scoped seccomp-BPF filter installed by cmd/bootstrap — closes the nested-user-namespace escape; denies mount, ptrace, keyctl, bpf, module loading |
store | Persistence behind a driver-agnostic interface (SQLite today; Postgres per design decision #14) |
vault | Envelope encryption for credentials at rest: per-secret DEK wrapped by the startup master key, XChaCha20-Poly1305 (vault) |
For the reasoning behind these boundaries, read DESIGN.md in the repo root — it records the threat model and all 20 design decisions.
Working on the dashboard
The dashboard lives in web/ — a Next.js app managed with pnpm:
cd web
pnpm install
pnpm dev # next dev
pnpm build # next build
pnpm lint # eslint
In the compose stack, the dashboard's server-side rewrite target (GATEWAY_INTERNAL_URL) is baked at build time by next.config.ts rewrites — the standalone server ignores the runtime env var. The compose file sets it to http://issuer-proxy:8080 because the gateway shares the sidecar's network namespace.
When the dashboard is the browser-facing entry point, the gateway's GIG_OIDC_REDIRECT_URL and GIG_PUBLIC_URL should point at the dashboard's origin (e.g. http://localhost:3000) so the OIDC callback lands on the right origin.
See Dashboard for what the dashboard does in production.
See also
- Quickstart — running Gig'MCP as a user
- System overview — how the pieces fit together
- Deployment — production compose setup
- registryctl CLI — developing against the registry