aiMCPGate
Π ΡΡΡΠΊΠ°Ρ Π²Π΅ΡΡΠΈΡ β README_RU.md.
A gateway / proxy for MCP servers (Model Context Protocol) written in Go.
It presents itself to an MCP client (Claude Code, Cursor, etc.) as one
MCP server, while under the hood it multiplexes calls across several
upstream MCP servers, aggregates their tools, prompts and resources into
one catalog, and logs every call.
Status: MVP complete (Stages 0β6) + post-MVP Stages 7β12 and 14 further
rounds shipped in v0.3.0. Phase 1 β multiplexing stdio upstreams behind a
stdio endpoint with a call log; Phase 2 β HTTP/SSE client-facing transport,
HTTP upstreams, a CLI log viewer (mcp-gate logs); release pipeline
(goreleaser, cross-compiled for linux/darwin/windows Γ amd64/arm64, no
CGO). Post-MVP added upstream auto-restart, hot config reload, tool
filtering/renaming, doctor, and β in v0.3.0 β full prompts/resources/
resources/templates/completion aggregation, ping, progress forwarding
and real cancellation, logging/setLevel fan-out, per-upstream call limits
(rate limit / concurrency / result truncation / timeout), a lazy catalog and
tools/list pagination, SSE serverβclient streams on both the client and the
upstream side, and elicitation/create proxying (stdioβstdio only).
Not implemented: sampling/roots proxying from an upstream to the
client, elicitation over the HTTP transport (either side), a per-client
access policy, and DELETE /mcp session termination.
Releases
Cross-platform binaries are built via goreleaser
(.goreleaser.yaml): linux/darwin/windows Γ amd64/arm64, no CGO,
the version is baked in via -ldflags -X main.version=..., checksums land in
SHA256SUMS. Local dry run: goreleaser release --snapshot --clean.
Install from MCP registry
Besides the raw release binaries, the gateway ships as an OCI image on GitHub
Container Registry and as an npm wrapper package β the two formats MCP
registries install from.
Docker:
docker run --rm -i -v $(pwd)/config.yaml:/config.yaml ghcr.io/akomyagin/aimcpgate serve
-i is mandatory: the gateway talks MCP over stdio, so the client must keep
stdin open (without it the container sees EOF and exits immediately). The
image has no config of its own, so mount yours β the example above mounts it
onto the default path /config.yaml; any other path works with serve -c.
To reproduce a registry sandbox check (Glama.ai etc.) without any real
upstream, use the demo config baked into the image β this exact command is
what a sandbox should run:
docker run --rm -i ghcr.io/akomyagin/aimcpgate serve -c /demo.config.yaml
npx (downloads the prebuilt binary for your platform on first install and
verifies its SHA256 checksum):
npx aimcpgate serve -c ./config.yaml
Image policy: the OCI image contains only the mcp-gate binary β no
runtimes for stdio upstreams (no node/npx, python, shells). If your config
launches stdio upstream servers, extend the image yourself and install what
they need; HTTP upstreams work out of the box (CA certificates are included).
Demo config: demo.config.yaml and the hidden
__demo-echo subcommand exist only so registry sandboxes (Glama.ai) can
introspect the gateway without any real upstream β never use them in a real
deployment.
Why
An active MCP user typically has several servers configured (filesystem,
GitHub, search, custom ones), each one duplicated in every client's own
config. aiMCPGate gives you:
- One entry point β a single MCP endpoint instead of N entries in the
client config.
- One catalog β every upstream server's tools and prompts merged together
(namespaced as
<upstream>__<tool> so names never collide), plus their
resources and resource templates (addressed by URI, so never renamed).
- A call log β which upstream, which tool, when, success/failure. This is
the value added on top of "just a proxy".
Solo pet project: the priority is learning Go (concurrency, os/exec,
JSON-RPC 2.0, the stdio and HTTP/SSE transports). Cost β $0/month by
default (a local process), no telemetry.
How it works (short version)
MCP client ββstdio/HTTPβββΆ aiMCPGate ββJSON-RPCβββΆ upstream A (stdio)
β βββββββββββΆ upstream B (stdio)
call log βββββββββββΆ upstream C (http, Phase 2)
MVP (two phases)
- Phase 1 β multiplexing 2+ stdio upstreams behind one stdio
endpoint (the same transport Claude Code speaks) plus basic logging.
- Phase 2 β HTTP/SSE transport, HTTP upstream servers, a log viewer
(the CLI one was built; the web view was deliberately dropped), optionally an
access policy β that one was considered and declined.
Build
export PATH="$HOME/sdk/go/bin:$PATH"
go build ./...
go vet ./...
go test -race ./...
go run ./cmd version
Usage
mcp-gate serve --config ./config.yaml
mcp-gate serve --config ./config-http.yaml
mcp-gate doctor --config ./config.yaml
mcp-gate call github__search_repositories '{"query":"mcp"}' --config ./config.yaml
mcp-gate catalog --config ./config.yaml --top 20
mcp-gate logs --file ./logs/calls.jsonl --tail 50
mcp-gate logs --config ./config.yaml --upstream github --status err
mcp-gate logs --config ./config.yaml --follow
mcp-gate logs --config ./config.yaml --stats
mcp-gate token --generate
mcp-gate token --config ./config-http.yaml
mcp-gate client-config --config ./config-http.yaml
mcp-gate skill > .claude/skills/mcp-gate/SKILL.md
mcp-gate completion bash > /etc/bash_completion.d/mcp-gate
All commands except token --generate, completion and skill (which falls
back to a built-in guide) load the config: pass --config, or drop a
config.yaml next to the binary (see Configuration below).
serve, doctor, call and catalog also accept --env-file ./.env β a
minimal KEY=VALUE parser applied before the config is loaded, so ${VAR}
references inside the config resolve from that file. The real process
environment always wins over the file.
Reloading config (SIGHUP)
The gateway reloads its configuration live on SIGHUP β no restart, no
dropped client connection. Edit config.yaml and send the signal:
kill -HUP $(pgrep -f 'mcp-gate serve')
On reload the gateway diffs the new config against the running upstreams and
applies the minimum change: newly added upstreams are launched, removed (or
enabled: false) ones are shut down, upstreams whose launch fields
(command/args/url/env/headers) changed are relaunched, and upstreams
where only the tool filter changed (allow/deny/rename, or the catalog
projection rules strip_annotations/strip_output_schema/max_description/
describe) are re-projected
without any restart. Call limits (rate_limit, max_concurrent,
max_result_bytes, call_timeout β global or per-upstream) are also applied
live: they never require a relaunch, the next call simply uses the new values.
Unchanged upstreams keep running untouched. A bad edit
(invalid YAML, failed validation) is logged and ignored β the currently running
config stays live, so a typo never takes the gateway down.
Behavioural note: since the gateway installs a SIGHUP handler, SIGHUP no
longer terminates the process the way the OS default would. To stop the gateway
use Ctrl-C, SIGINT, or SIGTERM.
SIGHUP is Unix-only. On Windows β or anywhere you would rather not send signals
β use the opt-in polling alternative instead:
mcp-gate serve --config ./config.yaml --watch-config
mcp-gate serve --config ./config.yaml --watch-config=10s
It stats the config file's mtime on that interval and applies the same reload
path SIGHUP takes. Running it alongside the SIGHUP handler is safe.
Configuration
Without --config, the gateway looks for config.yaml next to its own
binary (e.g. if mcp-gate is installed at /etc/gate/, it looks for
/etc/gate/config.yaml β regardless of the working directory it was launched
from). If that file doesn't exist and --config wasn't passed either, it
errors explicitly instead of starting an empty gateway. Relative paths inside
the config (log_file, skill_file, debug_payload_log) resolve against the
config file's own directory, not the current working directory.
An upstream is enabled by default: omit enabled: entirely and it is
launched like any other. To keep one out of the gateway without deleting its
config, disable it explicitly with enabled: false β it then appears
neither in tools/list nor in mcp-gate doctor's table. Careful: a valueless
enabled: (or enabled: null) reads as omitted, so commenting the value out
leaves the upstream running β only the literal false disables it.
Note: the "next to the binary" lookup uses the path of the running executable.
Under go run ./cmd ... that executable is a throwaway build in a temp
directory, so the default lookup will not find your config.yaml β pass
--config explicitly when using go run, or run a built binary.
Full example with every field β config.example.yaml.
The set of upstream servers is declared in YAML; secrets (tokens) go through
env/.env (${VAR} expansion at load time), never committed in the config.
Each upstream sets exactly one of command (stdio subprocess) or url
(HTTP server, Streamable HTTP) β the connection kind is inferred automatically.
transport: stdio
listen_addr: "127.0.0.1:28080"
log_file: ./logs/calls.jsonl
upstreams:
- name: filesystem
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"]
enabled: true
- name: github
command: github-mcp-server
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
enabled: true
- name: remote
url: https://mcp.example.com/mcp
headers:
Authorization: "Bearer ${REMOTE_MCP_TOKEN}"
enabled: true
License
MIT β see LICENSE.