Ajustes na documentação e remanejamento dos folders

This commit is contained in:
2026-06-21 09:34:08 -03:00
parent 4df0f39834
commit 804244b39d
32 changed files with 684 additions and 378 deletions

View File

@@ -1,7 +1,15 @@
FROM python:3.11-slim
FROM python:3.12-slim
WORKDIR /app
COPY apps/mcp_gateway/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY apps/mcp_gateway /app
EXPOSE 9200
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9200"]
COPY apps/mcp_gateway/app /app/app
COPY apps/mcp_gateway/config /app/config
ENV MCP_GATEWAY_CONFIG_PATH=/app/config/mcp_gateway.yaml
EXPOSE 8300
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8300"]

View File

@@ -0,0 +1 @@

View File

@@ -1,74 +1,232 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Any
import httpx
import yaml
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
class ToolInvokeRequest(BaseModel):
class BusinessContext(BaseModel):
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ToolInvocation(BaseModel):
tenant_id: str = "default"
agent_id: str
channel: str | None = None
tool_name: str
arguments: dict[str, Any] = Field(default_factory=dict)
context: dict[str, Any] = Field(default_factory=dict)
business_context: BusinessContext = Field(default_factory=BusinessContext)
metadata: dict[str, Any] = Field(default_factory=dict)
app = FastAPI(title="Agent Framework OCI - MCP Gateway", version="0.1.0")
class ToolResult(BaseModel):
tool_name: str
version: str | None = None
ok: bool
data: Any = None
error: str | None = None
cache: dict[str, Any] = Field(default_factory=dict)
latency_ms: int = 0
metadata: dict[str, Any] = Field(default_factory=dict)
def load_config() -> dict[str, Any]:
config_path = Path(os.getenv("MCP_GATEWAY_CONFIG", "config/mcp_servers.yaml"))
if not config_path.exists():
return {"servers": {}}
return yaml.safe_load(config_path.read_text()) or {"servers": {}}
path = Path(os.getenv("MCP_GATEWAY_CONFIG_PATH", "config/mcp_gateway.yaml"))
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def find_tool(tool_name: str) -> tuple[str, dict[str, Any]] | None:
cfg = load_config()
for server_name, server in (cfg.get("servers") or {}).items():
if tool_name in (server.get("tools") or []):
return server_name, server
return None
config = load_config()
cache: dict[str, tuple[float, Any]] = {}
app = FastAPI(title="Agent Platform OCI - MCP Gateway", version="1.0.0")
def audit(name: str, payload: dict[str, Any]) -> None:
print(json.dumps({"event": name, **payload}, ensure_ascii=False, default=str))
def auth_check(authorization: str | None) -> None:
auth = config.get("auth") or {}
if not auth.get("enabled", False):
return
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing MCP Gateway bearer token")
token = authorization.split(" ", 1)[1]
if token not in (auth.get("static_tokens") or {}):
raise HTTPException(status_code=403, detail="Invalid MCP Gateway token")
def map_arguments(tool_name: str, args: dict[str, Any], bc: dict[str, Any]) -> dict[str, Any]:
result = dict(args or {})
for source, target in ((config.get("parameter_mapping") or {}).get(tool_name) or {}).items():
if bc.get(source) is not None and target not in result:
result[target] = bc[source]
return result
def cache_key(tenant_id: str, agent_id: str, tool_name: str, version: str, args: dict[str, Any]) -> str:
digest = hashlib.sha256(json.dumps(args, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
return f"mcp:{tenant_id}:{agent_id}:{tool_name}:{version}:{digest}"
async def post_with_retry(url: str, payload: dict[str, Any], timeout: int, retry: dict[str, Any]) -> Any:
attempts = int(retry.get("max_attempts", 1)) if retry.get("enabled", False) else 1
backoff_ms = int(retry.get("backoff_ms", 250))
last_exc: Exception | None = None
for attempt in range(attempts):
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
return resp.json()
except Exception as exc:
last_exc = exc
if attempt < attempts - 1:
await asyncio.sleep(backoff_ms / 1000)
raise RuntimeError(str(last_exc))
def build_server_payload(tool_name: str, args: dict[str, Any], server: dict[str, Any], tool: dict[str, Any]) -> dict[str, Any]:
"""Builds the payload expected by the downstream MCP server.
The example servers under mcp/servers expose the framework legacy contract:
POST /mcp/tools/call {tool_name, arguments}. Some mocks expose direct
tool endpoints that accept only the argument object. The gateway supports
both shapes through the server/tool config.
"""
protocol = str(tool.get("protocol") or server.get("protocol") or "legacy_http")
if protocol in {"legacy_http", "framework_http"}:
return {"tool_name": tool_name, "arguments": args or {}}
return args or {}
def normalize_server_response(data: Any) -> tuple[bool, Any, str | None, dict[str, Any]]:
if isinstance(data, dict) and ("ok" in data or "result" in data or "error" in data):
ok = bool(data.get("ok", not data.get("error")))
return ok, data.get("result", data.get("data")), data.get("error"), data.get("metadata") or {}
return True, data, None, {}
@app.get("/health")
def health() -> dict[str, Any]:
return {"status": "ok", "component": "mcp_gateway"}
async def health():
return {"status": "ok", "service": "mcp_gateway"}
@app.get("/tools")
def tools() -> dict[str, Any]:
cfg = load_config()
result = []
for server_name, server in (cfg.get("servers") or {}).items():
for tool in server.get("tools") or []:
result.append({"name": tool, "server": server_name})
return {"tools": result}
@app.get("/ready")
async def ready():
enabled_tools = [k for k, v in (config.get("tools") or {}).items() if v.get("enabled", True)]
return {"status": "ready", "tools": enabled_tools}
@app.post("/tools/{tool_name}/invoke")
async def invoke_tool(tool_name: str, payload: ToolInvokeRequest) -> dict[str, Any]:
found = find_tool(tool_name)
if not found:
raise HTTPException(status_code=404, detail=f"Tool not registered in MCP Gateway: {tool_name}")
server_name, server = found
base_url = server.get("base_url")
if not base_url:
raise HTTPException(status_code=500, detail=f"Server {server_name} has no base_url")
@app.get("/v1/tools")
async def tools():
return {"tools": [{"name": name, **cfg} for name, cfg in (config.get("tools") or {}).items()]}
@app.get("/v1/tools/{tool_name}")
async def tool_detail(tool_name: str):
tool = (config.get("tools") or {}).get(tool_name)
if not tool:
raise HTTPException(status_code=404, detail=f"Tool not found: {tool_name}")
return {"name": tool_name, **tool}
@app.post("/v1/tools/{tool_name}/invoke", response_model=ToolResult)
async def invoke(tool_name: str, invocation: ToolInvocation, authorization: str | None = Header(default=None)):
started = time.perf_counter()
auth_check(authorization)
tool = (config.get("tools") or {}).get(tool_name)
if not tool or not tool.get("enabled", True):
raise HTTPException(status_code=404, detail=f"Tool not found or disabled: {tool_name}")
if invocation.tool_name != tool_name:
raise HTTPException(status_code=422, detail="Path tool_name and body tool_name differ")
allowed_agents = tool.get("allowed_agents") or []
if allowed_agents and invocation.agent_id not in allowed_agents:
raise HTTPException(status_code=403, detail=f"Agent not allowed: {invocation.agent_id}")
allowed_channels = tool.get("allowed_channels") or []
if invocation.channel and allowed_channels and invocation.channel not in allowed_channels:
raise HTTPException(status_code=403, detail=f"Channel not allowed: {invocation.channel}")
bc = invocation.business_context.model_dump()
missing = [k for k in tool.get("required_business_keys", []) if not bc.get(k)]
if missing:
raise HTTPException(status_code=422, detail={"missing_business_keys": missing})
version = str(tool.get("version", "1.0.0"))
args = map_arguments(tool_name, invocation.arguments, bc)
ttl = int(tool.get("cache_ttl_seconds", 0) or 0)
ck = None
if tool.get("idempotent", False) and ttl > 0:
ck = cache_key(invocation.tenant_id, invocation.agent_id, tool_name, version, args)
cached = cache.get(ck)
if cached and cached[0] > time.time():
audit("mcp.cache.hit", {"tool": tool_name, "agent_id": invocation.agent_id})
return ToolResult(
tool_name=tool_name,
version=version,
ok=True,
data=cached[1],
cache={"hit": True, "key": ck, "ttl_seconds": ttl},
latency_ms=int((time.perf_counter() - started) * 1000),
)
server = (config.get("servers") or {}).get(tool.get("server"))
if not server or not server.get("enabled", True):
raise HTTPException(status_code=503, detail=f"MCP server unavailable: {tool.get('server')}")
url = f"{server['url'].rstrip('/')}{tool.get('endpoint')}"
audit("mcp.tool.started", {"tool": tool_name, "version": version, "agent_id": invocation.agent_id, "server": tool.get("server")})
# Conventional endpoint. Concrete MCP servers may adapt this via an adapter later.
url = f"{base_url.rstrip('/')}/tools/{tool_name}/invoke"
try:
async with httpx.AsyncClient(timeout=float(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "30"))) as client:
resp = await client.post(url, json=payload.model_dump())
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
data.setdefault("gateway", "mcp_gateway")
data.setdefault("server", server_name)
data.setdefault("tool", tool_name)
return data
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"MCP upstream request failed: {exc}") from exc
raw_data = await post_with_retry(
url=url,
payload=build_server_payload(tool_name, args, server, tool),
timeout=int(tool.get("timeout_seconds") or server.get("timeout_seconds") or 30),
retry=tool.get("retry") or {},
)
ok, data, error, server_metadata = normalize_server_response(raw_data)
if ck and ttl > 0 and ok:
cache[ck] = (time.time() + ttl, data)
latency_ms = int((time.perf_counter() - started) * 1000)
audit("mcp.tool.completed", {"tool": tool_name, "latency_ms": latency_ms, "ok": ok})
return ToolResult(
tool_name=tool_name,
version=version,
ok=ok,
data=data,
error=error,
cache={"hit": False, "key": ck, "ttl_seconds": ttl},
latency_ms=latency_ms,
metadata={"server": tool.get("server"), **server_metadata},
)
except Exception as exc:
latency_ms = int((time.perf_counter() - started) * 1000)
audit("mcp.tool.failed", {"tool": tool_name, "latency_ms": latency_ms, "error": str(exc)})
return ToolResult(
tool_name=tool_name,
version=version,
ok=False,
error=str(exc),
latency_ms=latency_ms,
metadata={"server": tool.get("server")},
)

View File

@@ -1,5 +1,5 @@
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2
fastapi>=0.110
uvicorn[standard]>=0.27
pydantic>=2.6
PyYAML>=6.0
httpx>=0.27
PyYAML>=6