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 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:
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.
Building from source instead:
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.
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
| Key | Default | Meaning |
|---|---|---|
listen | :2019 | Listen 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_bytes | 1048576 | Responses beyond this are truncated before reaching the model. |
request_timeout | 30s | Timeout for outbound requests. |
token_ttl | 12h | Access token lifetime. |
code_ttl | 60s | Authorization code lifetime. |
login_rate_limit | 10/m | Failed logins allowed per source IP. Behind a proxy this becomes a single shared bucket — see the client IP problem. |
Users
| Key | Meaning |
|---|---|
name | Login name. [a-zA-Z0-9._-]{1,64}, unique. |
password | A literal password or a bcrypt: hash. See value prefixes. |
allow_any | If 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. |
secrets | Map of name to value. Names are [a-zA-Z0-9_]{1,64}. |
targets | List 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.
| Form | Meaning |
|---|---|
hunter2 | Literal value. |
env:NAME | Read from the environment. Unset or empty fails the load. |
file:/path | Read from a file, trailing newline trimmed — for Docker and Podman secrets. |
bcrypt:$2a$... | Passwords only: a bcrypt hash. Generate with aegis hashpw. |
literal:env:x | Escape hatch for a literal value that starts with a prefix keyword. |
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.
| Key | Default | Meaning |
|---|---|---|
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_limit | unlimited | <count>/<s|m|h>, e.g. 60/m. The guard against a model in a loop. |
timeout | server default | Per-target request timeout. |
max_response_bytes | server default | Per-target response cap. |
follow_redirects | false | If 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
| Token | Matches |
|---|---|
* | 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/** | yes | yes | no |
/v1/* | yes | no | no |
/** | yes | yes | yes |
Injection
Injection rules are attached to the target, never chosen by the model. Values may contain ${secret_name}; $${ is a literal ${.
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.
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.
| Endpoint | Purpose |
|---|---|
/.well-known/oauth-protected-resource | Points at the authorization server. |
/.well-known/oauth-authorization-server | Endpoint and capability metadata. |
POST /register | Dynamic client registration (RFC 7591). Public clients only, so PKCE is mandatory. |
GET /authorize | The login form. |
POST /authorize | Credential check, then an authorization code. |
POST /token | Code plus PKCE verifier, or a refresh token, in exchange for an access token. |
GET /healthz | Unauthenticated health check. |
/mcp | The 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.
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.
{
"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
| Argument | Type | Notes |
|---|---|---|
url | string | Required. Absolute, including scheme and host. |
method | string | Default GET. |
headers | object | Reserved and target-injected headers are rejected. |
query | object | Merged into the URL. Injected parameters win. |
body | string | Raw 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.
{
"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.
| # | Step | On failure |
|---|---|---|
| 1 | Authenticate the bearer token to exactly one user | 401 |
| 2 | Parse and normalise the URL, reject traversal and embedded credentials | 400 |
| 3 | Match a target of that user | 403 |
| 4 | Network guard: resolve and check the destination | 403 |
| 5 | Rate limit for this user and target | 429 |
| 6 | Method allowed by the target | 403 |
| 7 | Header check | 400 |
| 8 | Request body size | 413 |
| 9 | Inject headers, query parameters and body fields | 400 |
| 10 | Send | 502 / 504 |
| 11 | Redact every secret value from body and headers | — |
| 12 | Truncate to the response cap | — |
| 13 | Write 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:
{
"error": "no_target",
"message": "No permitted target matches this URL. Call list_targets to see what is available.",
"status": 403
}
| Code | Status | Meaning |
|---|---|---|
invalid_url | 400 | Not absolute, bad scheme, credentials in the URL, or .. in the path. |
reserved_header | 400 | The model tried to set a reserved or injected header. |
body_injection_failed | 400 | Body injection is configured but the body is not JSON. |
no_target | 403 | No target matched the URL. |
method_not_allowed | 403 | Target matched, method not permitted. |
blocked_network | 403 | The destination resolves into a blocked range. |
redirect_out_of_scope | 403 | A redirect left the target. |
request_too_large | 413 | Request body over 1 MiB. |
rate_limited | 429 | Bucket empty. Carries retry_after_ms. |
upstream_timeout | 504 | The upstream did not respond in time. |
upstream_error | 502 | Connection or TLS failure. |
Audit log
One JSON object per line on stdout, for every request — allowed or refused.
{"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
| Command | Purpose |
|---|---|
aegis | Run 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 hashpw | Read a password from a TTY or stdin and print a bcrypt hash. |
aegis version | Version, commit and build date. |
$ 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
| Variable | Default | Purpose |
|---|---|---|
AEGIS_CONFIG | /etc/aegis/config.yaml | Config path. |
AEGIS_LISTEN | :2019 | Listen address. Overrides the config. |
AEGIS_PUBLIC_URL | — | External base URL. Overrides the config. |
AEGIS_LOG_LEVEL | info | debug, info, warn, error. |
Any variable referenced by an env: value in the config must be present in the container's environment as well.
aegis