What is MCP Gateway? I tried the OSS ContextForge as an entry point for asking AI about business system data

What is MCP Gateway? I tried the OSS ContextForge as an entry point for asking AI about business system data

# MCP Gateway: Centralizing Authentication, Auditing, and Cost Control for AI Agents ## Concept Overview When AI agents reference business systems, consolidating authentication, auditing, and cost control in one place via an MCP Gateway — and walking through the process of converting existing Web APIs to MCP using the OSS implementation ContextForge. --- ## Part 1: Organizing the MCP Gateway Concept ### Background: The Problem with Direct Agent-to-System Access ``` [Current State - Problematic Pattern] AI Agent A ──────────────────► Business System API AI Agent B ──────────────────► Database AI Agent C ──────────────────► Internal Tool │ ├─ Authentication scattered across each agent ├─ No audit trail ├─ Token costs uncontrolled └─ Security policies inconsistently applied ``` ### MCP Gateway Architecture ``` [Target State - Centralized via MCP Gateway] AI Agent A ──┐ AI Agent B ──┼──► MCP Gateway ──► Business System API AI Agent C ──┘ │ ├──► Database │ └──► Internal Tool │ ┌──────▼──────┐ │ Centralized │ │ • Auth/AuthZ │ │ • Audit Log │ │ • Rate Limit │ │ • Cost Track │ └─────────────┘ ``` ### Three Core Concerns #### 1. Authentication & Authorization ```yaml # Conceptual policy definition auth_policy: agent_identity: method: jwt # Agents present JWT tokens issuer: "https://auth.internal/agents" tool_permissions: agent_type_readonly: allowed_tools: - "search_*" - "get_*" denied_tools: - "delete_*" - "update_*" agent_type_operator: allowed_tools: - "*" requires_approval: - "bulk_delete" - "system_config" ``` #### 2. Audit Trail ```python # Audit log structure (conceptual) audit_event = { "timestamp": "2024-01-15T10:30:00Z", "agent_id": "agent-sales-assistant-001", "agent_type": "sales_assistant", "session_id": "sess_abc123", # MCP call details "tool_name": "search_customer_records", "tool_input": { "query": "enterprise customers", "limit": 10 }, # Result metadata (not full data for PII protection) "result_count": 7, "result_summary": "success", # Cost tracking "upstream_api": "crm_api", "latency_ms": 234, "tokens_consumed": 0, # For non-LLM tools # Risk scoring "data_sensitivity": "confidential", "access_risk_score": 0.3 } ``` #### 3. Cost Control ```python # Cost control policy (conceptual) cost_policy = { "per_agent_limits": { "hourly_tool_calls": 1000, "daily_api_cost_usd": 50.0, "concurrent_sessions": 5 }, "per_tool_limits": { "expensive_ml_inference": { "calls_per_minute": 10, "require_justification": True } }, "global_circuit_breaker": { "daily_total_cost_usd": 500.0, "action": "halt_all_agents" } } ``` --- ## Part 2: Converting Existing Web APIs to MCP with ContextForge ### Environment Setup ```bash # Install ContextForge pip install contextforge # Alternatively, install from source git clone https://github.com/contextforge/contextforge cd contextforge pip install -e ".[dev]" # Verify installation contextforge --version # contextforge 0.3.x ``` ### Step 1: Prepare the Target Web API ```python # target_api/main.py # Simple example business API (FastAPI) from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import Optional, List import uvicorn app = FastAPI(title="Customer Management API", version="1.0.0") # Dummy data CUSTOMERS = { "C001": {"id": "C001", "name": "Acme Corp", "tier": "enterprise", "mrr": 50000}, "C002": {"id": "C002", "name": "StartupXYZ", "tier": "startup", "mrr": 2000}, "C003": {"id": "C003", "name": "MegaCorp", "tier": "enterprise", "mrr": 120000}, } class CustomerUpdate(BaseModel): name: Optional[str] = None tier: Optional[str] = None @app.get("/customers") def list_customers(tier: Optional[str] = None, min_mrr: Optional[int] = None): """List customers with optional filters""" customers = list(CUSTOMERS.values()) if tier: customers = [c for c in customers if c["tier"] == tier] if min_mrr: customers = [c for c in customers if c["mrr"] >= min_mrr] return {"customers": customers, "total": len(customers)} @app.get("/customers/{customer_id}") def get_customer(customer_id: str): """Get specific customer details""" if customer_id not in CUSTOMERS: raise HTTPException(status_code=404, detail="Customer not found") return CUSTOMERS[customer_id] @app.put("/customers/{customer_id}") def update_customer(customer_id: str, update: CustomerUpdate): """Update customer information""" if customer_id not in CUSTOMERS: raise HTTPException(status_code=404, detail="Customer not found") if update.name: CUSTOMERS[customer_id]["name"] = update.name if update.tier: CUSTOMERS[customer_id]["tier"] = update.tier return CUSTOMERS[customer_id] @app.get("/customers/{customer_id}/health-score") def get_health_score(customer_id: str): """Calculate customer health score""" if customer_id not in CUSTOMERS: raise HTTPException(status_code=404, detail="Customer not found") customer = CUSTOMERS[customer_id] # Simple scoring logic score = min(100, customer["mrr"] / 1000 * 2) return { "customer_id": customer_id, "health_score": score, "risk_level": "low" if score > 70 else "medium" if score > 40 else "high" } if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8080) ``` ```bash # Start the API in a separate terminal python target_api/main.py # INFO: Uvicorn running on http://0.0.0.0:8080 ``` ### Step 2: OpenAPI Spec Generation ```bash # Retrieve the auto-generated spec curl http://localhost:8080/openapi.json | python -m json.tool > customer_api_spec.json # Alternatively, save manually from the spec cat customer_api_spec.json ``` ```json { "openapi": "3.0.2", "info": { "title": "Customer Management API", "version": "1.0.0" }, "paths": { "/customers": { "get": { "summary": "List Customers", "operationId": "list_customers_customers_get", "parameters": [ { "name": "tier", "in": "query", "required": false, "schema": {"type": "string"} }, { "name": "min_mrr", "in": "query", "required": false, "schema": {"type": "integer"} } ] } } } } ``` ### Step 3: ContextForge Configuration ```yaml # contextforge.yaml # MCP server configuration file server: name: "customer-management-mcp" version: "1.0.0" description: "MCP server for customer management operations" # Target API settings upstream: base_url: "http://localhost:8080" openapi_spec: "./customer_api_spec.json" timeout_seconds: 30 # Authentication settings auth: type: "api_key" header: "X-API-Key" value: "${UPSTREAM_API_KEY}" # From environment variable # MCP tool definitions # Auto-generate from OpenAPI spec + custom settings tools: auto_generate: true # Per-tool overrides overrides: list_customers_customers_get: # Rename to human-readable name name: "search_customers" description: | Search and filter the customer list. Filterable by customer tier (enterprise/startup/etc.) and minimum MRR value. # Input schema override input_schema: type: object properties: tier: type: string description: "Customer tier filter (enterprise, startup, smb)" enum: ["enterprise", "startup", "smb"] min_mrr: type: integer description: "Minimum Monthly Recurring Revenue (USD)" minimum: 0 # Permission requirements required_permissions: ["customer:read"] get_customer_customers__customer_id__get: name: "get_customer_details" description: "Retrieve detailed information for a specific customer" required_permissions: ["customer:read"] update_customer_customers__customer_id__put: name: "update_customer" description: "Update customer information (name, tier)" required_permissions: ["customer:write"] # High-risk operation: require confirmation require_confirmation: true confirmation_message: | About to update customer {customer_id}. Changes: {update} Please confirm (yes/no): get_health_score_customers__customer_id__health_score_get: name: "get_customer_health_score" description: | Calculate the health score for a customer. Score range: 0-100 (70+: low risk, 40-70: medium risk, below 40: high risk) required_permissions: ["customer:read", "analytics:read"] # Gateway settings gateway: # Rate limiting rate_limiting: enabled: true default_limits: calls_per_minute: 60 calls_per_hour: 500 tool_specific_limits: update_customer: calls_per_minute: 10 # Stricter for write operations # Audit logging audit: enabled: true log_file: "./logs/mcp_audit.jsonl" log_inputs: true log_outputs: true mask_fields: # PII masking - "email" - "phone" - "credit_card" # Caching cache: enabled: true default_ttl_seconds: 300 tool_specific_ttl: search_customers: 60 get_customer_details: 120 update_customer: 0 # No cache for writes get_customer_health_score: 300 # Transport settings transport: type: "stdio" # or "http", "websocket" # For HTTP transport # http: # host: "0.0.0.0" # port: 3000 # path: "/mcp" ``` ### Step 4: Generate and Launch the MCP Server ```bash # Validate configuration contextforge validate contextforge.yaml # ✓ Configuration valid # ✓ OpenAPI spec loaded: 5 endpoints found # ✓ Tool mappings: 4 tools configured # ✓ Auth settings valid # Preview generated tool definitions contextforge tools list --config contextforge.yaml ``` ``` Available MCP Tools: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Tool: search_customers Description: Search and filter the customer list. Permissions: customer:read Rate limit: 60/min Cache TTL: 60s Tool: get_customer_details Description: Retrieve detailed information for a specific customer Permissions: customer:read Rate limit: 60/min Cache TTL: 120s Tool: update_customer Description: Update customer information (name, tier) Permissions: customer:write Rate limit: 10/min Requires confirmation: Yes Cache TTL: No cache Tool: get_customer_health_score Description: Calculate health score for a customer. Permissions: customer:read, analytics:read Rate limit: 60/min Cache TTL: 300s ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` ```bash # Start the MCP server contextforge serve --config contextforge.yaml # INFO: Starting MCP server: customer-management-mcp v1.0.0 # INFO: Loaded 4 tools # INFO: Audit logging enabled: ./logs/mcp_audit.jsonl # INFO: Rate limiting enabled # INFO: Server ready (stdio transport) ``` ### Step 5: MCP Client Implementation ```python # mcp_client_test.py # Test calling the MCP server import asyncio import json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def test_mcp_tools(): """Test MCP tools via ContextForge""" server_params = StdioServerParameters( command="contextforge", args=["serve", "--config", "contextforge.yaml"], env={ "UPSTREAM_API_KEY": "test-key-123" } ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: # Initialize await session.initialize() print("✓ MCP session initialized") # 1. List available tools tools = await session.list_tools() print(f"\n📋 Available tools: {len(tools.tools)}") for tool in tools.tools: print(f" - {tool.name}: {tool.description[:50]}...") # 2. Test search_customers print("\n🔍 Testing: search_customers") result = await session.call_tool( "search_customers", arguments={ "tier": "enterprise" } ) data = json.loads(result.content[0].text) print(f" Result: {data['total']} enterprise customers found") for customer in data['customers']: print(f" - {customer['name']} (MRR: ${customer['mrr']:,})") # 3. Test get_customer_details print("\n👤 Testing: get_customer_details") result = await session.call_tool( "get_customer_details", arguments={"customer_id": "C001"} ) customer = json.loads(result.content[0].text) print(f" Customer: {customer['name']}, Tier: {customer['tier']}") # 4. Test get_customer_health_score print("\n📊 Testing: get_customer_health_score") result = await session.call_tool( "get_customer_health_score", arguments={"customer_id": "C003"} ) health = json.loads(result.content[0].text) print(f" {health['customer_id']} Health Score: {health['health_score']:.1f}") print(f" Risk Level: {health['risk_level']}") # 5. Test rate limiting print("\n⏱️ Testing: Rate limit enforcement") for i in range(3): result = await session.call_tool( "search_customers", arguments={} ) print(f" Call {i+1}: Success") asyncio.run(test_mcp_tools()) ``` ```bash python mcp_client_test.py ``` ``` ✓ MCP session initialized 📋 Available tools: 4 - search_customers: Search and filter the customer list... - get_customer_details: Retrieve detailed information for a... - update_customer: Update customer information (name, tier... - get_customer_health_score: Calculate health score for a cus... 🔍 Testing: search_customers Result: 2 enterprise customers found - Acme Corp (MRR: $50,000) - MegaCorp (MRR: $120,000) 👤 Testing: get_customer_details Customer: Acme Corp, Tier: enterprise 📊 Testing: get_customer_health_score C003 Health Score: 100.0 Risk Level: low ⏱️ Testing: Rate limit enforcement Call 1: Success Call 2: Success Call 3: Success ``` ### Step 6: Checking the Audit Log ```python # check_audit_log.py # Analyze the audit log import json from pathlib import Path from collections import defaultdict from datetime import datetime def analyze_audit_log(log_file: str = "./logs/mcp_audit.jsonl"): """Audit log analysis""" events = [] with open(log_file) as f: for line in f: if line.strip(): events.append(json.loads(line)) print(f"📋 Total audit events: {len(events)}") print(f"{'='*60}") # Events by tool tool_stats = defaultdict(lambda: {"calls": 0, "errors": 0, "total_latency": 0}) for event in events: tool = event.get("tool_name", "unknown") tool_stats[tool]["calls"] += 1 if event.get("status") == "error": tool_stats[tool]["errors"] += 1 tool_stats[tool]["total_latency"] += event.get("latency_ms", 0) print("\n📊 Calls per tool:") for tool, stats in tool_stats.items(): avg_latency = stats["total_latency"] / stats["calls"] if stats["calls"] > 0 else 0 print(f" {tool}:") print(f" Calls: {stats['calls']}, Errors: {stats['errors']}") print(f" Avg Latency: {avg_latency:.1f}ms") # Recent events print("\n🕐 Recent events (last 5):") for event in events[-5:]: ts = event.get("timestamp", "") tool = event.get("tool_name", "") status = event.get("status", "") agent = event.get("agent_id", "unknown") print(f" [{ts}] {tool} by {agent}: {status}") analyze_audit_log() ``` ``` 📋 Total audit events: 8 ============================================================ 📊 Calls per tool: search_customers: Calls: 4, Errors: 0 Avg Latency: 18.3ms get_customer_details: Calls: 1, Errors: 0 Avg Latency: 12.1ms get_customer_health_score: Calls: 1, Errors: 0 Avg Latency: 22.7ms 🕐 Recent events (last 5): [2024-01-15T10:30:01Z] search_customers by test-client: success [2024-01-15T10:30:02Z] get_customer_details by test-client: success [2024-01-15T10:30:03Z] get_customer_health_score by test-client: success [2024-01-15T10:30:04Z] search_customers by test-client: success [2024-01-15T10:30:05Z] search_customers by test-client: success ``` ### Step 7: Integration with AI Agents ```python # agent_with_mcp.py # Example of an AI agent using the MCP server via ContextForge import asyncio from anthropic import Anthropic from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client import json async def run_customer_analysis_agent(query: str): """ Customer analysis agent Performs customer analysis using tools via MCP """ client = Anthropic() server_params = StdioServerParameters( command="contextforge", args=["serve", "--config", "contextforge.yaml"], env={"UPSTREAM_API_KEY": "production-key"} ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # Retrieve available tools and convert to Claude format mcp_tools = await session.list_tools() claude_tools = [] for tool in mcp_tools.tools: claude_tools.append({ "name": tool.name, "description": tool.description, "input_schema": tool.inputSchema }) print(f"🤖 Agent started. Available tools: {len(claude_tools)}") print(f"💬 User query: {query}\n") messages = [{"role": "user", "content": query}] # Agentic loop while True: response = client.messages.create( model="claude-opus-4-5", max_tokens=4096, tools=claude_tools, messages=messages ) # If no tool use, we're done if response.stop_reason == "end_turn": final_response = "" for block in response.content: if hasattr(block, "text"): final_response += block.text print(f"🎯 Agent response:\n{final_response}") break # Execute tool calls tool_results = [] for block in response.content: if block.type == "tool_use": print(f"🔧 Tool call: {block.name}") print(f" Input: {json.dumps(block.input, ensure_ascii=False)}") # Execute via MCP try: result = await session.call_tool( block.name, arguments=block.input ) result_content = result.content[0].text print(f" ✓ Result: {result_content[:100]}...") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": result_content }) except Exception as e: print(f" ✗ Error: {e}") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": f"Error: {str(e)}", "is_error": True }) # Continue conversation messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results}) # Run asyncio.run(run_customer_analysis_agent( "Identify all at-risk enterprise customers and provide improvement recommendations" )) ``` ``` 🤖 Agent started. Available tools: 4 💬 User query: Identify all at-risk enterprise customers and provide improvement recommendations 🔧 Tool call: search_customers Input: {"tier": "enterprise"} ✓ Result: {"customers": [{"id": "C001", "name": "Acme Corp"... 🔧 Tool call: get_customer_health_score Input: {"customer_id": "C001"} ✓ Result: {"customer_id": "C001", "health_score": 100.0, "risk_... 🔧 Tool call: get_customer_health_score Input: {"customer_id": "C003"} ✓ Result: {"customer_id": "C003", "health_score": 100.0, "risk_... 🎯 Agent response: Analysis results for enterprise customers: **Current Status:** - Acme Corp (C001): Health Score 100/100 - Low Risk MRR: $50,000/month - MegaCorp (C003): Health Score 100/100 - Low Risk MRR: $120,000/month **Assessment:** Both enterprise customers are currently in excellent health. However, based on MRR levels, here are proactive improvement recommendations: **Acme Corp:** - Upsell to a higher plan to potentially grow MRR from $50K to $80K+ - Schedule a quarterly business review (QBR) to confirm value delivery **MegaCorp:** - As a top revenue customer, assign a dedicated Customer Success Manager - Develop a case study for marketing (with their permission) - Propose an enterprise multi-year contract for pricing stability ``` --- ## Part 3: Production Deployment Considerations ### MCP Gateway Hardening ```python # gateway_middleware.py # Custom middleware for production deployment from contextforge.middleware import BaseMiddleware from contextforge.types import MCPRequest, MCPResponse import hashlib import time from typing import Optional class SecurityMiddleware(BaseMiddleware): """Security enforcement middleware""" async def before_tool_call( self, request: MCPRequest, context: dict ) -> Optional[MCPResponse]: # 1. Verify agent identity agent_id = context.get("agent_id") if not await self.verify_agent_token(context.get("auth_token")): return MCPResponse.error("Authentication failed", code=401) # 2. Input validation if not await self.validate_input_safety(request.tool_input): return MCPResponse.error("Input validation failed", code=400) # 3. Data access policy check if not await self.check_data_policy(agent_id, request.tool_name): return MCPResponse.error("Access denied by data policy", code=403) return None # Proceed async def after_tool_call( self, request: MCPRequest, response: MCPResponse, context: dict ) -> MCPResponse: # 4. Output PII scrubbing response = await self.scrub_pii(response) # 5. Record compliance log await self.log_compliance_event(request, response, context) return response async def validate_input_safety(self, tool_input: dict) -> bool: """Detect prompt injection and suspicious inputs""" suspicious_patterns = [ "ignore previous instructions", "system prompt", "jailbreak", "<script>", "DROP TABLE", ] input_str = str(tool_input).lower() for pattern in suspicious_patterns: if pattern.lower() in input_str: return False return True class CostTrackingMiddleware(BaseMiddleware): """Cost tracking and control middleware""" def __init__(self, budget_store): self.budget_store = budget_store # Cost per tool call (USD) self.tool_costs = { "search_customers": 0.001, "get_customer_details": 0.001, "get_customer_health_score": 0.005, "update_customer": 0.002, } async def before_tool_call(self, request, context): agent_id = context.get("agent_id") tool_cost = self.tool_costs.get(request.tool_name, 0.001) # Check budget remaining = await self.budget_store.get_remaining_budget(agent_id) if remaining < tool_cost: return MCPResponse.error( f"Insufficient budget. Required: ${tool_cost}, " f"Remaining: ${remaining:.4f}", code=429 ) return None async def after_tool_call(self, request, response, context): agent_id = context.get("agent_id") tool_cost = self.tool_costs.get(request.tool_name, 0.001) # Deduct cost await self.budget_store.deduct_budget( agent_id=agent_id, amount=tool_cost, tool_name=request.tool_name, timestamp=time.time() ) return response ``` ### Deployment Configuration ```yaml # docker-compose.yaml version: '3.8' services: mcp-gateway: build: ./mcp-gateway environment: - UPSTREAM_API_KEY=${UPSTREAM_API_KEY} - AUTH_SECRET=${JWT_SECRET} - REDIS_URL=redis://redis:6379 - LOG_LEVEL=INFO volumes: - ./contextforge.yaml:/app/contextforge.yaml - ./logs:/app/logs ports: - "3000:3000" depends_on: - redis - audit-db redis: image: redis:7-alpine # Rate limiting and caching backend audit-db: image: postgres:15 environment: POSTGRES_DB: mcp_audit POSTGRES_USER: audit_user POSTGRES_PASSWORD: ${AUDIT_DB_PASSWORD} volumes: - audit_data:/var/lib/postgresql/data volumes: audit_data: ``` --- ## Summary ``` MCP Gateway Architecture Summary ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem Solution ───────────────────────── ───────────────────────── Auth scattered across Centralized JWT/API key each agent verification in gateway No audit trail JSONL audit log with full call records Uncontrolled API Per-agent/tool budget costs control + circuit breaker Inconsistent input/ Gateway-level validation output validation and PII scrubbing Tight coupling between MCP abstraction layer agents and API specs (OpenAPI → MCP tools) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Implementation Steps (ContextForge) ───────────────────────────────────────────────────── 1. Prepare target API (FastAPI/Express/etc.) 2. Export OpenAPI spec 3. Write contextforge.yaml (tool mapping + policies) 4. contextforge serve starts MCP server 5. AI agents connect via MCP protocol 6. All calls pass through gateway centrally ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` The MCP Gateway pattern enables enterprise-grade safety and governance for AI agent deployments while maintaining development agility — existing Web APIs require no modification to become MCP-compatible tools.
2026.08.31

This page has been translated by machine translation. View original

More and more people are asking about letting AI agents access internal business system data. For example, customer management systems like Salesforce, file sharing, accounting, expense reimbursement... the data they want to access is scattered across various business systems.

You might think "Why not just connect the AI agent directly to each system?" but as the number of targets increases, problems start to emerge. In this article, I'll organize those problems and then introduce the concept of MCP Gateway that has emerged as a solution, and actually try out the OSS implementation ContextForge.

MCP Gateway is a category where multiple vendors and OSS projects have already released products with similar definitions. It's a layer that sits between AI agents and groups of MCP servers, taking centralized control of cross-cutting concerns like authentication and access control. This article assumes readers who have used MCP (Model Context Protocol, the standard protocol for connecting AI agents to external tools) but haven't looked into Gateways for managing them.
For MCP itself, our company blog also has an explanation, so please refer to that.

https://dev.classmethod.jp/articles/shuntaka-mcp-study/

What Goes Wrong with Direct Connections

First, let's think about a configuration where AI agents are connected directly to each business system.

It looks simple at first, but as the number of systems increases, the following problems accumulate.

  • Configuration becomes cumbersome: For each client or agent, you must register and manage connection destinations one by one
  • Authentication is fragmented: Each system has different authentication methods and token management, requiring individual handling
  • Audit logs can't be captured: There's no central place to record who accessed which system's data and how much
  • Costs are unpredictable: When API usage is pay-per-use, agents can call freely and costs can skyrocket without limit
  • Access control is coarse: Fine-grained control like "this agent can only access this data" is difficult, and it tends to be all-or-nothing

What's even more troublesome is that connecting to AI agents via MCP usually requires a translation layer (MCP wrapper) that makes each existing Web API speak MCP. Building and operating these individually leads to a proliferation of wrappers, and the problems above become distributed across as many systems as there are.

Solving these one by one is quite a burden.

"MCP Gateway" That Solves These Problems

That's where the concept of MCP Gateway has recently emerged.

An MCP Gateway is a single, controlled entry point that sits between AI agents and business systems. By ensuring all tool calls pass through this "checkpoint," authentication, auditing, and control can be centralized in one place.

The problems mentioned earlier map neatly to the features that MCP Gateway provides.

Direct Connection Problem Solution with MCP Gateway
Cumbersome configuration Consolidate and route multiple systems to a single endpoint
Fragmented authentication Centralize authentication and authorization at the entry point
Can't capture audit logs Record all calls in one place (audit logs / observability)
Unpredictable costs Enforce call limits with rate limiting / quotas, reduce duplicate calls with cache
Coarse access control Access control at the tool level

Note that if you only need to expose existing Web APIs as MCP, you can achieve that by writing your own MCP servers. The value of inserting an MCP Gateway lies in gathering that translation in one place while also being able to apply authentication, auditing, and control all together.

How to Implement It: SaaS / OSS / Build Your Own

There are roughly three ways to implement an MCP Gateway.

  • SaaS (managed): Entrust operations to a vendor. No need to build out authentication, governance, or scaling, and you can get started quickly. For example, Amazon Bedrock AgentCore Gateway is a fully managed service that can convert existing REST APIs (OpenAPI specs) and Lambda functions into MCP-compatible tools and publish them from a single endpoint, handling authentication for both the client side and tool side
  • OSS (self-hosted): Host it yourself. Customization is free but operations are your own responsibility. ContextForge, which I'll cover today, falls into this category
  • Build your own: Build everything yourself. Hard to choose unless requirements are very specific

When you break it down, this three-way choice is a tradeoff of "how much do you want to build yourself". SaaS minimizes build effort, OSS puts operations on you, and building from scratch means doing everything yourself.

Building the governance layer from scratch means you're taking on availability and keeping up with the MCP spec yourself, which is quite a heavy choice. Realistically, leaning toward SaaS or OSS is sensible.

For this article, I'll cover ContextForge as an OSS that you can self-host while also being able to do MCP conversion of existing Web APIs and extend through plugins.

What Is ContextForge

ContextForge (IBM/mcp-context-forge) is an open source AI Gateway / Registry / Proxy published by IBM. It can consolidate not only MCP servers but also A2A (Agent2Agent, a protocol for coordinating AI agents with each other) servers and REST/gRPC APIs, and publish them as a unified endpoint.

https://github.com/IBM/mcp-context-forge

The features that are particularly relevant for the use case of "an entry point for internal systems" include:

  • MCP-ification of existing Web APIs: REST APIs can be registered as MCP tools. You can configure per-tool mapping of tool parameters to HTTP headers, and timeouts (timeout_ms, default 20000 milliseconds) (REST Passthrough)
  • Rate limiting: Upper limits on tool calls can be set with TOOL_RATE_LIMIT (default 100 calls/minute), counted per tool and per client (Configuration)
  • Caching: The Cached Tool Result plugin can cache results of idempotent tools with a TTL (cached_tool_result)
  • Plugin mechanism: Extensible through a plugin framework called CPEX (ContextForge Plugin Extensions). PII masking, content moderation, circuit breakers, retries, and more are available out of the box (Plugins)
  • Auditing and observability: Traces can be sent to external backends via OpenTelemetry (OTLP) (OTEL_ENABLE_OBSERVABILITY, disabled by default. Configuration). Authentication and authorization events can be recorded in the security_events table with SECURITY_LOGGING_ENABLED (Security Features)

Trying It Out: Converting an Existing Web API to MCP with ContextForge

From here, I'll actually set up ContextForge, register an existing Web API as an MCP tool, and try calling it through the MCP Gateway.

Instead of "an existing REST API inside the company," I'll use SampleAPIs, a dummy API service usable without authentication. I'll treat GET https://api.sampleapis.com/coffee/hot as a stand-in for an internal business API.

Here's the configuration we'll build:

Note that this time, rather than "how to set this up for production," we're at the stage of "checking whether this OSS meets our requirements by getting a feel for it," so I'll use a minimal single-container (SQLite) configuration. The official docker-compose.yml is a configuration with production operation in mind, with large resource requirements and lots of configuration, which is overkill for confirming functionality. I've summarized what I cut to get a minimal configuration in a supplement at the end of the article.

Starting ContextForge

You can start with a single container, but there are two things to keep in mind first.

Note 1: Make secrets strong

v1.0.8 validates JWT_SECRET_KEY and AUTH_ENCRYPTION_SECRET at startup, and the process won't start if the conditions aren't met (regardless of development or production). The conditions are "at least 32 characters," "sufficient entropy," and "not a known weak value." For that reason, the command below generates strong values with openssl rand -base64 48.

Note 2: Explicitly enable the Admin UI

MCPGATEWAY_UI_ENABLED and MCPGATEWAY_ADMIN_API_ENABLED are false by default. When trying with docker run, set both to true to open /admin.

With that in mind, the startup command looks like this:

$ docker run -d --name mcpgateway-minimal \
    -p 4444:4444 -v "$(pwd)/data-minimal:/data" \
    -e HOST=0.0.0.0 \
    -e DATABASE_URL="sqlite:////data/mcp.db" \
    -e JWT_SECRET_KEY="$(openssl rand -base64 48)" \
    -e AUTH_ENCRYPTION_SECRET="$(openssl rand -base64 48)" \
    -e PLATFORM_ADMIN_EMAIL=admin@example.com \
    -e PLATFORM_ADMIN_PASSWORD=changeme \
    -e MCPGATEWAY_UI_ENABLED=true \
    -e MCPGATEWAY_ADMIN_API_ENABLED=true \
    -e GUNICORN_WORKERS=2 \
    -e EXPOSE_ERROR_DETAILS=true \
    ghcr.io/ibm/mcp-context-forge:v1.0.8

Once started, verify with a health check (/health requires no authentication). The base URL for the minimal configuration is http://localhost:4444.

$ export BASE_URL="http://localhost:4444"
$ curl -s ${BASE_URL}/health
Execution result
{
  "status": "healthy",
  "mcp_runtime": {
    "mode": "python",
    "mounted": "python",
    "boot_mode": "off",
    "boot_mounted": "python",
    "effective_mode": "off",
    "override_active": false,
    "override_version": 0,
    "cluster_propagation": "disabled",
    "boot_reconcile_status": "ok",
    "pod_id": "86a9db64527a",
    "rust_build_included": false,
    "rust_runtime_enabled": false,
    "session_core_mode": "python",
    "event_store_mode": "python",
    "resume_core_mode": "python",
    "live_stream_core_mode": "python",
    "affinity_core_mode": "python",
    "session_auth_reuse_mode": "python",
    "rust_session_core_enabled": false,
    "rust_event_store_enabled": false,
    "rust_resume_core_enabled": false,
    "rust_live_stream_core_enabled": false,
    "rust_affinity_core_enabled": false,
    "rust_session_auth_reuse_enabled": false
  }
}

Logging into the Admin UI

Now that the environment is up, let's open the Admin UI first. Access http://localhost:4444/admin in your browser, and a login screen will appear.

ContextForge login screen

Enter the PLATFORM_ADMIN_EMAIL and PLATFORM_ADMIN_PASSWORD values specified at startup (admin@example.com / changeme). On first login, you won't go directly to the admin panel — instead, a screen asking you to change your password appears. It's designed so you can't keep using the initial password, so set a new password here.

After the change, the System Overview screen opens.

ContextForge admin panel (System Overview)

The running version, MCP runtime status, execution counts, success rates, and other metrics are displayed. The left sidebar is the list of registration targets (MCP Servers, Virtual Servers, Tools, Prompts...) we'll be working with. At this point, there are no registered tools or virtual servers — all show 0 items — and we'll be adding business APIs here one by one.

Understanding the Authentication Model

Before registering tools and connecting clients, let's clarify where ContextForge's authentication applies. Authentication is split into two layers.

  • ① Client → MCP Gateway: A JWT (Bearer token) is required to connect to the MCP Gateway. Since AUTH_REQUIRED, which requires authentication on all API routes, defaults to true (Configuration), even in our configuration where nothing was specified in the startup command, tools cannot be called without authentication.
  • ② MCP Gateway → Business API: This is authentication per business API. The SampleAPIs we're using as our example happen to require no authentication, but in actual operations, API keys or OAuth would be held on the MCP Gateway side and applied.

The benefit of MCP Gateway "centralizing authentication in one place" comes from this structure: ① centrally receives client authentication, and ② the MCP Gateway manages each API's authentication credentials on its behalf.

Below, we'll first issue a JWT token from the Admin UI for ①.

Issuing a JWT Token

JWT tokens can be issued from the token management screen in the Admin UI. Tokens issued from the screen are registered in the DB and can be listed or revoked later.

In the Admin UI you just logged into, open "API Tokens" under "ORGANIZATION" in the sidebar.

API Tokens in the sidebar

Specify a name and expiration date and press "Create Token" to issue one.

Token issuance form in Admin UI

The expiration date is not optional but required, and the form also states "Expiration required by server policy (REQUIRE_TOKEN_EXPIRATION=true)." It's designed so you can't create tokens without an expiration.

In "Token Scoping," you can narrow down the scope allowed by this token. This time I specified tools.read, tools.execute in Permissions to create a token that only allows reading and executing tools. You can also specify restrictions to specific virtual servers (Server ID) or restrictions on source IPs (IP Restrictions).

When issued, the token string is displayed. This screen is only shown once, so copy it here.

Token display screen after issuance

Issued tokens appear in the list. You can check creation date, expiration, last used date/time, and scope, and you can also revoke tokens with "Revoke" and view usage statistics with "Usage Stats."

Token list in Admin UI

Store the copied token in an environment variable. Later, when connecting from the client, it will be used as the authentication header (Authorization: Bearer <TOKEN>).

$ export TOKEN="<<YOUR_TOKEN>>"

Registering an Existing Web API as an MCP Tool

Now to the main topic. I'll register a SampleAPIs endpoint as a REST tool in ContextForge. Open "Tools" under "MCP" in the sidebar, and at the bottom of the list you'll find a form called "Add New Tool from REST API." This is the entry point for MCP-ifying existing Web APIs.

Add New Tool from REST API form

I only entered these three things:

  • Name: sample-coffee-hot
  • URL: https://api.sampleapis.com/coffee/hot
  • Description: A description of the tool. Since the LLM reads this when choosing a tool, write what the tool can do

Integration Type defaults to REST and Request Type defaults to GET, so I left them as-is. Entering the url automatically extracts the internally used base_url and path_template. Headers, Input Schema, Output Schema, and Json Path Filter can be left empty and the tool can still be registered. For APIs that require authentication, select Basic / Bearer Token / Custom Headers under "Authentication Type" and hold the credentials there (today's SampleAPIs requires no authentication so I left it as None).

At the bottom of the form, specify tags and visibility, then press "Add Tool." Visibility is a choice of Public / Team / Private, defaulting to Public.

Bottom of form and Add Tool button

Once registered, it appears in the tool list.

Registered tool list

A Tool ID is assigned, the Source shows the registered URL, and Status shows REST Public Online. The Name shown here (sample-coffee-hot) becomes the name used when calling the tool from an MCP client.

You can also verify operation from the screen. Choose "Test" from "Actions" in the list and press "Run Tool" to get the result of calling the API through the MCP Gateway back as a JSON-RPC response.

Execution result in Test Tool

isError: false, and SampleAPIs' coffee list (Black Coffee, etc.) is in result.content as text. What was just a REST API at api.sampleapis.com/coffee/hot can now be treated as an MCP tool through ContextForge.

Grouping into a Virtual Server

Registered tools can be grouped as a virtual server and published to clients as a single MCP endpoint. Open "Virtual Servers" in the sidebar and use the "Add New Server" form at the bottom of the list.

Enter a name and description, then check the tools you want to publish under "Associated Tools." The sample-coffee-hot we just registered appears here. Resources and Prompts can be bundled the same way, but this time it's just tools.

Add New Server form

Leave Server ID empty to auto-generate one (only specify a "Custom UUID" if you want to inherit an existing ID). Enabling "Enable OAuth 2.0 for MCP Client Authentication" allows MCP clients to authenticate via browser-based OAuth/SSO. Since I'll be connecting with a JWT token this time, I'll leave it off.

Press "Add Server" and the virtual server is added to the list.

Virtual server list

It shows "1 tool," indicating this virtual server has one tool. Open "View" from "Actions" to see the information needed for connection.

Virtual server details

The Server ID and a URL containing it are displayed. The MCP endpoint for clients to connect to is the form of this URL with /mcp appended to the end.

http://localhost:4444/servers/<<YOUR_SERVER_UUID>>/mcp

This <<YOUR_SERVER_UUID>> and the JWT token from earlier are the connection information when connecting from the client.

Connecting from Claude Desktop

Let's connect to the created virtual server from Claude Desktop as an actual AI client.

One thing to pause and consider here is "where are we connecting from?" Claude Desktop has a connector feature to register remote MCP servers by URL, but this connection is sent from Anthropic's cloud side, so it won't reach our MCP Gateway running on localhost. The same applies to a configuration where the MCP Gateway is placed inside an internal corporate network — if you place it somewhere unreachable from the internet, this route won't work.

So this time, I'll use the method of writing to a configuration file. Since only the stdio format can be written in Claude Desktop's configuration file, I'll use mcp-remote as a bridge that receives stdio and forwards to HTTP.

First, write the token stored earlier in the TOKEN environment variable to a header file.

$ echo "Authorization: Bearer ${TOKEN}" > ~/.contextforge-headers.txt
$ chmod 600 ~/.contextforge-headers.txt

Next, add the following to the configuration file claude_desktop_config.json (on macOS: ~/Library/Application Support/Claude/claude_desktop_config.json). Replace <<YOUR_SERVER_UUID>> with the virtual server's ID and <<YOUR_USERNAME>> with your own username.

claude_desktop_config.json
{
  "mcpServers": {
    "contextforge": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "http://localhost:4444/servers/<<YOUR_SERVER_UUID>>/mcp",
        "--allow-http",
        "--transport", "http-only",
        "--header-file", "/Users/<<YOUR_USERNAME>>/.contextforge-headers.txt"
      ]
    }
  }
}

For details on mcp-remote flags and how to pass authentication headers, refer to the official README.

https://www.npmjs.com/package/mcp-remote

Restart Claude Desktop, and the business API tools you registered become usable from Claude.

Claude Desktop accessing tools through ContextForge

From the AI client's perspective, the connection destination is just a single ContextForge endpoint. Behind the scenes, multiple business systems are bundled, and the connection to the MCP Gateway is authenticated with JWT. This is the concrete form of a "single controlled entry point."

Seeing "What Was Called and How Much" in the Metrics Screen

Another benefit of routing through the MCP Gateway is that call records are gathered in one place. With direct connections, you'd have to look at each API's logs individually, but ContextForge lets you view calls that passed through the MCP Gateway all together in the "Metrics" section under "MONITORING" in the sidebar.

Let me open it after executing the earlier tool a few times.

Metrics screen Overview tab

The top row of cards shows entity counts (users, teams, MCP resources, collected metrics), and below that are execution-related metrics: total execution count, success rate, average response time, and error rate.

"Top Performers" then ranks the most frequently used tools, resources, prompts, and virtual servers. Execution count, average response time, success rate, and last used date/time are shown per tool, so "which APIs are being used and which ones are slow" is visible here alone. Below that, breakdown for each of Tools / Resources / Prompts / Servers (success/failure count, failure rate, average response time, last execution time) is shown.

Switching tabs at the top of the screen reveals other perspectives. The "Activity" tab shows the status of API tokens, session count, and the number of collected metrics records.

Metrics screen Activity tab

Active / Revoked / Total for issued tokens, MCP session count, and "Token Logs" shows the number of token usage log entries. You can track how much each person's token is being used.

The "Security" tab shows counts of authentication events and audit logs.

Metrics screen Security tab

Auth Events are authentication events like logins, and this environment has counts from re-logins. Audit Logs accumulate when AUDIT_TRAIL_ENABLED is enabled; since it's disabled this time, the count is 0.

The numbers themselves are small since it's a test environment, but the fact that just by routing through a single MCP Gateway, per-tool usage records and response times are automatically captured is valuable in itself. With direct connections, tracking "which API was called, how much, and how fast" would require cross-referencing each API's logs separately.

Supplement: Operations Available from Both the UI and API

All operations so far were done from the Admin UI, but the same things can be done via REST API as well. Since the Admin UI itself calls this API, anything you can do on screen can also be done via API. This is more suited for cases where you want to manage tool and virtual server registration in code, or want to push changes from CI/CD.

The list of endpoints can be seen directly at /docs (Swagger UI) and /redoc on the running MCP Gateway. If you're already logged into the Admin UI in your browser, you can open these directly.

http://localhost:4444/docs

For specific usage, the API Usage Guide in the official documentation has examples per endpoint.

https://ibm.github.io/mcp-context-forge/manage/api-usage/

Supplement: What Was Cut to Create the Minimal Configuration

The official docker-compose.yml is a configuration with production operations in mind. Even without specifying profiles, it starts up a Gateway (3 replicas), nginx, PostgreSQL, pgbouncer, Redis, a DB migration job, and sample MCP servers with their registration jobs. Adding the monitoring profile adds Prometheus, Grafana, Loki, Tempo, pgAdmin, etc., and the sso profile adds Keycloak.

For this time, I replaced this with the following to get a single container.

Full Configuration This Minimal Configuration
PostgreSQL + pgbouncer (connection pool) SQLite (DATABASE_URL="sqlite:////data/mcp.db")
Redis (CACHE_TYPE=redis) Leave as default CACHE_TYPE=database. No Redis needed
nginx (TLS termination / cache, published on 8080) Publish Gateway's 4444 directly
One-shot job for migrations Not needed since SQLite file is created at startup
Sample MCP servers and auto-registration jobs Not needed since we register the target Web API ourselves
Gateway 3 replicas / GUNICORN_WORKERS=24 1 container / GUNICORN_WORKERS=2
Monitoring stack (monitoring profile) Check via Admin UI Metrics screen

Conversely, there are parts that aren't enough with defaults in docker run, so I explicitly added those.

  • MCPGATEWAY_UI_ENABLED / MCPGATEWAY_ADMIN_API_ENABLED: Both default to false, so set to true to use the Admin UI
  • JWT_SECRET_KEY / AUTH_ENCRYPTION_SECRET: Strength is validated at startup, so pass generated values
  • EXPOSE_ERROR_DETAILS=true: Validation errors are masked by default, returning only {"detail": "An error occurred, please try again."}, so this makes details visible during verification (a setting that should be hidden in production)

There are also constraints from what was cut. Since we're publishing directly over HTTP without nginx, a secure cookie warning appears on the login screen. With SQLite and a single container, sharing across multiple instances or scaling cannot be verified. Without the monitoring stack, metrics are only visible within what the Admin UI shows. It was sufficient for the purpose of "checking whether features meet requirements," but for verifying a production configuration, you'd need to set up the full configuration separately.

Closing

Starting from the theme of AI agents accessing internal business systems, I explored the concept of MCP Gateway and tried out ContextForge, its OSS implementation.

To summarize: the process of converting Web APIs to MCP is unavoidable no matter what, but the essence of MCP Gateway lies in being able to bundle that conversion in one place and centralize governance like authentication, auditing, rate limiting, and caching. Particularly for business systems where API usage is pay-per-use, cost governance through rate limiting and caching becomes effective. In actual operations, rather than governing everything uniformly, approaches like only routing systems with high cost risk through the Gateway are also worth considering.

For those who have started struggling with managing growing numbers of MCP servers, I think first trying to MCP-ify a single existing API locally and bundle it will give you a hands-on feel for where MCP Gateway shines. Please give it a try.

I hope this blog post is of some help to someone.

References

  • ContextForge (IBM/mcp-context-forge)

https://github.com/IBM/mcp-context-forge

  • ContextForge Official Documentation

https://ibm.github.io/mcp-context-forge/

  • SampleAPIs used as the example

https://sampleapis.com/

  • mcp-remote (stdio ⇄ remote MCP bridge)

https://www.npmjs.com/package/mcp-remote


AI白書2026 配布中

クラスメソッドが独自に行なったAI診断調査をもとに、企業のAI活用の現在地を調査レポートとしてまとめました。企業規模別の活用度傾向に加え、規模を超えてAI活用を進める企業に共通する取り組みまで、自社の現在地を捉えるためのヒントにぜひ。

AI白書2026

無料でダウンロードする

Share this article

DevelopersIO 2026