Documentation

Everything needed to run aegis. For putting it behind a reverse proxy, see the deployment guide. The full specification lives in spezifikation.md; where the two disagree, the spec wins.

Quick start

docker
docker run -d --name aegis -p 2019:2019 \
  -v "$PWD/config.yaml:/etc/aegis/config.yaml:ro" \
  -e AEGIS_PUBLIC_URL=https://aegis.example.com \
  --read-only --cap-drop ALL \
  ghcr.io/andreaskasper/aegis:latest

Or with Compose:

docker-compose.yml
services:
  aegis:
    image: ghcr.io/andreaskasper/aegis:latest
    ports: ["2019:2019"]
    volumes:
      - ./config.yaml:/etc/aegis/config.yaml:ro
    environment:
      AEGIS_PUBLIC_URL: https://aegis.example.com
    read_only: true
    cap_drop: [ALL]
    restart: unless-stopped

Then point an MCP client at https://aegis.example.com/mcp. The client discovers the OAuth endpoints, registers itself, and opens the login page in a browser.

Aegis does not terminate TLS. Put it behind a reverse proxy in production, and do not publish port 2019 when you do. The deployment guide has ready-to-run Compose setups for Traefik with Let's Encrypt, for a Cloudflare Tunnel, and for Cloudflare's proxy in front of Traefik — along with the pitfalls: the client IP behind a proxy, Cloudflare Access breaking MCP clients, and SSE timeouts.

Building from source instead:

shell
git clone https://github.com/andreaskasper/aegis
cd aegis/src
go build -o aegis .
AEGIS_CONFIG=../config.yaml AEGIS_PUBLIC_URL=http://localhost:2019 ./aegis

Configuration

One YAML file holds everything. Users are the top-level unit: each user brings its own secrets and its own targets, so what a user can do is answerable by reading one contiguous block.

config.yaml
server:
  listen: ":2019"
  public_url: "https://aegis.example.com"   # required
  max_response_bytes: 1048576               # 1 MiB
  request_timeout: "30s"
  token_ttl: "12h"
  code_ttl: "60s"
  login_rate_limit: "10/m"                  # per source IP

users:
  - name: andreas
    password: "bcrypt:$2a$12$..."
    allow_any: false

    secrets:
      lexware_token: "env:LEXWARE_TOKEN"
      github_pat: "file:/run/secrets/github_pat"

    targets:
      - id: lexware
        description: "Lexware Office accounting API"
        base_url: "https://api.lexware.io"
        methods: [GET, POST]
        paths: ["/v1/**"]
        rate_limit: "60/m"
        inject:
          headers:
            Authorization: "Bearer ${lexware_token}"

Server

KeyDefaultMeaning
listen:2019Listen address.
public_url—Required. The URL clients reach aegis on; OAuth redirect URIs are derived from it. May be set with AEGIS_PUBLIC_URL instead.
max_response_bytes1048576Responses beyond this are truncated before reaching the model.
request_timeout30sTimeout for outbound requests.
token_ttl12hAccess token lifetime.
code_ttl60sAuthorization code lifetime.
login_rate_limit10/mFailed logins allowed per source IP. Behind a proxy this becomes a single shared bucket — see the client IP problem.

Users

KeyMeaning
nameLogin name. [a-zA-Z0-9._-]{1,64}, unique.
passwordA literal password or a bcrypt: hash. See value prefixes.
allow_anyIf true, this user may also request arbitrary public URLs. No credentials are attached outside a configured target, and the network guard still applies. Default false.
secretsMap of name to value. Names are [a-zA-Z0-9_]{1,64}.
targetsList of permitted destinations, see targets.

Value prefixes

Every secret value, and every password, may be literal or a reference. Resolution happens once at load time — an unresolvable reference is a configuration error, not a runtime surprise.

FormMeaning
hunter2Literal value.
env:NAMERead from the environment. Unset or empty fails the load.
file:/pathRead from a file, trailing newline trimmed — for Docker and Podman secrets.
bcrypt:$2a$...Passwords only: a bcrypt hash. Generate with aegis hashpw.
literal:env:xEscape hatch for a literal value that starts with a prefix keyword.
Secrets shorter than 8 characters are not redacted from responses. Below that length the false-positive rate would corrupt legitimate content. Aegis logs a warning at startup naming each such secret — it is not a silent compromise, but it is a real gap. Use long credentials.

Targets

A target is a permitted destination. The first target whose scheme, host, port and path pattern accept a URL wins; the method is checked afterwards, so the audit log can name the target that was attempted.

KeyDefaultMeaning
id—Required, unique within the user.
description—Required. Shown to the model in list_targets, so write it for the model.
base_url—Required. Absolute http or https, no query or fragment. A base URL in a private range is rejected at load time.
methods[GET]Allowed HTTP methods.
paths["/**"]Allowed path patterns.
rate_limitunlimited<count>/<s|m|h>, e.g. 60/m. The guard against a model in a loop.
timeoutserver defaultPer-target request timeout.
max_response_bytesserver defaultPer-target response cap.
follow_redirectsfalseIf true, up to 5 hops are followed and every hop is re-checked against the target and the network guard.
inject—What aegis attaches on the way out.

Path patterns

TokenMatches
*Any characters within one segment.
**Zero or more whole segments. Only valid as a complete segment.
?A single character within one segment.

Matching is case-sensitive and runs on the decoded, cleaned path. A request path containing .. is rejected before matching, so traversal cannot launder a URL into a narrower pattern.

Pattern/v1/contacts/v1/contacts/42/v2/x
/v1/**yesyesno
/v1/*yesnono
/**yesyesyes

Injection

Injection rules are attached to the target, never chosen by the model. Values may contain ${secret_name}; $${ is a literal ${.

inject
inject:
  headers:
    Authorization: "Bearer ${token}"      # set, overwriting the model
  query:
    appid: "${api_key}"                   # set on the final URL
  body:
    "$.auth.token": "${token}"            # JSON bodies only

Body injection applies only when the request body parses as JSON and the content type is JSON; otherwise the request is refused with body_injection_failed. Intermediate objects on the path are created as needed.

Reloading

Aegis watches the config file and reloads on change; SIGHUP forces it. If the new file does not parse or does not validate, the previous configuration stays active and one error line is logged. Existing sessions survive a reload — which matters, because a restart logs everyone out.

shell
docker kill -s HUP aegis

A removed user invalidates its tokens on their next use. Target changes take effect on the next request.

Authentication

Aegis is its own OAuth 2.1 authorization server, so a compliant MCP client needs nothing but the URL.

EndpointPurpose
/.well-known/oauth-protected-resourcePoints at the authorization server.
/.well-known/oauth-authorization-serverEndpoint and capability metadata.
POST /registerDynamic client registration (RFC 7591). Public clients only, so PKCE is mandatory.
GET /authorizeThe login form.
POST /authorizeCredential check, then an authorization code.
POST /tokenCode plus PKCE verifier, or a refresh token, in exchange for an access token.
GET /healthzUnauthenticated health check.
/mcpThe MCP endpoint. POST for JSON-RPC, GET for the SSE stream, DELETE to end the session.

There is no consent screen: a successful login issues a token scoped to that user's targets. The token is the identity — it decides what is reachable and which secrets are attached.

Everything is in memory. Registered clients, authorization codes and tokens do not survive a restart. After docker restart aegis, clients re-register and users sign in again. That is deliberate: a container holding credentials should leave nothing on disk. Hot-reloading the config is what keeps restarts rare.

Tools

list_targets

No arguments. Returns the targets the calling user may reach. Injection rules, secret names and secret values are not part of the response.

result
{
  "targets": [
    {
      "id": "github",
      "description": "GitHub REST API, read-only",
      "base_url": "https://api.github.com",
      "methods": ["GET"],
      "paths": ["/repos/andreaskasper/**", "/user"],
      "rate_limit": "120/m"
    }
  ],
  "allow_any": false
}

http_request

ArgumentTypeNotes
urlstringRequired. Absolute, including scheme and host.
methodstringDefault GET.
headersobjectReserved and target-injected headers are rejected.
queryobjectMerged into the URL. Injected parameters win.
bodystringRaw request body, up to 1 MiB.

An upstream 4xx or 5xx is a successful tool call — the status is reported rather than raised. Only aegis-side refusals are errors.

result
{
  "status": 200,
  "headers": {"content-type": "application/json"},
  "body": "...",
  "truncated": false,
  "target": "github",
  "duration_ms": 143
}

Headers the model may not set

Authorization, Proxy-Authorization, Cookie, Host, Content-Length, Connection, Transfer-Encoding, Upgrade, X-Forwarded-*, X-Real-IP — plus any header the matched target injects. A header value containing a line break is refused too.

Request pipeline

Ordered. Any step may end the request.

#StepOn failure
1Authenticate the bearer token to exactly one user401
2Parse and normalise the URL, reject traversal and embedded credentials400
3Match a target of that user403
4Network guard: resolve and check the destination403
5Rate limit for this user and target429
6Method allowed by the target403
7Header check400
8Request body size413
9Inject headers, query parameters and body fields400
10Send502 / 504
11Redact every secret value from body and headers—
12Truncate to the response cap—
13Write one audit line—

Redaction runs before truncation, so a secret cannot survive by straddling the cut. Set-Cookie is dropped entirely and never reaches the model.

Errors

Aegis-side refusals come back as a tool error with a stable code:

tool error
{
  "error": "no_target",
  "message": "No permitted target matches this URL. Call list_targets to see what is available.",
  "status": 403
}
CodeStatusMeaning
invalid_url400Not absolute, bad scheme, credentials in the URL, or .. in the path.
reserved_header400The model tried to set a reserved or injected header.
body_injection_failed400Body injection is configured but the body is not JSON.
no_target403No target matched the URL.
method_not_allowed403Target matched, method not permitted.
blocked_network403The destination resolves into a blocked range.
redirect_out_of_scope403A redirect left the target.
request_too_large413Request body over 1 MiB.
rate_limited429Bucket empty. Carries retry_after_ms.
upstream_timeout504The upstream did not respond in time.
upstream_error502Connection or TLS failure.

Audit log

One JSON object per line on stdout, for every request — allowed or refused.

stdout
{"ts":"2026-07-28T21:14:02.183Z","level":"info","event":"request",
 "user":"andreas","client_id":"aBc123","target":"github",
 "method":"GET","url":"https://api.github.com/user","status":200,
 "duration_ms":143,"bytes":312,"truncated":false,
 "injected":["github_pat"],"redacted":0}

injected lists secret names only. Any query parameter the target injects is written as [REDACTED] in the logged URL. Request and response bodies are never logged, at any level.

A non-zero redacted count is worth attention: it means the upstream reflected one of your credentials back, and redaction caught it.

Other events: startup, shutdown, config_reloaded, config_error, short_secret, login_success, login_failed, client_registered, token_issued, token_reuse_detected.

CLI

CommandPurpose
aegisRun the server.
aegis validate [path]Parse, resolve and validate a config. Prints every problem at once and exits non-zero on error. Never prints a secret value.
aegis hashpwRead a password from a TTY or stdin and print a bcrypt hash.
aegis versionVersion, commit and build date.
aegis validate
$ aegis validate config.yaml
config.yaml: OK
  public_url: https://aegis.example.com
  listen:     :2019

  user andreas (allow_any=false)
    secrets: github_pat, lexware_token
    target lexware   GET,POST https://api.lexware.io  paths=/v1/**
    target github    GET https://api.github.com  paths=/repos/andreaskasper/**,/user

Environment

VariableDefaultPurpose
AEGIS_CONFIG/etc/aegis/config.yamlConfig path.
AEGIS_LISTEN:2019Listen address. Overrides the config.
AEGIS_PUBLIC_URL—External base URL. Overrides the config.
AEGIS_LOG_LEVELinfodebug, info, warn, error.

Any variable referenced by an env: value in the config must be present in the container's environment as well.