Enterprise MCP Gateway
README.md
# Enterprise MCP Gateway
**Multi-Tenant Model Context Protocol Gateway with Dynamic RBAC, Multi-Backend Integration, and Defense-in-Depth Observability**
An enterprise-grade MCP server that mediates AI agent and IDE copilot access to backend systems through a unified, governed interface with authentication, authorization, rate limiting, audit logging, and encryption.
---
## Key Metrics
| Metric | Value |
|---|---|
| MCP Tools | 15 registered (1 built-in, 4 Jira, 4 BRE stubs, 3 Neo4j, 3 Teradata) |
| Backend Systems | 4 backend modules (Jira, BRE/RDS, Neo4j, Teradata) plus DocumentDB as the config/audit store |
| Authentication Modes | 3 (API Key + JWT, Kiro RBAC, Legacy opt-in loopback only) |
| Security Guardrails | 7 composable checks per request |
| Audit Pipelines | 3 parallel (Jira, Generic, Access) |
| Rate Limiting | 2-layer sliding window (per-consumer + per-group) |
| Encryption | AES-256-GCM with AWS KMS envelope encryption |
| RBAC Roles | 5 AD-group-mapped tiers |
---
## Architecture Overview
```
Client (AI Agent / Kiro IDE / REST)
|
v
AuthMiddleware (3-mode: API Key+JWT, Kiro RBAC, Legacy)
|
v
Guardrail Pipeline (7 composable security checks)
|
v
Two-Layer Rate Limiter (per-consumer + per-tool-group)
|
v
Backend Dispatcher
|
+--- Jira REST API / LLM Orchestrator
+--- Business Rule Engine (RDS API)
+--- Neo4j (Bolt driver)
+--- Teradata (MCP Sidecar)
|
v
Audit Pipelines (Jira | Generic | Access) --> DocumentDB
```
---
## MCP Capabilities
Exactly what the server advertises over `/mcp`, and nothing more:
| Capability | Served | Methods |
|---|---|---|
| Tools | 15 | `tools/list`, `tools/call` |
| Resources | 1 (`graph://neo4j/schema`) | `resources/list`, `resources/read` |
| Prompts | 1 (`cypher_query_guidance`) | `prompts/list`, `prompts/get` |
- `graph://neo4j/schema` (`application/json`) returns node labels,
relationship types, property keys and connected
`(:Label)-[:REL]->(:Label)` relationship patterns read from
`CALL db.schema.visualization()`. When that procedure is unavailable the
pattern list is empty and a `notes` field explains why; patterns are never
fabricated.
- `cypher_query_guidance(question)` returns Cypher composition guidance that
embeds the live schema and the constraints the gateway actually enforces
(read-only keyword blocking and the auto-injected `LIMIT`, capped at 500
rows).
- Both are registered only when `NEO4J_ENABLED=true`. With the backend
disabled `resources/list` and `prompts/list` return empty and a
`resources/read` of `graph://neo4j/schema` fails with `Resource not found`.
The `resources` and `prompts` capability flags themselves are advertised
unconditionally: the FastMCP layer builds the `initialize` capability object
from its own server features, not from what the backends registered. The
registration guard controls the lists, which is what a discovering client
enumerates.
- Both pass through the same guardrail pipeline as the tools before any
database work: the resource authorizes against `neo4j_get_schema` and the
prompt against `neo4j_validate_query`, and the prompt withholds the live
schema unless the caller is also authorized for `neo4j_get_schema`. A
caller denied the tool cannot read the data through the resource or the
prompt, and a caller with no identity is denied outright.
- A guardrail denial is delivered in the body, not as a JSON-RPC error, which
follows the gateway convention that handler errors are returned at HTTP 200:
a denied `resources/read` returns `contents[0].text` holding the
`{"error": "forbidden", "guardrail": "tool_authorization", ...}` document,
and a denied `prompts/get` returns that same document as the prompt text. A
client has to inspect the payload; it gets no protocol-level signal.
- No other resources or prompts are registered.
---
## Quick Start
### Prerequisites
- Python 3.11+
- Docker and Docker Compose (for local infrastructure)
### Local Development
```bash
# Clone the repository
git clone <repo-url> && cd enterprise-mcp-gateway-core
# Copy environment config
cp .env.example .env
# Start infrastructure (MongoDB + Redis)
docker compose up -d mongodb redis
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\Activate.ps1 # Windows PowerShell
# Install dependencies
pip install -r requirements.txt
pip install -e ".[dev]"
# Run the gateway
python src/gateway/app.py --transport=streamable-http --port=8100
# In another terminal, run tests
pytest tests/ -v
```
### Docker Compose (Full Stack)
```bash
docker compose up -d
# Gateway at http://localhost:8100
# MongoDB at localhost:27017
# Redis at localhost:6379
```
---
## Project Structure
```
enterprise-mcp-gateway-core/
src/gateway/
app.py # ASGI entry point (Starlette + FastMCP)
config/
settings.py # All env-var configuration with defaults
db.py # DocumentDB/MongoDB connection
middleware/
auth.py # AuthMiddleware (3-mode authentication)
security/
jwt_validator.py # IdP-agnostic JWT validation
consumer_registry.py # Consumer profile management
kms_envelope.py # AES-256-GCM envelope encryption
guardrails/
pipeline.py # 7-step composable security pipeline
ratelimit/
limiter.py # Two-layer sliding-window rate limiter
audit/
loggers.py # 3 audit pipelines + sanitization
backends/
jira_backend.py # Jira tools (read + write)
bre_backend.py # Business Rule Engine tools (stubs, not implemented)
neo4j_backend.py # Neo4j Knowledge Graph tools
teradata_backend.py # Teradata via MCP sidecar
tests/ # Unit test suite
docs/ # Architecture docs and whitepaper
Dockerfile # Production container image
docker-compose.yml # Local dev environment
pyproject.toml # Build configuration
requirements.txt # Pinned dependencies
```
---
## Environment Variables
### Server
| Variable | Default | Description |
|---|---|---|
| `MCP_SERVER_PORT` | `8100` | Gateway HTTP port |
| `LOG_LEVEL` | `INFO` | Logging level |
### Authentication
| Variable | Default | Description |
|---|---|---|
| `JWT_AUTH_ENABLED` | `false` | Enable JWT bearer token validation |
| `JWT_SECRET` | _(empty)_ | Shared secret for JWT signing/validation |
| `JWT_ALGORITHMS` | `HS512` | Accepted JWT signing algorithms. Must stay within one key family (HMAC, RSA or EC); a mixed list is refused |
| `JWT_VERIFY_AUD` | `true` | Verify the `aud` claim. Requires `JWT_AUDIENCE` to be set |
| `JWT_AUDIENCE` | _(empty)_ | Expected audience; required when `JWT_VERIFY_AUD=true` |
| `JWT_ISSUER` | _(empty)_ | Expected issuer; always required, see the fail-closed note below |
| `JWT_CLAIM_USER_ID` | `sAMAccountName,sub` | Comma-separated candidate claim keys for user ID |
| `JWT_CLAIM_GROUPS` | `groups,roles` | Candidate claim keys for group membership |
| `KIRO_RBAC_ENABLED` | `true` | Require a gateway-signed token for IDE consumers |
| `GATEWAY_ALLOW_INSECURE_LOCAL` | `false` | Opt-in escape hatch for the legacy auto-whitelist. Also requires `KIRO_RBAC_ENABLED=false` and a loopback client |
| `GATEWAY_STDIO_TRUST_LOCAL` | `false` | Grant a local stdio caller (no AuthMiddleware, therefore no identity) unrestricted tool and project scope. Keep false for any HTTP deployment |
| `KIRO_TOKEN_TTL_HOURS` | `24` | Kiro token expiry in hours |
The token validator is fail-closed and the configuration check runs on every
validation, independently of `JWT_AUTH_ENABLED`. With `KIRO_RBAC_ENABLED=true`
(the default) the Mode 2 gateway-signed token is validated by the same code
path, so that mode requires `JWT_SECRET`, `JWT_ISSUER`, and either
`JWT_AUDIENCE` or `JWT_VERIFY_AUD=false`. Any other combination makes the
gateway refuse to validate tokens at all. This documents today's effective
behaviour rather than changing it: validation already failed on the empty
`JWT_SECRET` default.
### Rate Limiting
| Variable | Default | Description |
|---|---|---|
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | Sliding window duration |
| `RATE_LIMIT_JIRA` | `30` | Global Jira calls per window |
| `RATE_LIMIT_DEFAULT` | `59` | Default group limit, enforced per pod (not a global cap) |
| `WRITE_RATE_LIMIT` | `5` | Max writes per consumer per write window |
### Backends
| Variable | Default | Description |
|---|---|---|
| `JIRA_BASE_URL` | `https://jira.example.com` | Jira REST API base URL |
| `JIRA_BACKEND` | `pat` | Backend mode: `pat`, `basic`, or `llm-orchestrator` |
| `NEO4J_ENABLED` | `true` | Enable Neo4j backend |
| `TERADATA_MCP_ENABLED` | `true` | Enable Teradata sidecar |
| `TERADATA_MCP_URL` | `http://localhost:8001/mcp` | Teradata sidecar URL |
### Encryption
| Variable | Default | Description |
|---|---|---|
| `ENCRYPTION_ENABLED` | `false` | Enable KMS envelope encryption |
| `KMS_KEY_ID` | `alias/enterprise-mcp-gateway-tokens` | AWS KMS CMK ARN or alias |
### DocumentDB
| Variable | Default | Description |
|---|---|---|
| `DOCDB_HOST` | `localhost` | MongoDB/DocumentDB host |
| `DOCDB_DATABASE` | `ENTERPRISE_MCP_GATEWAY` | Database name |
| `DOCDB_TLS_ENABLED` | `true` | Connect with TLS and full certificate validation. The DocumentDB/RDS CA bundle must be in the host trust store |
---
## Authentication Modes
### Mode 1: API Key + JWT Bearer Token
For service-to-service consumers (AI agents, REST clients):
```
X-MCP-API-Key: <consumer-api-key>
Authorization: Bearer <jwt-token>
```
### Mode 2: Kiro IDE RBAC (Gateway-Signed Token)
For IDE copilots authenticated via browser-based flow:
```
X-MCP-App-Name: kiro-username
X-MCP-Auth-Token: <gateway-signed-jwt>
```
### Mode 3: Legacy (Auto-Whitelist) - local development only, off by default
Disabled unless all three conditions hold: `GATEWAY_ALLOW_INSECURE_LOCAL=true`,
`KIRO_RBAC_ENABLED=false`, and the request arrives from a loopback peer with no
forwarding headers (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Real-IP`,
`Forwarded`). The peer address is the immediate TCP peer, so in a deployment
where a reverse proxy or sidecar on the same host fronts the gateway and strips
forwarding headers, a remote caller would appear loopback - keep this flag off
anywhere behind a proxy. Any other request without a credential receives
HTTP 401.
```
X-MCP-App-Name: kiro-local
```
### stdio transport
`AuthMiddleware` only runs on the HTTP transport, so a tool call arriving over
stdio carries no caller identity. The absence of an identity is not a grant:
such calls are denied by the tool authorization guardrail (`forbidden` /
`tool_authorization`) unless `GATEWAY_STDIO_TRUST_LOCAL=true` is set, which
grants the local caller unrestricted tool and project scope and logs a warning
on every call.
### JSON-RPC error codes
An authentication failure on the MCP JSON-RPC endpoint (`/mcp` and
`/mcp/<...>`, excluding the public `/mcp/portal`, `/mcp/dashboard` and
`/mcp/api/` prefixes) is returned as HTTP 401 with a well-formed JSON-RPC 2.0
error object. The inbound request `id` is echoed when it is readable and is
`null` otherwise (batch array, notification, or an unreadable, oversized or
non-JSON body). The pre-existing machine-readable error key is preserved at
`error.data.error`, so consumers that already branch on it keep working.
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": "An API key is required. Set the X-MCP-API-Key header.",
"data": {
"error": "unauthorized",
"message": "An API key is required. Set the X-MCP-API-Key header."
}
}
}
```
Gateway-defined codes, from the `-32000` to `-32099` range the specification
reserves for server-defined errors:
| Code | Condition | `error.data.error` |
|---|---|---|
| `-32001` | Authentication required: no credential presented | `unauthorized`, `kiro_auth_required` |
| `-32002` | Authentication failed: credential presented but rejected | `unauthorized` (unregistered key), `kiro_token_invalid` |
| `-32003` | Authorization precondition not met | `jwt_required` |
`-32002` overlaps a code MCP itself defines: the FastMCP layer returns
`-32002` with the message `Resource not found` on this same `/mcp` endpoint,
for example on a `resources/read` of an unregistered URI. The gateway keeps
its three codes in one contiguous block rather than renumbering around the
overlap, so do not branch on the code alone. The discriminators for a gateway
authentication failure are the HTTP `401` status, which the protocol-level
errors never carry, and the `error.data.error` key.
The standard codes `-32700` (parse error), `-32600` (invalid request),
`-32601` (method not found), `-32602` (invalid params) and `-32603` (internal
error) are produced by the FastMCP JSON-RPC layer, as are MCP-defined codes
such as the `-32002` above, so that list is not exhaustive; the gateway does
not override any of them and does not invent codes for conditions it never
detects.
Requests to any other path keep their plain `{"error": ..., "message": ...}`
body: wrapping a health check or a dashboard API response in a `jsonrpc`
object would misrepresent the protocol in use on that path. No error body
ever carries a stack trace, exception text, token or key material.
---
## Security Guardrails
Every tool invocation passes through 7 composable checks:
| # | Guardrail | Fail Mode | Description |
|---|---|---|---|
| 0 | Tool Active | Fail-open | Registry lookup; disabled tools blocked |
| 1 | Tool Authorization | Fail-closed | Consumer's allowed_tools whitelist |
| 2 | User Write Auth | Fail-closed | AD group gate for write operations |
| 3 | Write Attribution | Fail-closed | Personal PAT required for writes |
| 4 | JQL Validation | Fail-closed | Injection pattern blocking |
| 5 | SQL Read-Only | Fail-closed | DDL/DML blocking for Teradata |
| 6 | Project Scope | Fail-closed | Consumer project whitelist |
| 7 | Write Rate Limit | Fail-closed | Per-consumer mutation throttle |
---
## Testing
```bash
# Run all tests
pytest tests/ -v
# Run with coverage
pytest tests/ --cov=gateway --cov-report=term-missing
# Run specific test module
pytest tests/test_guardrails.py -v
```
`requirements.txt` declares `starlette>=1.0,<2` and the suite is exercised
locally against `starlette 1.0.0`, where it reports zero warnings. A clean CI
install resolves a newer release in the same `1.x` line, which emits one
`StarletteDeprecationWarning` from `starlette.testclient` about its httpx
dependency. The warning comes from the test client, not from gateway code, and
no gate turns warnings into errors, so the suite passes either way; the
zero-warning property is a property of the local pin, not of the declared
range. The range is kept deliberately: the pure-ASGI middleware is written
against stable ASGI primitives and is expected to hold across `1.x`, and
pinning an upper bound the project has never needed would claim more than the
evidence supports.
---
## License
MIT
---
## Documentation
- [Technical Whitepaper](docs/Gateway_MCP_Technical_Paper.md) - Full architecture paper with threat model and academic mapping
- [Architecture Baseline](docs/MCP_Gateway_Architecture_Baseline.md) - Detailed discovery baseline with inventory tables
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues