DEV Community

Muhammad Hammad
Muhammad Hammad

Posted on

Architectural Breakdown: Put the Arithmetic in the Tool: an MCP Server for an AWS Waste Scanner

Put the Arithmetic in the Tool: MCP Server for an AWS Waste Scanner

Architecture Diagram


The Hard Truth About This Draft

The original document read like a template that got fed through a content-spinning mill seven times. Sections 3.2.11 through 3.2.18 were nearly identical paragraphs recycling the same four bullet points about thread synchronization and 8GB RAM limits. That is not documentation; that is padding. What follows is an actual implementation spec. If you are shipping this for a production build, you need specs that survive contact with reality.


MCP Server Architecture

Hardware Reality Check

Your EC2 instance is not infinite. It is an E5-2670 v2 with 8 cores, 8GB RAM, and a single 1GbE interface on SATA III SSD storage. That last detail matters. SATA III caps around 550MB/s sequential, and random 4K I/O will chew through your latency budget faster than you can say PostgreSQL connection pool. Every optimization below is designed for that constraint, not a theoretical server, not a cluster of m6i.quad instances. Eight gigabytes. Do the math.

Bounded Queues, Not a Suggestion

Incoming MCP requests will burst. The scanner endpoint does not care about your graceful backpressure strategy. It sends JSON payloads and expects answers. A bounded queue is what separates a server that stays up from one that OOMs at 2 AM on a Friday.

# Bounded async queue prevents unbounded memory growth under load spikes
request_queue = asyncio.Queue(maxsize=256)

# Four workers leaves headroom on 8 cores (2 cores per worker)
processing_workers = 4
Enter fullscreen mode Exit fullscreen mode

Queue depth of 256 keeps memory footprint predictable. Each queued request averages approximately 4KB of uncompressed JSON. That is roughly 1MB of buffer space. Trivial until you have unbounded growth eating into your 8GB.

Implementation note: Reject with 429 Too Many Requests when the queue is full. Better to tell the caller to retry than silently consume all available RAM buffering incoming requests.

async def enqueue_scanner_payload(raw_bytes: bytes) -> dict:
    """Returns 429 response shape when queue is saturated."""
    if request_queue.full():
        return {"status": 429, "error": "queue_full", "retry_after_ms": 500}
    task_id = str(uuid.uuid4())
    await request_queue.put({"id": task_id, "payload": raw_bytes, "enqueued_at": time.time()})
    return {"status": 202, "task_id": task_id}
Enter fullscreen mode Exit fullscreen mode

Race Condition Resilience, Specifics, Not Buzzwords

The phrase "implement thread synchronization" is meaningless without specifying what you are protecting. Here is what actually needs protection in this architecture:

  1. Shared in-memory state (caches, counters, rate-limit windows): Use asyncio.Lock or Semaphore, not global mutexes that block the event loop. Your MCP server is async. Do not undermine it with blocking primitives.

  2. Database writes: The PostgreSQL connection pool itself is thread-safe, but application-level transactions must be serialized. Use SELECT ... FOR UPDATE on waste-scanner rows that multiple requests might modify simultaneously. Optimistic locking with version columns works too, but pessimistic locking is clearer when dealing with hardware-limited concurrency.

  3. Atomic operations: For simple counters and metrics, use Redis INCR/DECR or PostgreSQL conditional expressions. Do not read-modify-write in application code. That read-modify-write pattern is where the race condition lives.

  4. Logging: Structured logging with correlation IDs is non-negotiable for debugging race conditions post-mortem. Every log line must include the request ID so you can trace concurrent access patterns across services.

# Pessimistic lock to prevent concurrent scan count races
async def increment_scan_count(conn, device_id: str, corr_id: str) -> int:
    """Atomically increments using FOR UPDATE to serialize writes safely."""
    async with conn.transaction():
        row = await conn.fetchrow(
            "SELECT scan_count FROM scanner_devices WHERE device_id = $1 FOR UPDATE",
            device_id
        )
        new_count = row["scan_count"] + 1
        await conn.execute(
            "UPDATE scanner_devices SET scan_count = $1 WHERE device_id = $2",
            new_count, device_id
        )
    logger.info({"corr_id": corr_id, "new_count": new_count, "event": "scan_incremented"})
    return new_count
Enter fullscreen mode Exit fullscreen mode

Database Design That Does Not Assume Infinite RAM

Your schema decisions are constrained by 8GB. That means no full-table scans on hot paths. Every query touching waste_scans or scanner_readings needs an index. Verify with EXPLAIN ANALYZE. Minimize JOIN complexity. Denormalize where it reduces query count, not where it feels convenient. Two efficient queries beat one query that locks three tables.

Connection pooling is mandatory. Use pgbouncer in transaction mode, not session mode. With 8 cores and 8GB RAM, you are running maybe 50 PostgreSQL connections, pooled through pgbouncer.

Schema tip: Store scan results as JSONB with GIN indexes if your payload shape varies. If it is fixed, use typed columns. JSONB without a schema strategy is how you hit 8GB RAM from index bloat alone.

Caching Strategy With Eviction Policies

Caching reduces database pressure but adds memory pressure. On 8GB RAM, every cached object displaces either working set data or OS page cache. You need eviction.

  • LRU cache with TTL for frequently accessed scanner metadata: device IDs, region mappings, calibration constants. Ten-minute TTL, max 5,000 entries. That is roughly 50MB, fully accountable.
  • Do not cache query results that change on every write. A recent scans cache is fine if you invalidate on insert. A scan count cache that never invalidates is lying to your users.
  • Use Redis, not process-local cache, if you plan to scale beyond one EC2 instance later. Even if you do not now, architecting for local-only cache creates migration debt you will regret.

Rate Limiting That Actually Protects You

DDoS protection is not a buzzword section. It is infrastructure survival. At minimum:

  • Per-client rate limit: 10 requests per second per scanner device ID, enforced server-side.
  • Burst allowance: 20 requests in a 2-second window before soft-reject, hard-reject after 30.
  • Global cap: If total inbound rate exceeds 100 req/s, reject all excess. One scanner should not starve the others.

Implementation: Token bucket algorithm. Simple, correct, low overhead. Leaky bucket works too but rewards steady streams over bursts, which is exactly what scanners produce.

from collections import defaultdict
import time

class TokenBucketRateLimiter:
    def __init__(self, rate: float = 10.0, burst: int = 30):
        self.rate = rate          # tokens per second refilled
        self.burst = burst        # maximum bucket capacity
        self.buckets: dict[str, dict] = defaultdict(lambda: {"tokens": float(burst), "last": time.time()})

    def allow(self, client_id: str) -> bool:
        bucket = self.buckets[client_id]
        now = time.time()
        elapsed = now - bucket["last"]
        bucket["tokens"] = min(float(self.burst), bucket["tokens"] + elapsed * self.rate)
        bucket["last"] = now
        if bucket["tokens"] >= 1.0:
            bucket["tokens"] -= 1.0
            return True
        return False
Enter fullscreen mode Exit fullscreen mode

The TL;DR of the Original Nine Sections

For anyone wondering why those nine near-identical sections existed in the original draft: they were each trying to cover hardware constraints, race condition resilience, caching, and rate limiting from a slightly different angle. They said the same thing nine times because there was not enough actual content. The spec above says it once, properly.


Closing Thought

"Put the arithmetic in the tool" does not mean write more code. It means the tool is the arithmetic, the queue bounds, the lock granularity, the eviction policy, the connection pool size. These are not features you bolt on after the architecture is done. They are the architecture. Build them in first, or rebuild them later when the server crashes under load.

What metric would you track in real-time to detect when your token bucket rate limiter is silently dropping legitimate scanner traffic versus blocking actual abuse?

Top comments (0)