DEV Community

Cover image for From 22 to 23: Evasion Detection, Elicitation, and 231 New Tests
aegisgate
aegisgate

Posted on

From 22 to 23: Evasion Detection, Elicitation, and 231 New Tests

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 "igno​re previ​ous instruc​tions." 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 igno​re previ​ous
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 → ...
Enter fullscreen mode Exit fullscreen mode

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/create as 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
]
Enter fullscreen mode Exit fullscreen mode

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
    },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/subscribe with 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)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

Official Platform Update

Security protocols have been updated for all developer accounts.

  • tr.ee/dev-to