From d3667e805d325e6c8538f7dd64ebc943f49b9fa4 Mon Sep 17 00:00:00 2001 From: Cristiano Hoshikawa Date: Sun, 21 Jun 2026 09:34:23 -0300 Subject: [PATCH] =?UTF-8?q?Ajustes=20na=20documenta=C3=A7=C3=A3o=20e=20rem?= =?UTF-8?q?anejamento=20dos=20folders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/workspace.xml | 121 ++++++++ MANUAL_AGENT_PLATFORM_GATEWAYS.md | 275 ++++++++++++++++++ .../app/config/governance_loader.py | 14 + apps/agent_gateway/app/governance/__init__.py | 1 + apps/agent_gateway/app/governance/audit.py | 14 + .../app/governance/evaluation_hooks.py | 11 + .../app/governance/model_policies.py | 66 +++++ .../app/governance/rate_limit.py | 35 +++ apps/agent_gateway/app/governance/usage.py | 14 + .../app/governance_middleware.py | 78 +++++ .../app/routes/governed_proxy_example.py | 40 +++ .../config/gateway_governance.yaml | 56 ++++ apps/mcp_gateway/.env.example | 3 + apps/mcp_gateway/config/mcp_gateway.yaml | 172 +++++++++++ deploy/docker/docker-compose.mcp-gateway.yml | 20 ++ .../agent_framework/gateway_policy_context.py | 27 ++ .../src/agent_framework/gateways/__init__.py | 3 + .../gateways/mcp_gateway_client.py | 50 ++++ .../runtime_mcp_gateway_adapter.py | 35 +++ mcp/servers/mock_telecom_mcp/app.py | 29 ++ mcp/servers/mock_telecom_mcp/requirements.txt | 2 + .../app/mcp_gateway_client_factory.py | 16 + .../app/mcp_gateway_client_factory.py | 16 + 23 files changed, 1098 insertions(+) create mode 100644 .idea/workspace.xml create mode 100644 MANUAL_AGENT_PLATFORM_GATEWAYS.md create mode 100644 apps/agent_gateway/app/config/governance_loader.py create mode 100644 apps/agent_gateway/app/governance/__init__.py create mode 100644 apps/agent_gateway/app/governance/audit.py create mode 100644 apps/agent_gateway/app/governance/evaluation_hooks.py create mode 100644 apps/agent_gateway/app/governance/model_policies.py create mode 100644 apps/agent_gateway/app/governance/rate_limit.py create mode 100644 apps/agent_gateway/app/governance/usage.py create mode 100644 apps/agent_gateway/app/governance_middleware.py create mode 100644 apps/agent_gateway/app/routes/governed_proxy_example.py create mode 100644 apps/agent_gateway/config/gateway_governance.yaml create mode 100644 apps/mcp_gateway/.env.example create mode 100644 apps/mcp_gateway/config/mcp_gateway.yaml create mode 100644 deploy/docker/docker-compose.mcp-gateway.yml create mode 100644 libs/agent_framework/src/agent_framework/gateway_policy_context.py create mode 100644 libs/agent_framework/src/agent_framework/gateways/__init__.py create mode 100644 libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py create mode 100644 libs/agent_framework/src/agent_framework/runtime_mcp_gateway_adapter.py create mode 100644 mcp/servers/mock_telecom_mcp/app.py create mode 100644 mcp/servers/mock_telecom_mcp/requirements.txt create mode 100644 templates/agent_template_backend/app/mcp_gateway_client_factory.py create mode 100644 templates/agent_template_backend_day_zero/app/mcp_gateway_client_factory.py diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..6b0f862 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "lastFilter": { + "state": "OPEN", + "assignee": "hoshikawa2" + } +} + { + "selectedUrlAndAccountId": { + "url": "https://github.com/hoshikawa2/agent_platform_oci.git", + "accountId": "bd799ac0-7624-42b2-999a-b96e6e11f98a" + } +} + + + + + + { + "customColor": "", + "associatedIndex": 2 +} + + + + { + "keyToString": { + "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "SHARE_PROJECT_CONFIGURATION_FILES": "true", + "git-widget-placeholder": "main", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "settings.editor.selected.configurable": "configurable.group.appearance", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + 1781996088468 + + + + + + \ No newline at end of file diff --git a/MANUAL_AGENT_PLATFORM_GATEWAYS.md b/MANUAL_AGENT_PLATFORM_GATEWAYS.md new file mode 100644 index 0000000..4f36a57 --- /dev/null +++ b/MANUAL_AGENT_PLATFORM_GATEWAYS.md @@ -0,0 +1,275 @@ +# Agent Platform OCI — Manual Oficial de Agent Gateway e MCP Gateway + +## Objetivo + +Este documento consolida: +- Arquitetura oficial +- Inventário dos componentes +- Procedimento completo de execução local +- MCP Gateway +- Agent Gateway +- Backend Runtime +- Frontend +- Testes E2E +- Troubleshooting +- Decisões arquiteturais + +--- + +# Arquitetura Oficial + +Frontend (5173) +↓ +Agent Gateway (9000) +↓ +Agent Template Backend / Runtime (8000) +↓ +MCP Gateway (8300) +↓ +Telecom MCP Server (8100) +Retail MCP Server (8200) + +Não existe AI Gateway. + +--- + +# Portas Oficiais + +| Componente | Porta | +|------------|--------| +| Frontend | 5173 | +| Agent Gateway | 9000 | +| Backend Runtime | 8000 | +| MCP Gateway | 8300 | +| Telecom MCP Server | 8100 | +| Retail MCP Server | 8200 | + +--- + +# Variáveis Oficiais + +## Agent Template Backend + +ENABLE_MCP_TOOLS=true + +MCP_GATEWAY_ENABLED=true +MCP_GATEWAY_URL=http://localhost:8300 +MCP_GATEWAY_TIMEOUT_SECONDS=60 +MCP_GATEWAY_AGENT_ID=telecom_contas +MCP_GATEWAY_TENANT_ID=default + +## Agent Gateway + +DEFAULT_AGENT_BACKEND_URL=http://localhost:8000 +AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml + +## MCP Gateway + +MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml + +--- + +# Ordem de Inicialização + +1. Telecom MCP Server +2. Retail MCP Server +3. MCP Gateway +4. Agent Template Backend +5. Agent Gateway +6. Frontend + +--- + +# Terminal 1 — Telecom MCP Server + +cd mcp/servers/telecom_mcp_server + +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +python -m uvicorn main:app --host 0.0.0.0 --port 8100 --reload + +Validação: + +curl http://localhost:8100/health + +--- + +# Terminal 2 — Retail MCP Server + +cd mcp/servers/retail_mcp_server + +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +python -m uvicorn main:app --host 0.0.0.0 --port 8200 --reload + +Validação: + +curl http://localhost:8200/health + +--- + +# Terminal 3 — MCP Gateway + +cd apps/mcp_gateway + +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml + +python -m uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload + +Validações: + +curl http://localhost:8300/health +curl http://localhost:8300/ready +curl http://localhost:8300/v1/tools + +Teste: + +curl -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke + +--- + +# Terminal 4 — Agent Template Backend + +cd templates/agent_template_backend + +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +Validações: + +curl http://localhost:8000/health +curl http://localhost:8000/agents + +--- + +# Terminal 5 — Agent Gateway + +cd apps/agent_gateway + +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000 +export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml + +python -m uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload + +Validações: + +curl http://localhost:9000/health + +Teste: + +curl -X POST http://localhost:9000/gateway/message + +--- + +# Terminal 6 — Frontend + +cd agent_frontend + +npm install + +npm run dev -- --host 0.0.0.0 --port 5173 + +Abrir: + +http://localhost:5173 + +Backend URL: + +http://localhost:9000 + +--- + +# Fluxo de Tools + +Agent +↓ +MCPToolRouter +↓ +MCPGatewayClient +↓ +MCP Gateway +↓ +MCP Server + +--- + +# Teste Integrado E2E + +Frontend +↓ +Agent Gateway +↓ +Backend Runtime +↓ +MCP Gateway +↓ +Telecom MCP Server + +Resultado esperado: + +- Agent Gateway recebe requisição +- Runtime executa LangGraph +- MCP Gateway resolve tool +- MCP Server responde +- Usuário recebe resposta + +--- + +# Troubleshooting + +## Backend chamando MCP Server direto + +Confirmar: + +MCP_GATEWAY_ENABLED=true + +MCP_GATEWAY_URL=http://localhost:8300 + +## Porta incorreta + +A porta oficial do MCP Gateway é: + +8300 + +## Agent Gateway não encontra Backend + +Validar: + +curl http://localhost:8000/health + +## MCP Gateway não encontra MCP Server + +Validar: + +curl http://localhost:8100/health +curl http://localhost:8200/health + +--- + +# Decisões Arquiteturais Oficiais + +- Não existe AI Gateway +- Agent Gateway centraliza governança +- Runtime executa LangGraph +- Runtime executa LLM +- MCP Gateway centraliza tools +- MCP Servers executam tools +- Backend usa MCP Gateway +- gateway_runtime.env.example foi removido +- MCP_GATEWAY_* fica no .env do backend +- Porta oficial MCP Gateway = 8300 diff --git a/apps/agent_gateway/app/config/governance_loader.py b/apps/agent_gateway/app/config/governance_loader.py new file mode 100644 index 0000000..7882399 --- /dev/null +++ b/apps/agent_gateway/app/config/governance_loader.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import yaml + + +def load_gateway_governance_config(path: str | None = None) -> dict[str, Any]: + config_path = Path(path or os.getenv("AGENT_GATEWAY_GOVERNANCE_CONFIG", "config/gateway_governance.yaml")) + if not config_path.exists(): + return {} + return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} diff --git a/apps/agent_gateway/app/governance/__init__.py b/apps/agent_gateway/app/governance/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/agent_gateway/app/governance/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/agent_gateway/app/governance/audit.py b/apps/agent_gateway/app/governance/audit.py new file mode 100644 index 0000000..ddaad24 --- /dev/null +++ b/apps/agent_gateway/app/governance/audit.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +logger = logging.getLogger("agent_gateway.governance") + + +def audit_event(name: str, payload: dict[str, Any]) -> None: + safe = dict(payload) + if "message" in safe: + safe["message_len"] = len(str(safe.pop("message") or "")) + logger.info("%s %s", name, json.dumps(safe, ensure_ascii=False, default=str)) diff --git a/apps/agent_gateway/app/governance/evaluation_hooks.py b/apps/agent_gateway/app/governance/evaluation_hooks.py new file mode 100644 index 0000000..e04dbb7 --- /dev/null +++ b/apps/agent_gateway/app/governance/evaluation_hooks.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + + +class EvaluationHooks: + def before_backend_call(self, request_payload: dict[str, Any]) -> dict[str, Any]: + return request_payload + + def after_backend_call(self, response_payload: dict[str, Any]) -> dict[str, Any]: + return response_payload diff --git a/apps/agent_gateway/app/governance/model_policies.py b/apps/agent_gateway/app/governance/model_policies.py new file mode 100644 index 0000000..62ffab7 --- /dev/null +++ b/apps/agent_gateway/app/governance/model_policies.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any + + +class ModelPolicyError(RuntimeError): + pass + + +class ModelPolicyResolver: + def __init__(self, config: dict[str, Any]): + self.config = config or {} + + def resolve_profile( + self, + *, + tenant_id: str, + agent_id: str | None, + operation: str, + requested_profile: str | None = None, + ) -> dict[str, Any]: + profiles = self.config.get("profiles", {}) or {} + operation_profiles = self.config.get("operation_profiles", {}) or {} + + profile_name = requested_profile or operation_profiles.get(operation) or "default" + profile = profiles.get(profile_name) + if not profile: + raise ModelPolicyError(f"Model profile not found: {profile_name}") + + self._validate_policy( + tenant_id=tenant_id, + agent_id=agent_id, + profile_name=profile_name, + profile=profile, + ) + + return { + "profile": profile_name, + "provider": profile.get("provider"), + "model": profile.get("model"), + "parameters": { + k: v for k, v in profile.items() + if k not in {"provider", "model"} + }, + } + + def _validate_policy( + self, + *, + tenant_id: str, + agent_id: str | None, + profile_name: str, + profile: dict[str, Any], + ) -> None: + policies = self.config.get("policies", {}) or {} + tenant_policies = policies.get("tenants", {}) or {} + tenant_policy = tenant_policies.get(tenant_id) or tenant_policies.get("default") or {} + + allowed_profiles = tenant_policy.get("allowed_profiles") + if allowed_profiles and profile_name not in allowed_profiles: + raise ModelPolicyError(f"Profile not allowed for tenant={tenant_id}: {profile_name}") + + allowed_providers = tenant_policy.get("allowed_providers") + provider = profile.get("provider") + if allowed_providers and provider not in allowed_providers: + raise ModelPolicyError(f"Provider not allowed for tenant={tenant_id}: {provider}") diff --git a/apps/agent_gateway/app/governance/rate_limit.py b/apps/agent_gateway/app/governance/rate_limit.py new file mode 100644 index 0000000..92e7a4e --- /dev/null +++ b/apps/agent_gateway/app/governance/rate_limit.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import time +from collections import defaultdict, deque +from typing import Any + + +class RateLimitExceeded(RuntimeError): + pass + + +class InMemoryRateLimiter: + def __init__(self, config: dict[str, Any]): + self.config = config or {} + self.events: dict[str, deque[float]] = defaultdict(deque) + + def check(self, *, tenant_id: str, agent_id: str | None, channel: str | None) -> None: + default_limit = ((self.config.get("default") or {}).get("requests_per_minute")) or 600 + agent_limits = self.config.get("agents") or {} + channel_limits = self.config.get("channels") or {} + + limit = default_limit + if agent_id and agent_id in agent_limits: + limit = agent_limits[agent_id].get("requests_per_minute", limit) + if channel and channel in channel_limits: + limit = min(limit, channel_limits[channel].get("requests_per_minute", limit)) + + key = f"{tenant_id}:{agent_id or '*'}:{channel or '*'}" + now = time.time() + bucket = self.events[key] + while bucket and bucket[0] < now - 60: + bucket.popleft() + if len(bucket) >= int(limit): + raise RateLimitExceeded(f"Gateway rate limit exceeded for {key}: {limit}/min") + bucket.append(now) diff --git a/apps/agent_gateway/app/governance/usage.py b/apps/agent_gateway/app/governance/usage.py new file mode 100644 index 0000000..14b62e7 --- /dev/null +++ b/apps/agent_gateway/app/governance/usage.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + + +class UsageRecorder: + def record_gateway_request(self, payload: dict[str, Any]) -> None: + return None + + def record_model_policy(self, payload: dict[str, Any]) -> None: + return None + + def record_backend_response(self, payload: dict[str, Any]) -> None: + return None diff --git a/apps/agent_gateway/app/governance_middleware.py b/apps/agent_gateway/app/governance_middleware.py new file mode 100644 index 0000000..a41b7fd --- /dev/null +++ b/apps/agent_gateway/app/governance_middleware.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from app.config.governance_loader import load_gateway_governance_config +from app.governance.audit import audit_event +from app.governance.evaluation_hooks import EvaluationHooks +from app.governance.model_policies import ModelPolicyError, ModelPolicyResolver +from app.governance.rate_limit import InMemoryRateLimiter, RateLimitExceeded +from app.governance.usage import UsageRecorder + + +class AgentGatewayGovernance: + def __init__(self, config: dict[str, Any] | None = None): + self.config = config if config is not None else load_gateway_governance_config() + self.model_resolver = ModelPolicyResolver((self.config.get("model_governance") or {})) + self.rate_limiter = InMemoryRateLimiter((self.config.get("rate_limits") or {})) + self.usage = UsageRecorder() + self.eval_hooks = EvaluationHooks() + + def prepare_backend_request(self, gateway_request: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]: + tenant_id = gateway_request.get("tenant_id") or "default" + agent_id = gateway_request.get("agent_id") + channel = gateway_request.get("channel") + payload = gateway_request.get("payload") or {} + metadata = payload.setdefault("metadata", {}) + + try: + self.rate_limiter.check(tenant_id=tenant_id, agent_id=agent_id, channel=channel) + + model_policy = self.model_resolver.resolve_profile( + tenant_id=tenant_id, + agent_id=agent_id, + operation=metadata.get("operation") or "agent.final_answer", + requested_profile=metadata.get("llm_profile"), + ) + metadata["model_policy"] = model_policy + + headers = { + "X-Agent-Gateway-Governance": "enabled", + "X-Model-Profile": str(model_policy.get("profile") or ""), + "X-Model-Provider": str(model_policy.get("provider") or ""), + "X-Model-Name": str(model_policy.get("model") or ""), + } + + audit_event("agent_gateway.request.governed", { + "tenant_id": tenant_id, + "agent_id": agent_id, + "channel": channel, + "model_policy": model_policy, + "request_id": metadata.get("request_id"), + "message": payload.get("message"), + }) + + self.usage.record_gateway_request({ + "tenant_id": tenant_id, + "agent_id": agent_id, + "channel": channel, + "metadata": metadata, + }) + + governed = self.eval_hooks.before_backend_call(gateway_request) + return governed, headers + + except RateLimitExceeded as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc + except ModelPolicyError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + def process_backend_response(self, response_payload: dict[str, Any]) -> dict[str, Any]: + response_payload = self.eval_hooks.after_backend_call(response_payload) + self.usage.record_backend_response(response_payload) + audit_event("agent_gateway.response.completed", { + "metadata": response_payload.get("metadata") if isinstance(response_payload, dict) else {}, + }) + return response_payload diff --git a/apps/agent_gateway/app/routes/governed_proxy_example.py b/apps/agent_gateway/app/routes/governed_proxy_example.py new file mode 100644 index 0000000..b876211 --- /dev/null +++ b/apps/agent_gateway/app/routes/governed_proxy_example.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import os +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException, Request + +from app.governance_middleware import AgentGatewayGovernance + +router = APIRouter() +governance = AgentGatewayGovernance() + + +@router.post("/gateway/message/governed") +async def governed_gateway_message(request: Request): + """Example governed proxy route. + + Use as reference to patch the existing /gateway/message handler. + """ + + body: dict[str, Any] = await request.json() + backend_url = os.getenv("DEFAULT_AGENT_BACKEND_URL", "http://localhost:8000") + + governed_body, headers = governance.prepare_backend_request(body) + + try: + async with httpx.AsyncClient(timeout=90) as client: + resp = await client.post( + f"{backend_url.rstrip('/')}/gateway/message", + json=governed_body, + headers=headers, + ) + resp.raise_for_status() + data = resp.json() + return governance.process_backend_response(data) + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc diff --git a/apps/agent_gateway/config/gateway_governance.yaml b/apps/agent_gateway/config/gateway_governance.yaml new file mode 100644 index 0000000..65d3a68 --- /dev/null +++ b/apps/agent_gateway/config/gateway_governance.yaml @@ -0,0 +1,56 @@ +model_governance: + profiles: + default: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0.2 + max_tokens: 2048 + + router: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 500 + + judge: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 800 + + operation_profiles: + router.intent: router + agent.final_answer: default + judge.response_quality: judge + + policies: + tenants: + default: + allowed_providers: + - oci_openai + - oci_sdk + - mock + allowed_profiles: + - default + - router + - judge + +rate_limits: + default: + requests_per_minute: 600 + channels: + whatsapp: + requests_per_minute: 120 + web: + requests_per_minute: 300 + agents: + telecom_contas: + requests_per_minute: 180 + +backend_headers: + propagate_model_policy: true + propagate_trace_context: true + +evaluation: + enabled: true + sample_rate: 1.0 diff --git a/apps/mcp_gateway/.env.example b/apps/mcp_gateway/.env.example new file mode 100644 index 0000000..a733fe5 --- /dev/null +++ b/apps/mcp_gateway/.env.example @@ -0,0 +1,3 @@ +MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml +MCP_GATEWAY_HOST=0.0.0.0 +MCP_GATEWAY_PORT=8300 diff --git a/apps/mcp_gateway/config/mcp_gateway.yaml b/apps/mcp_gateway/config/mcp_gateway.yaml new file mode 100644 index 0000000..978aba5 --- /dev/null +++ b/apps/mcp_gateway/config/mcp_gateway.yaml @@ -0,0 +1,172 @@ +# Dedicated MCP Gateway configuration. +# The agent backend/framework calls this gateway; this gateway calls the final MCP servers. + +servers: + telecom: + enabled: true + protocol: legacy_http + transport: http + # Local run: uvicorn mcp.servers.telecom_mcp_server.main:app --port 8100 + url: http://localhost:8100/mcp + timeout_seconds: 30 + + retail: + enabled: true + protocol: legacy_http + transport: http + # Local run: uvicorn mcp.servers.retail_mcp_server.main:app --port 8200 + url: http://localhost:8200/mcp + timeout_seconds: 30 + +tools: + consultar_fatura: + version: 1.0.0 + server: telecom + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: true + cache_ttl_seconds: 300 + timeout_seconds: 30 + retry: {enabled: true, max_attempts: 2, backoff_ms: 250} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + consultar_pagamentos: + version: 1.0.0 + server: telecom + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: true + cache_ttl_seconds: 300 + timeout_seconds: 30 + retry: {enabled: true, max_attempts: 2, backoff_ms: 250} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + consultar_plano: + version: 1.0.0 + server: telecom + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: true + cache_ttl_seconds: 300 + timeout_seconds: 30 + retry: {enabled: true, max_attempts: 2, backoff_ms: 250} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + listar_servicos: + version: 1.0.0 + server: telecom + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: true + cache_ttl_seconds: 300 + timeout_seconds: 30 + retry: {enabled: true, max_attempts: 2, backoff_ms: 250} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + consultar_pedido: + version: 1.0.0 + server: retail + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: true + cache_ttl_seconds: 300 + timeout_seconds: 30 + retry: {enabled: true, max_attempts: 2, backoff_ms: 250} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + consultar_entrega: + version: 1.0.0 + server: retail + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: true + cache_ttl_seconds: 300 + timeout_seconds: 30 + retry: {enabled: true, max_attempts: 2, backoff_ms: 250} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + solicitar_troca: + version: 1.0.0 + server: retail + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: false + cache_ttl_seconds: 0 + timeout_seconds: 30 + retry: {enabled: false} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + + solicitar_devolucao: + version: 1.0.0 + server: retail + endpoint: /tools/call + protocol: legacy_http + enabled: true + idempotent: false + cache_ttl_seconds: 0 + timeout_seconds: 30 + retry: {enabled: false} + allowed_agents: [] + allowed_channels: [] + required_business_keys: [] + +# Optional mapping if the backend sends canonical BusinessContext directly to the gateway. +# In the normal framework path, the framework already maps before calling the gateway. +parameter_mapping: + consultar_fatura: + customer_key: msisdn + contract_key: invoice_id + interaction_key: ura_call_id + session_key: session_id + consultar_pagamentos: + customer_key: msisdn + interaction_key: ura_call_id + session_key: session_id + consultar_plano: + customer_key: msisdn + resource_key: asset_id + contract_key: asset_id + session_key: session_id + listar_servicos: + customer_key: msisdn + session_key: session_id + consultar_pedido: + customer_key: customer_id + contract_key: order_id + session_key: session_id + consultar_entrega: + contract_key: order_id + session_key: session_id + solicitar_troca: + contract_key: order_id + session_key: session_id + solicitar_devolucao: + contract_key: order_id + session_key: session_id + +auth: + enabled: false + static_tokens: + runtime-local-token: + agents: [] diff --git a/deploy/docker/docker-compose.mcp-gateway.yml b/deploy/docker/docker-compose.mcp-gateway.yml new file mode 100644 index 0000000..03d3d0a --- /dev/null +++ b/deploy/docker/docker-compose.mcp-gateway.yml @@ -0,0 +1,20 @@ +services: + mcp_gateway: + build: + context: ../.. + dockerfile: apps/mcp_gateway/Dockerfile + ports: + - "8300:8300" + depends_on: + - mock_telecom_mcp + + mock_telecom_mcp: + image: python:3.12-slim + working_dir: /app + command: > + sh -c "pip install -r requirements.txt && + uvicorn app:app --host 0.0.0.0 --port 8001" + volumes: + - ../../mcp/servers/mock_telecom_mcp:/app + ports: + - "8001:8001" diff --git a/libs/agent_framework/src/agent_framework/gateway_policy_context.py b/libs/agent_framework/src/agent_framework/gateway_policy_context.py new file mode 100644 index 0000000..341eb7a --- /dev/null +++ b/libs/agent_framework/src/agent_framework/gateway_policy_context.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any + + +def get_gateway_model_policy(state: dict[str, Any]) -> dict[str, Any] | None: + metadata = state.get("metadata") or {} + policy = metadata.get("model_policy") + return policy if isinstance(policy, dict) else None + + +def apply_gateway_model_policy_to_llm_kwargs( + state: dict[str, Any], + fallback_profile: dict[str, Any] | None = None, +) -> dict[str, Any]: + policy = get_gateway_model_policy(state) + if not policy: + return fallback_profile or {} + + params = dict(policy.get("parameters") or {}) + if policy.get("model"): + params["model"] = policy["model"] + if policy.get("provider"): + params["provider"] = policy["provider"] + if policy.get("profile"): + params["profile"] = policy["profile"] + return params diff --git a/libs/agent_framework/src/agent_framework/gateways/__init__.py b/libs/agent_framework/src/agent_framework/gateways/__init__.py new file mode 100644 index 0000000..6106e4d --- /dev/null +++ b/libs/agent_framework/src/agent_framework/gateways/__init__.py @@ -0,0 +1,3 @@ +from .mcp_gateway_client import MCPGatewayClient + +__all__ = ["MCPGatewayClient"] diff --git a/libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py b/libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py new file mode 100644 index 0000000..fb440db --- /dev/null +++ b/libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +import httpx + + +class MCPGatewayClient: + def __init__(self, base_url: str, token: str | None = None, timeout_seconds: int = 60): + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_seconds = timeout_seconds + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.token}"} if self.token else {} + + async def list_tools(self) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + response = await client.get(f"{self.base_url}/v1/tools", headers=self._headers()) + response.raise_for_status() + return response.json() + + async def invoke_tool( + self, + *, + tenant_id: str, + agent_id: str, + channel: str | None, + tool_name: str, + arguments: dict[str, Any] | None = None, + business_context: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "tenant_id": tenant_id, + "agent_id": agent_id, + "channel": channel, + "tool_name": tool_name, + "arguments": arguments or {}, + "business_context": business_context or {}, + "metadata": metadata or {}, + } + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + response = await client.post( + f"{self.base_url}/v1/tools/{tool_name}/invoke", + json=payload, + headers=self._headers(), + ) + response.raise_for_status() + return response.json() diff --git a/libs/agent_framework/src/agent_framework/runtime_mcp_gateway_adapter.py b/libs/agent_framework/src/agent_framework/runtime_mcp_gateway_adapter.py new file mode 100644 index 0000000..74fe472 --- /dev/null +++ b/libs/agent_framework/src/agent_framework/runtime_mcp_gateway_adapter.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + +from agent_framework.gateways import MCPGatewayClient + + +class MCPGatewayRuntimeMixin: + mcp_gateway_client: MCPGatewayClient | None = None + + async def _invoke_mcp_gateway_tool( + self, + state: dict[str, Any], + tool_name: str, + arguments: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if not self.mcp_gateway_client: + raise RuntimeError("MCP Gateway client not configured") + + result = await self.mcp_gateway_client.invoke_tool( + tenant_id=state.get("tenant_id", "default"), + agent_id=state.get("agent_id") or state.get("route") or "unknown", + channel=state.get("channel"), + tool_name=tool_name, + arguments=arguments or {}, + business_context=state.get("business_context") or {}, + metadata={ + "session_id": state.get("session_id"), + "conversation_key": state.get("conversation_key"), + "trace_id": (state.get("metadata") or {}).get("trace_id"), + }, + ) + + state.setdefault("mcp_results", []).append(result) + return result diff --git a/mcp/servers/mock_telecom_mcp/app.py b/mcp/servers/mock_telecom_mcp/app.py new file mode 100644 index 0000000..6300a1b --- /dev/null +++ b/mcp/servers/mock_telecom_mcp/app.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI + +app = FastAPI(title="Mock Telecom MCP Server") + + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "mock_telecom_mcp"} + + +@app.post("/tools/consultar_fatura") +async def consultar_fatura(payload: dict): + return { + "invoice_id": payload.get("invoice_id") or "INV-001", + "msisdn": payload.get("msisdn"), + "valor_total": 249.90, + "vencimento": "2026-06-10", + "status": "ABERTA", + } + + +@app.post("/tools/consultar_pagamentos") +async def consultar_pagamentos(payload: dict): + return { + "msisdn": payload.get("msisdn"), + "pagamentos": [ + {"data": "2026-06-05", "valor": 249.90, "status": "CONFIRMADO"} + ], + } diff --git a/mcp/servers/mock_telecom_mcp/requirements.txt b/mcp/servers/mock_telecom_mcp/requirements.txt new file mode 100644 index 0000000..4178b85 --- /dev/null +++ b/mcp/servers/mock_telecom_mcp/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 diff --git a/templates/agent_template_backend/app/mcp_gateway_client_factory.py b/templates/agent_template_backend/app/mcp_gateway_client_factory.py new file mode 100644 index 0000000..5a32d15 --- /dev/null +++ b/templates/agent_template_backend/app/mcp_gateway_client_factory.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import os + +from agent_framework.gateways import MCPGatewayClient + + +def build_mcp_gateway_client() -> MCPGatewayClient | None: + if os.getenv("MCP_GATEWAY_ENABLED", "true").lower() != "true": + return None + + return MCPGatewayClient( + base_url=os.getenv("MCP_GATEWAY_URL", "http://localhost:8300"), + token=os.getenv("MCP_GATEWAY_TOKEN") or None, + timeout_seconds=int(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "60")), + ) diff --git a/templates/agent_template_backend_day_zero/app/mcp_gateway_client_factory.py b/templates/agent_template_backend_day_zero/app/mcp_gateway_client_factory.py new file mode 100644 index 0000000..5a32d15 --- /dev/null +++ b/templates/agent_template_backend_day_zero/app/mcp_gateway_client_factory.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import os + +from agent_framework.gateways import MCPGatewayClient + + +def build_mcp_gateway_client() -> MCPGatewayClient | None: + if os.getenv("MCP_GATEWAY_ENABLED", "true").lower() != "true": + return None + + return MCPGatewayClient( + base_url=os.getenv("MCP_GATEWAY_URL", "http://localhost:8300"), + token=os.getenv("MCP_GATEWAY_TOKEN") or None, + timeout_seconds=int(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "60")), + )