In my last post, I walked through the architecture of AegisGate MCP — a standalone MCP server framework with 22 security layers, zero external Go dependencies, and a vendored neural ML pipeline.
That was v1.3.0. This is v1.5.0. Here's what changed.
The 23rd Layer: Evasion Detection
The biggest addition is a new security layer wired into the live request pipeline between L1 regex scanning and L3 neural detection. We call it L2.5: Evasion Detection.
The problem it solves: attackers don't send raw prompts. They encode, split, obfuscate, and reframe. Base64-encoded payloads. Unicode homoglyphs. Zero-width characters splitting "ignore previous instructions" into "ignore previous instructions." Leet speak. CamelCase. Role-play framing.
The EvasionDetector scans for 15 evasion patterns across 4 categories:
| Category | Patterns | Example |
|---|---|---|
| Encoding | base64, URL-encoding, unicode, HTML entities | aWdub3JlIHByZXZpb3Vz |
| Splitting | zero-width chars, interleaving, word splitting | ignore previous |
| Obfuscation | leet speak, CamelCase, padding, markup injection | 1gn0r3 Pr3v10us |
| Semantic | role-play framing, instruction override, context manipulation, output constraint bypass | "pretend you are DAN..." |
Score ≥ 0.8 blocks independently. Score ≥ 0.3 logs a warning. Pure Go, no CGO required — it runs in the heuristic-only build too.
This brings the security layer count from 22 to 23. The startup log now prints the full chain:
Security chain (23 layers): auth → rbac → regex → evasion → neural → toolPoison → ...
Elicitation: Servers That Ask Back
MCP's elicitation capability lets a server request information from the client mid-tool-execution. "I need your API key to proceed — can you provide it?" The client responds via elicitation/create.
We implemented this across all three transports:
-
HTTP/SSE: Async correlation via a pending-request registry. The server sends
elicitation/createas an SSE event; the client's response arrives as a separate POST with a matching request ID. -
TCP: Synchronous request-response on the bidirectional connection. Server sends
elicitation/create, blocks until the response arrives on the same conn. - stdio: Same synchronous pattern via stdin/stdout pipes.
The ElicitationRegistry manages pending requests with a 30-second default timeout, monotonic IDs, and concurrent-safe resolution. Tool handlers call ElicitInput() and get back the user's response (or a timeout error).
resp, err := server.ElicitInput(ctx, &ElicitRequest{
Message: "Enter your API key:",
Schema: map[string]any{"type": "string"},
})
if err != nil {
return fmt.Errorf("elicitation failed: %w", err)
}
apiKey := resp.Data.(string)
The initialize response now advertises elicitation in capabilities. Clients that support it can handle the flow; clients that don't will return method not found, and the tool handler gets a timeout.
SSE Grows Up: Multiplexing and Reconnection
The original post noted a limitation: "This is single-event SSE. Each request gets one response or one ack, then the connection closes."
Two changes fix that:
Per-session SSE multiplexing. sseConns changed from map[string]*sseConn to map[string][]*sseConn. Multiple SSE streams can now run in parallel per session, and a client can reconnect while the old connection is still open.
Last-Event-ID reconnection. SSE events now carry monotonic IDs (id: 42\n). On reconnection, the client sends Last-Event-ID: 41 and the server replays missed events from a bounded ring buffer (100 events). This is the standard SSE reconnection pattern from the HTML spec.
// Event with ID
id: 42
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{...}}
// Client reconnects with Last-Event-ID: 41
// Server replays event 42 from ring buffer
JSON-RPC Batch Support
JSON-RPC 2.0 defines batch requests — an array of requests sent as a single message. All three transports (TCP, stdio, HTTP) now handle them:
[
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
{"jsonrpc":"2.0","id":2,"method":"ping"},
{"jsonrpc":"2.0","method":"notifications/initialized"}
]
Responses are collected and returned as an array. All-notification batches produce no response (per spec). Each request in the batch still passes through all 23 security layers independently.
Per-Tool Rate Limiting
We had global and per-session rate limiting. Now we have per-tool rate limiting too:
config := ServerConfigV2{
MaxCallsPerToolPerSession: 50, // max 50 calls to any single tool per session
ToolRateOverrides: map[string]int{
"exec": 5, // exec is expensive — 5 calls per session
"read_file": 120, // read_file is cheap — allow more
},
}
Atomic counter per tool per session. Resets when the session expires.
Testing: 412 → 643
| Metric | v1.3.0 | v1.5.0 |
|---|---|---|
| Unit/integration tests | 412 | 611 |
| Load/stress tests | 0 | 32 |
| Benchmarks | 10 | 14 |
| Fuzz targets | 0 | 3 |
| Coverage | 91.3% | 92.0% |
| Race detector | manual | CI (every push) |
| Total tests | 412 | 643 |
k6-Style Load Testing
32 load tests across 7 categories, build-tagged //go:build load:
| Category | What it tests |
|---|---|
| Stress | Ramp-up to 500 concurrent connections, sustained load |
| Burst | 12,710 requests/second peak, verify no drops |
| Crush | Goroutine leak detection (Δ=0 after shutdown) |
| Chaos | Random connection drops mid-session |
| HTTP | HTTP transport-specific throughput (2,290 rps) |
| Cross-transport | HTTP vs TCP overhead ratio (0.62×) |
| FD leak | File descriptor leak across 1,000 connection cycles (Δ=1) |
These run in CI on every push. The Load / Stress / Crush job takes ~1m25s and catches regressions before they reach main.
Race Detector in CI
A new CI job runs go test -race on every push and PR. It found a test-only race condition in mockResponseWriter — a test goroutine called buf.String() while another goroutine wrote via fmt.Fprintf. Production code was already safe (the real sseConn has a mutex), but the test helper wasn't. Fixed with a sync.Mutex on the mock.
CI is now 27 checks (up from 17), including benchmark regression tracking via benchstat and a go mod tidy check.
A Subtle Bug: HTTP Session ID Mismatch
This one was sneaky. When OnAuthSuccess is configured (the default), the auth middleware replaces conn.Session.ID with an RBAC session ID. The HTTP transport then returned this RBAC ID in the Mcp-Session-Id header — but stored the session in its map under the original HTTP session ID.
Result: every subsequent request with the RBAC ID from the header got "session not found or expired."
Fixed by tracking the HTTP session ID separately from conn.Session.ID in both the single-request and batch-request paths. The Mcp-Session-Id header now consistently returns the HTTP session ID, and the session map lookup works.
MCP Registry
The server is now published to the MCP Registry at io.github.aegisgatesecurity/aegisgate-mcp.
Publishing wasn't straightforward — the mcp-publisher CLI's GitHub device-flow doesn't request read:org scope, so org members get a 403 despite having public admin membership. I documented the root cause and workaround on the registry's GitHub. The fix is to exchange a read:org-scoped token directly with the registry's auth endpoint.
Quick Start (v1.5.0)
# Docker (full ML)
docker pull ghcr.io/aegisgatesecurity/aegisgate-mcp:1.5.0
docker run -p 8081:8081 ghcr.io/aegisgatesecurity/aegisgate-mcp:1.5.0
# Build from source (air-gapped, zero deps)
git clone https://github.com/aegisgatesecurity/aegisgate-mcp.git
cd aegisgate-mcp
make build # heuristic-only, ~8MB
# or
make build-cgo # full ML, links vendored ONNX Runtime
./mcp-server --transport streamable-http --addr :8081
MCP Registry: io.github.aegisgatesecurity/aegisgate-mcp
GitHub: aegisgatesecurity/aegisgate-mcp
License: Apache 2.0
What's Next
-
P2: Server-initiated notifications (
notifications/tools/list_changed) -
P3: Real
resources/subscribewith long-lived SSE -
P4:
resources/templates/list
The roadmap is in docs/roadmap.md.
Secure Every AI Interaction.
Josh Colvin is the founder of AegisGate Security, building open-source, self-hosted AI security. Apache 2.0. No telemetry. No data egress. GitHub.
Top comments (1)
Official Platform Update
Security protocols have been updated for all developer accounts.