diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fb9138060bbf91ba1372ce4aba6a2716d91b3e67..17326a5aa5db9152fcafb2a89ff5c014014c3c3a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,79 @@ +default: + image: python:3.12-slim + cache: + paths: + - .cache/pip + before_script: + - pip install -r "$CI_PROJECT_DIR/mcp_module/requirements.txt" + - pip install -r "$CI_PROJECT_DIR/ai_agent/requirements.txt" + - pip install mypy pytest ruff import-linter + - export PYTHONPATH="$CI_PROJECT_DIR:$PYTHONPATH" + stages: + - type + - architecture + - lint + - format + - test - build +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + SOURCE_DIRS: "mcp_module/ ai_agent/" + TEST_DIRS: "tests/" + +include: + - template: Security/SAST.gitlab-ci.yml + +sast: + stage: test + +type: + stage: type + tags: + - docker + script: + - mypy $SOURCE_DIRS + +architecture: + stage: architecture + tags: + - docker + script: + - | + if [ -f "$CI_PROJECT_DIR/.importlinter" ] || \ + [ -f "$CI_PROJECT_DIR/.importlinter.ini" ] || \ + [ -f "$CI_PROJECT_DIR/pyproject.toml" ] || \ + [ -f "$CI_PROJECT_DIR/setup.cfg" ]; then + lint-imports + else + echo "No import-linter config found; skipping architecture check." + fi + +lint: + stage: lint + tags: + - docker + script: + - ruff check $SOURCE_DIRS $TEST_DIRS + +format: + stage: format + tags: + - docker + script: + - ruff format --check $SOURCE_DIRS $TEST_DIRS + +test: + stage: test + tags: + - docker + script: + - pytest + .variables-template: tags: - - "shell" + - shell before_script: - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" - | @@ -44,4 +114,4 @@ build-mcp-module: only: - main - develop - - tags + - tags \ No newline at end of file diff --git a/ai_agent/README.md b/ai_agent/README.md index 23285d32798f974101ff9b63e113300fc127c13d..4bb1b7b6ae29a18de73b50dce2c330404f715cb5 100644 --- a/ai_agent/README.md +++ b/ai_agent/README.md @@ -36,6 +36,12 @@ MCP_SERVER_URL=http://127.0.0.1:8004/mcp # --- Groq --- GROQ_API_KEY=your_groq_api_key_here GROQ_MODEL_NAME=qwen/qwen3-32b + +# --- Optional short-term memory (Redis) --- +REDIS_URL=redis://127.0.0.1:6379/0 +MEMORY_WINDOW_TURNS=6 +MEMORY_TTL_SECONDS=3600 +MEMORY_KEY_PREFIX=ai_agent:session ``` ### Environment Variables @@ -47,6 +53,10 @@ GROQ_MODEL_NAME=qwen/qwen3-32b | `MCP_SERVER_URL` | URL of the MCP server used by sub-agent | | `GROQ_API_KEY` | API key for Groq LLM access | | `GROQ_MODEL_NAME` | Groq model identifier | +| `REDIS_URL` | Redis DSN for short-term memory (`redis://host:6379/0`) | +| `MEMORY_WINDOW_TURNS` | Number of recent user/assistant turns to retain | +| `MEMORY_TTL_SECONDS` | Session memory TTL in seconds | +| `MEMORY_KEY_PREFIX` | Prefix for Redis session keys | --- @@ -142,5 +152,22 @@ from ai_agent.routes.my_agent import router as my_agent_router app.include_router(my_agent_router) ``` ---- +## 🧠 Session Memory + +When `REDIS_URL` is configured, both Groq endpoints support short-term memory with a sliding window. + +- `POST /groq-mcp`: pass `session_id` in the JSON body. +- `GET /groq-mcp/stream`: pass `session_id` as a query parameter. + +Example: + +```json +{ + "session_id": "user-123-session-a", + "query": "What did I ask you before?" +} +``` +If `REDIS_URL` is missing, the API remains stateless. + +--- diff --git a/ai_agent/__init__.py b/ai_agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/ai_agent/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_agent/memory.py b/ai_agent/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..3a5341a81215fc93742427c1f9451f5e53824d86 --- /dev/null +++ b/ai_agent/memory.py @@ -0,0 +1,132 @@ +import hashlib +import json +import logging +import os +from collections.abc import Awaitable +from typing import Any, cast + +from redis.asyncio import Redis +from redis.asyncio import from_url as redis_from_url + +logger = logging.getLogger(__name__) + + +async def _maybe_await(value: Awaitable[Any] | Any) -> Any: + if isinstance(value, Awaitable): + return await value + return value + + +def _int_env(name: str, default: int) -> int: + raw_value = os.getenv(name) + if raw_value is None: + return default + try: + value = int(raw_value) + except ValueError: + logger.warning( + "Invalid %s value '%s'. Using default %s", name, raw_value, default + ) + return default + return max(1, value) + + +class RedisShortTermMemory: + def __init__(self) -> None: + self.redis_url = os.getenv("REDIS_URL", "").strip() + self.window_turns = _int_env("MEMORY_WINDOW_TURNS", 6) + self.ttl_seconds = _int_env("MEMORY_TTL_SECONDS", 3600) + self.key_prefix = ( + os.getenv("MEMORY_KEY_PREFIX", "ai_agent:session").strip() + or "ai_agent:session" + ) + self._client: Redis | None = None + self.enabled = bool(self.redis_url) + + async def _get_client(self) -> Redis | None: + if not self.enabled: + return None + if self._client is None: + self._client = redis_from_url( + self.redis_url, encoding="utf-8", decode_responses=True + ) + return self._client + + def _session_key(self, session_id: str) -> str: + session_hash = hashlib.sha256(session_id.encode("utf-8")).hexdigest() + return f"{self.key_prefix}:{session_hash}" + + async def get_messages(self, session_id: str | None) -> list[dict[str, str]]: + if not session_id: + return [] + client = await self._get_client() + if client is None: + return [] + + key = self._session_key(session_id) + try: + raw_messages = cast( + list[str], await _maybe_await(client.lrange(key, 0, -1)) + ) + if raw_messages: + await _maybe_await(client.expire(key, self.ttl_seconds)) + except Exception: + logger.exception("Failed reading memory key %s", key) + return [] + + parsed: list[dict[str, str]] = [] + for entry in raw_messages: + try: + value: dict[str, Any] = json.loads(entry) + except (json.JSONDecodeError, TypeError): + logger.warning("Skipping malformed session message for key %s", key) + continue + role = str(value.get("role", "")).strip() + content = str(value.get("content", "")).strip() + if not role or not content: + continue + parsed.append({"role": role, "content": content}) + return parsed + + async def augment_query(self, query: str, session_id: str | None) -> str: + messages = await self.get_messages(session_id) + if not messages: + return query + + history_lines = [ + "Use the recent conversation history below to answer consistently.", + "History:", + ] + for message in messages: + history_lines.append(f"{message['role'].upper()}: {message['content']}") + history_lines.append("") + history_lines.append(f"Current USER request: {query}") + return "\n".join(history_lines) + + async def append_turn( + self, session_id: str | None, user_query: str, assistant_response: str + ) -> None: + if not session_id: + return + client = await self._get_client() + if client is None: + return + + key = self._session_key(session_id) + entries = ( + json.dumps({"role": "user", "content": user_query}), + json.dumps({"role": "assistant", "content": assistant_response}), + ) + max_messages = self.window_turns * 2 + + try: + await _maybe_await(client.rpush(key, *entries)) + await _maybe_await(client.ltrim(key, -max_messages, -1)) + await _maybe_await(client.expire(key, self.ttl_seconds)) + except Exception: + logger.exception("Failed writing memory key %s", key) + + async def close(self) -> None: + if self._client is not None: + await self._client.close() + self._client = None diff --git a/ai_agent/prompts/__init__.py b/ai_agent/prompts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d9084f29746c54a3701176cb8d66f37d9ef77014 --- /dev/null +++ b/ai_agent/prompts/__init__.py @@ -0,0 +1,7 @@ +from ai_agent.prompts.memory import CONVERSATION_MEMORY_PROMPT +from ai_agent.prompts.tool_agent import TOOL_USING_ASSISTANT_PROMPT + +__all__ = [ + "CONVERSATION_MEMORY_PROMPT", + "TOOL_USING_ASSISTANT_PROMPT", +] diff --git a/ai_agent/prompts/memory.py b/ai_agent/prompts/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..911e7e5534d615d2e5e3b3a09a2d18e30697c98c --- /dev/null +++ b/ai_agent/prompts/memory.py @@ -0,0 +1,5 @@ +CONVERSATION_MEMORY_PROMPT = """ +You are an intelligent assistant that uses available tools to respond to user requests. +1. Identify the appropriate tool. +2. Summarize the tool's result and be as concise as possible with the less +""" diff --git a/ai_agent/prompts/tool_agent.py b/ai_agent/prompts/tool_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..a09fd393cfb0e3e3f3acf2e4f55d4a17acd6b830 --- /dev/null +++ b/ai_agent/prompts/tool_agent.py @@ -0,0 +1,6 @@ +TOOL_USING_ASSISTANT_PROMPT = """ +You are an intelligent assistant that uses available tools to respond to user requests. +1. Identify the appropriate tool. +2. Summarize the tool's result naturally. +3. Offer helpful next steps. +""" diff --git a/ai_agent/requirements.txt b/ai_agent/requirements.txt index df48aa5507e0a5540c4864c1b96a44a46c3e2e4c..ba78b3bd0634463ae46a17ec88649ca4dc5a2193 100644 --- a/ai_agent/requirements.txt +++ b/ai_agent/requirements.txt @@ -4,3 +4,4 @@ objgraph==3.6.2 python-dotenv==1.2.1 fastapi==0.118.0 uvicorn==0.37.0 +redis==5.2.1 diff --git a/ai_agent/routes/__init__.py b/ai_agent/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/ai_agent/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_agent/routes/debug.py b/ai_agent/routes/debug.py index cfd3bb7982a155c7d1263b8b3b5aadc5f930ff3c..a1e8446ce045e4269fbe3d0ac256ead239ce3c83 100644 --- a/ai_agent/routes/debug.py +++ b/ai_agent/routes/debug.py @@ -1,6 +1,6 @@ import io import sys -import objgraph +import objgraph # type: ignore[import-untyped] from fastapi import APIRouter from fastapi.responses import PlainTextResponse diff --git a/ai_agent/routes/groq.py b/ai_agent/routes/groq.py index 328bc520137d5af1274a84e9fec918bfbb2c8c2e..c5dba17d01143f021ad4ad98f6bdb766cf249d36 100644 --- a/ai_agent/routes/groq.py +++ b/ai_agent/routes/groq.py @@ -2,23 +2,31 @@ import logging from fastapi import APIRouter, Body, Query from fastapi.responses import JSONResponse, StreamingResponse +from ai_agent.memory import RedisShortTermMemory from ai_agent.sub_agents.groq_agent import create_groq_agent from ai_agent.utils import stream_agent_response router = APIRouter() +memory_store = RedisShortTermMemory() @router.post("/groq-mcp") async def groq_query(payload: dict | None = Body(default=None)): query = payload.get("query") if payload else None + session_id = payload.get("session_id") if payload else None if not query: return JSONResponse(status_code=400, content={"error": "No query provided"}) agent = await create_groq_agent() try: - result = await agent.run(query) - return {"response": result} + effective_query = await memory_store.augment_query(query, session_id) + result = await agent.run(effective_query) + await memory_store.append_turn(session_id, query, result) + response = {"response": result} + if session_id: + response["session_id"] = session_id + return response except Exception as e: logging.error("Error in groq_query", exc_info=True) return JSONResponse(status_code=500, content={"error": str(e)}) @@ -27,14 +35,24 @@ async def groq_query(payload: dict | None = Body(default=None)): @router.get("/groq-mcp/stream") -async def groq_stream(query: str | None = Query(default=None)): +async def groq_stream( + query: str | None = Query(default=None), + session_id: str | None = Query(default=None), +): if not query: return JSONResponse(status_code=400, content={"error": "No query provided"}) agent = await create_groq_agent() try: - generator = await stream_agent_response(agent, query) + effective_query = await memory_store.augment_query(query, session_id) + + async def on_complete(result: str): + await memory_store.append_turn(session_id, query, result) + + generator = await stream_agent_response( + agent, effective_query, on_complete=on_complete + ) return StreamingResponse( generator(), media_type="text/event-stream", diff --git a/ai_agent/sub_agents/__init__.py b/ai_agent/sub_agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/ai_agent/sub_agents/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_agent/sub_agents/groq_agent.py b/ai_agent/sub_agents/groq_agent.py index 4934012463a419916092fb8c47d9a7fbb145fcac..d987046ce95b9f83792fe922d43ff3572d478387 100644 --- a/ai_agent/sub_agents/groq_agent.py +++ b/ai_agent/sub_agents/groq_agent.py @@ -1,20 +1,23 @@ import os import logging from dotenv import load_dotenv +from ai_agent.prompts import CONVERSATION_MEMORY_PROMPT from langchain_groq import ChatGroq -from mcp_use import MCPAgent, MCPClient +from mcp_use import MCPAgent, MCPClient # type: ignore[import-untyped] load_dotenv() logging.getLogger("mcp_use").setLevel(logging.ERROR) logger = logging.getLogger(__name__) + def require_env(name: str) -> str: value = os.getenv(name) if value is None or not value.strip(): raise EnvironmentError(f"{name} environment variable is not set") return value.strip() + async def create_groq_agent(): """Create an MCPAgent using Groq backend.""" try: @@ -24,13 +27,18 @@ async def create_groq_agent(): logger.error("Groq agent configuration error: %s", e) raise - model_name = os.getenv("GROQ_MODEL_NAME", "openai/gpt-oss-20b").strip() or "openai/gpt-oss-20b" + model_name = ( + os.getenv("GROQ_MODEL_NAME", "openai/gpt-oss-20b").strip() + or "openai/gpt-oss-20b" + ) - client = MCPClient({ - "mcpServers": {"http": {"url": mcp_server_url}}, - "use-oauth2": False, - "use-oidc": False - }) + client = MCPClient( + { + "mcpServers": {"http": {"url": mcp_server_url}}, + "use-oauth2": False, + "use-oidc": False, + } + ) await client.create_session("http") llm = ChatGroq( @@ -43,17 +51,10 @@ async def create_groq_agent(): api_key=groq_api_key, ) - PROMPT = """ - You are an intelligent assistant that uses available tools to respond to user requests. - 1. Identify the appropriate tool. - 2. Summarize the tool's result naturally. - 3. Offer helpful next steps. - """ - agent = MCPAgent( client=client, - max_steps=10, - system_prompt=PROMPT, + max_steps=20, + system_prompt=CONVERSATION_MEMORY_PROMPT, llm=llm, verbose=True, ) diff --git a/ai_agent/utils.py b/ai_agent/utils.py index 81cc300cf4cf54b6f420eccecf42b5af5779fbc9..1b584a594b36679feccbbc1fbe0a536cfc830cfb 100644 --- a/ai_agent/utils.py +++ b/ai_agent/utils.py @@ -1,6 +1,8 @@ import asyncio import json import logging +from collections.abc import Awaitable, Callable + # --- SSE Helper --- def sse_event(data, event="message", id=None, retry=None): @@ -24,14 +26,23 @@ def sse_event(data, event="message", id=None, retry=None): # --- Common SSE Stream Helper --- -async def stream_agent_response(agent, query): +async def stream_agent_response( + agent, + query, + on_complete: Callable[[str], Awaitable[None] | None] | None = None, +): chunk_size = 50 + async def generator(): event_id = 0 try: result = await agent.run(query) + if on_complete is not None: + maybe_awaitable = on_complete(result) + if maybe_awaitable is not None: + await maybe_awaitable for i in range(0, len(result), chunk_size): - yield sse_event({"chunk": result[i:i + chunk_size]}, id=event_id) + yield sse_event({"chunk": result[i : i + chunk_size]}, id=event_id) event_id += 1 await asyncio.sleep(0.01) yield sse_event({"done": True}, event="complete", id=event_id) @@ -40,4 +51,5 @@ async def stream_agent_response(agent, query): finally: await agent.close() logging.info("Agent closed after streaming") + return generator diff --git a/deployment/docker/docker-compose.yaml b/deployment/docker/docker-compose.yaml index 0abbb0e0bf7d9e1c09189e246b2bbde886efb468..71388c13d8a02e0c02973e8873bd40796a3969f5 100644 --- a/deployment/docker/docker-compose.yaml +++ b/deployment/docker/docker-compose.yaml @@ -1,6 +1,14 @@ version: "3.9" services: + redis: + image: redis:7-alpine + container_name: ai-redis + ports: + - "6385:6379" + networks: + - ai_net + mcp: image: labs.etsi.org:5050/oop/code/ai2/mcp_module container_name: mcp @@ -21,12 +29,17 @@ services: image: labs.etsi.org:5050/oop/code/ai2/ai_agent container_name: ai-agent depends_on: + redis: + condition: service_started mcp: condition: service_healthy environment: MCP_SERVER_URL: http://mcp:8004/mcp GROQ_API_KEY: ${GROQ_API_KEY} GROQ_MODEL_NAME: ${GROQ_MODEL_NAME:-qwen/qwen3-32b} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + MEMORY_WINDOW_TURNS: ${MEMORY_WINDOW_TURNS:-10} + MEMORY_TTL_SECONDS: ${MEMORY_TTL_SECONDS:-3600} ports: - "9013:9013" networks: @@ -34,4 +47,5 @@ services: networks: ai_net: + name: ai_net driver: bridge diff --git a/mcp_module/README.md b/mcp_module/README.md index 4062e4ef447afe5c90569a9055236ebcd6237436..0f5a40b18c9c4bedf389aceedfcc1f93a6b42b3e 100644 --- a/mcp_module/README.md +++ b/mcp_module/README.md @@ -1,11 +1,11 @@ -# MCP module +# MCP module -## Overview +## Overview This project implements an MCP server that exposes **CAMARA-compliant APIs** through the [`FastMCP`](https://pypi.org/project/fastmcp/) framework integrated with [`FastAPI`](https://fastapi.tiangolo.com/), allowing integration with AI agents and enabled agentic workflows. -The MCP server is mounted on FastAPI at `/mcp` endpoint, allowing you to serve both MCP tools and standard REST API endpoints from the same application. +The MCP server is mounted on FastAPI, with a standard REST health endpoint at `/health`. --- @@ -14,21 +14,17 @@ The MCP server is mounted on FastAPI at `/mcp` endpoint, allowing you to serve b ``` mcp_module/ -├── dummy_backend/ # This repository contains a dummy backend implementation of CAMARA API endpoints -├── json_templates/ # CAMARA API request/response templates (interpolated by user input) +├── json_templates/ # Request templates interpolated by MCP tools ├── models/ # Pydantic models for CAMARA API compliance or custom types ├── tools/ # MCP tools registered as callable endpoints -│ ├── qod.py # Quality of Demand (QoD) related tools leveraging OOP OEG -│ └── edge_application.py # Discovery of edge applications leveraging OOP OEG -│ +├── security/ # Security-related module placeholders ├── app.py ├── requirements.txt └── Dockerfile ``` --- -* **`json_templates/`**: Contains CAMARA-compatible JSON structures that act as request/response templates. - These are dynamically **interpolated** using user input before being sent to CAMARA endpoint. +* **`json_templates/`**: Contains JSON request templates interpolated with tool input before being sent to the OEG endpoint. * **`models/`**: Contains **Pydantic models** that mirror CAMARA API specifications from [CAMARA API to MCP Mapping](https://lf-camaraproject.atlassian.net/wiki/spaces/CAM/pages/222691579/CAMARA+API+to+MCP+Tool+Mapping). @@ -37,12 +33,19 @@ mcp_module/ * **`tools/`** Implementation of MCP tools — each file should correspond to the equivalent CRUD operations for the relevant REST API. * All MCP tools are explicitly registered in `app.py` for safety and traceability: -| Tool | File | Description | -| -------------------------- | --------------------------- |------------------------------------------| -| `create_qod_session_oai` | `tools/qod.py` | Creates a CAMARA QoD session | -| `get_qod_session_oai` | `tools/qod.py` | Retrieves QoD session details | -| `delete_qod_session_oai` | `tools/qod.py` | Deletes an existing QoD session | -| `get_app_definitions` | `tools/edge_application.py` | Retrieves available edge app definitions | +| Tool | File | Description | +| --- | --- | --- | +| `create_qod_session_oai` | `tools/qod.py` | Creates a CAMARA QoD session | +| `get_qod_session_oai` | `tools/qod.py` | Retrieves QoD session details | +| `delete_qod_session_oai` | `tools/qod.py` | Deletes an existing QoD session | +| `get_app_definitions` | `tools/edge_application.py` | Lists available edge app definitions | +| `register_app_definition` | `tools/edge_application.py` | Registers an app definition from package type, image path, and port | +| `get_app_definition` | `tools/edge_application.py` | Retrieves one app definition by id | +| `delete_app_definition` | `tools/edge_application.py` | Deletes one app definition by id | +| `get_edge_cloud_zones` | `tools/edge_application.py` | Lists edge cloud zones | +| `instantiate_app` | `tools/edge_application.py` | Instantiates an app to one or more edge cloud zones | +| `get_app_instances` | `tools/edge_application.py` | Lists instantiated/deployed applications | +| `delete_app_instance` | `tools/edge_application.py` | Deletes one app instance by id | --- @@ -56,11 +59,14 @@ cd mcp_module python3 -m venv venv source venv/bin/activate # (on Linux/macOS) venv\Scripts\activate # (on Windows) pip install -r requirements.txt +``` Create a `.env` file in the project root (if not already present) and set your API configuration: +``` OEG_SERVICE_URL=http://your-oeg-host/oeg/1.0.0 - +MCP_HOST=127.0.0.1 +MCP_PORT=8004 ``` @@ -84,7 +90,7 @@ Run the application with: python app.py ``` -By default, it runs on `0.0.0.0:8004`. +By default, it runs on `127.0.0.1:8004` unless overridden by `MCP_HOST` and `MCP_PORT`. ### Option 2 - Containerized Run the container: @@ -97,18 +103,27 @@ docker run -p 8004:8004 -e OEG_SERVICE_URL=http://your-oeg-host/oeg/1.0.0 mcp_mo ## 🧩 Integration with 3rd party apps Open-WebUI 0.6.34 - Python 3.11.x +## Edge App Notes + +`register_app_definition` derives `name` and `componentName` from the final image path segment before `:` and appends a short random suffix, so repeated registrations of the same image can coexist. + +Request templates used by edge app tools: + +| Template | Used by | +| --- | --- | +| `json_templates/app_register_app_definition_request.json` | `register_app_definition` | +| `json_templates/app_instantiate_request.json` | `instantiate_app` | + ## 🧩 Extending the Project To add a new tool: 1. Define Pydantic models for request, response data schema validation. 2. Define the tool in `tools/your_tool.py`. -3. Registers a tool in `app.py`: +3. Register the tool in `app.py`. 4. Add corresponding models and JSON templates if needed. --- ## Roadmap Extend the MCP server with relevant prompts, tool call(s), and more comprehensive parameter assertions. - - diff --git a/mcp_module/__init__.py b/mcp_module/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/mcp_module/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/app.py b/mcp_module/app.py index 3ed690be80ab1656232670bf9d096249d45ba2ee..6c3b97fb09aba9f8b079d8bc02151cda8c8de3b7 100644 --- a/mcp_module/app.py +++ b/mcp_module/app.py @@ -1,14 +1,32 @@ import logging +import os +from collections.abc import Callable +from typing import Any + +import uvicorn from fastmcp import FastMCP from fastapi import FastAPI from fastapi.responses import PlainTextResponse -from mcp_module.tools.qod import create_qod_session_oai, get_qod_session_oai, delete_qod_session_oai -from mcp_module.tools.edge_application import get_app_definitions from dotenv import load_dotenv -import os -import uvicorn +from mcp_module.tools.edge_application import ( + delete_app_definition, + delete_app_instance, + get_edge_cloud_zones, + get_app_definitions, + get_app_definition, + get_app_instances, + instantiate_app, + register_app_definition, +) +from mcp_module.tools.qod import ( + create_qod_session_oai, + get_qod_session_oai, + delete_qod_session_oai, +) -logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s") +logging.basicConfig( + level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s" +) logger = logging.getLogger("MCP server") load_dotenv() @@ -17,11 +35,18 @@ load_dotenv() mcp = FastMCP("MCP Server") # Register tools - only audited functions are exposed -SAFE_TOOLS = [ +SAFE_TOOLS: list[Callable[..., Any]] = [ create_qod_session_oai, get_qod_session_oai, delete_qod_session_oai, get_app_definitions, + register_app_definition, + delete_app_definition, + get_app_definition, + get_edge_cloud_zones, + instantiate_app, + get_app_instances, + delete_app_instance, ] for tool in SAFE_TOOLS: @@ -36,16 +61,17 @@ app = FastAPI( redirect_slashes=False, ) + # Health check endpoint @app.get("/health", response_class=PlainTextResponse) async def health_check(): return "healthy" + # Mount MCP server app.mount("", mcp_app) if __name__ == "__main__": - logger.info("Starting MCP Server with FastAPI...") host = os.getenv("MCP_HOST", "127.0.0.1") port = int(os.getenv("MCP_PORT", "8004")) diff --git a/mcp_module/json_templates/app_instantiate_request.json b/mcp_module/json_templates/app_instantiate_request.json new file mode 100644 index 0000000000000000000000000000000000000000..53c5e9f643cf7f6edbe93d0fa62caa69c4ac1493 --- /dev/null +++ b/mcp_module/json_templates/app_instantiate_request.json @@ -0,0 +1,14 @@ +{ + "appId": "", + "appZones": [ + { + "EdgeCloudZone": { + "edgeCloudZoneId": "", + "edgeCloudZoneName": "string", + "edgeCloudZoneStatus": "unknown", + "edgeCloudProvider": "string", + "edgeCloudRegion": "string" + } + } + ] +} diff --git a/mcp_module/json_templates/app_register_app_definition_request.json b/mcp_module/json_templates/app_register_app_definition_request.json new file mode 100644 index 0000000000000000000000000000000000000000..e217d5907815df98c507e2f76fb0f56049e4364e --- /dev/null +++ b/mcp_module/json_templates/app_register_app_definition_request.json @@ -0,0 +1,22 @@ +{ + "name": "", + "version": "1.0.0", + "packageType": "", + "appRepo": { + "type": "PRIVATEREPO", + "imagePath": "" + }, + "componentSpec": [ + { + "componentName": "", + "networkInterfaces": [ + { + "interfaceId": "eth0", + "protocol": "TCP", + "port": 0, + "visibilityType": "VISIBILITY_EXTERNAL" + } + ] + } + ] +} diff --git a/mcp_module/models/__init__.py b/mcp_module/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/mcp_module/models/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/models/edge_application_models.py b/mcp_module/models/edge_application_models.py new file mode 100644 index 0000000000000000000000000000000000000000..ef4937d70a9f145ff48d3c91c8d90f3be16e71b7 --- /dev/null +++ b/mcp_module/models/edge_application_models.py @@ -0,0 +1,52 @@ +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + + +class RegisterAppDefinitionInput(BaseModel): + packageType: Literal["HELM", "CONTAINER"] = Field( + ..., description="Allowed package types: HELM or CONTAINER" + ) + imagePath: str = Field(..., min_length=1) + port: int = Field(..., ge=1, le=65535) + + @field_validator("packageType", mode="before") + @classmethod + def normalize_package_type(cls, value: str) -> str: + normalized = str(value).strip().upper().replace("-", "_").replace(" ", "_") + if normalized in {"HELM", "HELMCHART", "HELM_CHART"}: + return "HELM" + if normalized == "CONTAINER": + return "CONTAINER" + return normalized + + +class DeleteAppDefinitionInput(BaseModel): + sessionId: str = Field(..., min_length=1) + + +class GetAppDefinitionInput(BaseModel): + sessionId: str = Field(..., min_length=1) + + +class InstantiateAppInput(BaseModel): + appId: str = Field(..., min_length=1) + edgeCloudZoneIds: list[str] = Field(..., min_length=1) + + @field_validator("edgeCloudZoneIds", mode="before") + @classmethod + def normalize_edge_cloud_zone_ids(cls, value): + if isinstance(value, str): + return [value] + return value + + @field_validator("edgeCloudZoneIds") + @classmethod + def validate_edge_cloud_zone_ids(cls, value: list[str]) -> list[str]: + if any(not zone_id.strip() for zone_id in value): + raise ValueError("edgeCloudZoneIds must not contain empty values") + return value + + +class DeleteAppInstanceInput(BaseModel): + appInstanceId: str = Field(..., min_length=1) diff --git a/mcp_module/models/qod_models.py b/mcp_module/models/qod_models.py index f4e64eddf93861f5c03b9077ee797e99628f98ba..f9805cc234add3dd32805ba072a57f66c1239399 100644 --- a/mcp_module/models/qod_models.py +++ b/mcp_module/models/qod_models.py @@ -9,20 +9,26 @@ class IPv4Address(BaseModel): publicAddress: str publicPort: int -E164_REGEX = re.compile(r'^\+[1-9]\d{1,14}$') + +E164_REGEX = re.compile(r"^\+[1-9]\d{1,14}$") + class DeviceInput(BaseModel): phoneNumber: Optional[str] = None networkAccessIdentifier: Optional[str] = None ipv4Address: Optional[IPv4Address] = None ipv6Address: Optional[IPv6Address] = None - duration: Optional[int] = Field(None, ge=60, le=86400, description="Duration in seconds (60-86400)") + duration: Optional[int] = Field( + None, ge=60, le=86400, description="Duration in seconds (60-86400)" + ) @field_validator("phoneNumber") @classmethod def validate_phone_number(cls, v): if v is not None and not E164_REGEX.match(v): - raise ValueError("phoneNumber must conform to E.164 format (e.g., +1234567890)") + raise ValueError( + "phoneNumber must conform to E.164 format (e.g., +1234567890)" + ) return v @model_validator(mode="before") @@ -39,6 +45,7 @@ class DeviceInput(BaseModel): ) return values + class CreateQoDSessionInput(BaseModel): device: DeviceInput qosProfile: str @@ -49,16 +56,21 @@ class CreateQoDSessionOAIInput(BaseModel): devicePublicAddress: str applicationServerIpv4Address: str qosProfile: str - duration: int = Field(..., ge=60, le=86400, description="Duration in seconds (60-86400)") + duration: int = Field( + ..., ge=60, le=86400, description="Duration in seconds (60-86400)" + ) + # ----- Output schema ----- class QoDSessionMinimalResponse(BaseModel): sessionId: str qosStatus: str + class GetQoDSessionInput(BaseModel): sessionId: str + class QoDSessionFullResponse(BaseModel): sessionId: str device: Dict[str, Any] @@ -73,9 +85,11 @@ class QoDSessionFullResponse(BaseModel): startedAt: Optional[str] = None expiresAt: Optional[str] = None + class QoDSessionsList(BaseModel): device: DeviceInput + class QoDSessionListResponse(BaseModel): applicationServer: Dict[str, Any] device: Dict[str, Any] diff --git a/mcp_module/security/__init__.py b/mcp_module/security/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/mcp_module/security/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/tools/__init__.py b/mcp_module/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/mcp_module/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/tools/edge_application.py b/mcp_module/tools/edge_application.py index 8020ec8663af263a0aa1072d993c2394c3a92272..375dbafbe4ae403dda01c898c775384713805133 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -1,10 +1,25 @@ import logging +import json +from copy import deepcopy +from uuid import uuid4 import httpx from dotenv import load_dotenv from pathlib import Path import os +from urllib.parse import urlparse + +from mcp_module.models.edge_application_models import ( + DeleteAppDefinitionInput, + DeleteAppInstanceInput, + GetAppDefinitionInput, + InstantiateAppInput, + RegisterAppDefinitionInput, +) + # Configure logging -logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s") +logging.basicConfig( + level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s" +) logger = logging.getLogger("MCP server") # Calculate the path to the root .env file @@ -13,22 +28,41 @@ env_path = root_dir / ".env" # Load the .env file load_dotenv(dotenv_path=env_path) -SESSION_SERVICE_URL = os.getenv("OEG_SERVICE_URL") -if not SESSION_SERVICE_URL: - raise EnvironmentError("OEG_SERVICE_URL environment variable is not set") + + +def get_session_service_url() -> str: + session_service_url = os.getenv("OEG_SERVICE_URL") + if not session_service_url: + raise EnvironmentError("OEG_SERVICE_URL environment variable is not set") + return session_service_url + + +def _derive_app_name(image_path: str) -> str: + parsed = urlparse(image_path) + image_ref = parsed.path if parsed.scheme and parsed.netloc else image_path + last_segment = image_ref.rstrip("/").split("/")[-1] + name_without_digest = last_segment.split("@", 1)[0] + base_name = name_without_digest.split(":", 1)[0] + if not base_name: + raise ValueError("imagePath must include an app name in the final URL segment") + unique_id = uuid4().hex[:8] + return f"{base_name}-{unique_id}" + async def get_app_definitions() -> dict: """ Get the list of all existing Application definitions from the Edge Cloud Provider that the user has permission to view. """ - url = f"{SESSION_SERVICE_URL}/apps" + url = f"{get_session_service_url()}/apps" headers = {"accept": "application/json"} logger.debug("Fetching edge application definitions from: %s", url) try: async with httpx.AsyncClient(trust_env=True) as client: - response = await client.get(url, headers=headers, follow_redirects=True, timeout=15) + response = await client.get( + url, headers=headers, follow_redirects=True, timeout=15 + ) response.raise_for_status() except httpx.HTTPError as e: logger.error("Failed to fetch edge application definitions: %s", e) @@ -44,7 +78,9 @@ async def get_app_definitions() -> dict: # Ensure the result is a dict for FastMCP compatibility if isinstance(data, list): - logger.info("Edge application definitions returned as list; wrapping for FastMCP") + logger.info( + "Edge application definitions returned as list; wrapping for FastMCP" + ) return {"applications": data} if not isinstance(data, dict): logger.warning( @@ -53,3 +89,241 @@ async def get_app_definitions() -> dict: ) return {} return data + + +async def register_app_definition(inp: RegisterAppDefinitionInput) -> dict: + """ + Register an application definition from an image/chart URL and exposed port. + + Inputs: + - packageType: HELM_CHART or CONTAINER + - imagePath: image or Helm chart URL + - port: exposed TCP port + """ + template_path = ( + root_dir / "json_templates" / "app_register_app_definition_request.json" + ) + if not template_path.exists(): + raise FileNotFoundError(f"{template_path} not found") + + with open(template_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + app_name = _derive_app_name(inp.imagePath) + payload["name"] = app_name + payload["packageType"] = inp.packageType + payload["appRepo"]["imagePath"] = inp.imagePath + payload["componentSpec"][0]["componentName"] = app_name + payload["componentSpec"][0]["networkInterfaces"][0]["port"] = inp.port + + url = f"{get_session_service_url()}/apps" + headers = {"accept": "application/json", "content-type": "application/json"} + logger.debug("Register app definition payload: %s", json.dumps(payload, indent=2)) + logger.debug("Calling endpoint: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.post( + url, headers=headers, json=payload, follow_redirects=True, timeout=15 + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to register app definition: %s", e) + raise + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"response": data} + + +async def delete_app_definition(inp: DeleteAppDefinitionInput) -> dict: + """ + Delete all the information and content related to an application definition id. If there are multiple ones, invoke the tool multiple times. + """ + url = f"{get_session_service_url()}/apps/{inp.sessionId}" + logger.debug("Deleting app definition from: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.delete(url, follow_redirects=True, timeout=15) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to delete app definition %s: %s", inp.sessionId, e) + raise + + if not response.content: + return {"status_code": response.status_code} + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"response": data} + + +async def get_app_definition(inp: GetAppDefinitionInput) -> dict: + """ + Retrieve the Application definition for a specific session id. + """ + url = f"{get_session_service_url()}/apps/{inp.sessionId}" + headers = {"accept": "application/json"} + logger.debug("Fetching app definition from: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.get( + url, headers=headers, follow_redirects=True, timeout=15 + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to fetch app definition %s: %s", inp.sessionId, e) + raise + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"response": data} + + +async def get_edge_cloud_zones() -> dict: + """ + Retrieve edge cloud zones + """ + url = f"{get_session_service_url()}/edge-cloud-zones" + headers = {"accept": "application/json"} + logger.debug("Fetching edge cloud zones from: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.get( + url, + headers=headers, + follow_redirects=True, + timeout=15, + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to fetch edge cloud zones: %s", e) + raise + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"edgeCloudZones": data} + + +async def instantiate_app(inp: InstantiateAppInput) -> dict: + """ + Instantiate or deploy an application to one or several Edge Cloud Zones. + """ + template_path = root_dir / "json_templates" / "app_instantiate_request.json" + if not template_path.exists(): + raise FileNotFoundError(f"{template_path} not found") + + with open(template_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + zone_template = payload["appZones"][0] + payload["appId"] = inp.appId + payload["appZones"] = [] + + for edge_cloud_zone_id in inp.edgeCloudZoneIds: + app_zone = deepcopy(zone_template) + app_zone["EdgeCloudZone"]["edgeCloudZoneId"] = edge_cloud_zone_id + payload["appZones"].append(app_zone) + + url = f"{get_session_service_url()}/appinstances" + headers = {"accept": "application/json", "content-type": "application/json"} + logger.debug("Instantiate app payload: %s", json.dumps(payload, indent=2)) + logger.debug("Calling endpoint: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.post( + url, headers=headers, json=payload, follow_redirects=True, timeout=15 + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to instantiate app %s: %s", inp.appId, e) + raise + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"response": data} + + +async def get_app_instances() -> dict: + """ + Retrieve instantiated or deployed applications information. + """ + url = f"{get_session_service_url()}/appinstances" + headers = {"accept": "application/json"} + logger.debug("Fetching app instances from: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.get( + url, headers=headers, follow_redirects=True, timeout=15 + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to fetch app instances: %s", e) + raise + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"appInstances": data} + + +async def delete_app_instance(inp: DeleteAppInstanceInput) -> dict: + """ + Delete an instantiated or deployed application by app instance id. If there are multiple ones, invoke the tool multiple times. + """ + url = f"{get_session_service_url()}/appinstances/{inp.appInstanceId}" + logger.debug("Deleting app instance from: %s", url) + + try: + async with httpx.AsyncClient(trust_env=True) as client: + response = await client.delete(url, follow_redirects=True, timeout=15) + response.raise_for_status() + except httpx.HTTPError as e: + logger.error("Failed to delete app instance %s: %s", inp.appInstanceId, e) + raise + + if not response.content: + return {"status_code": response.status_code} + + try: + data = response.json() + except ValueError: + return {"status_code": response.status_code, "body": response.text} + + if isinstance(data, dict): + return data + return {"response": data} diff --git a/mcp_module/tools/qod.py b/mcp_module/tools/qod.py index b1c754d0fbd2538fcb63bd8d7de859d39f2efbaa..88724368932245cc3b851f40ff1ce7378456bbae 100644 --- a/mcp_module/tools/qod.py +++ b/mcp_module/tools/qod.py @@ -2,22 +2,31 @@ import json import logging import os from pathlib import Path +from typing import Any, List import httpx from dotenv import load_dotenv -from mcp_module.models.qod_models import * -from typing import Any, List - -logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s") +from mcp_module.models.qod_models import ( + CreateQoDSessionOAIInput, + GetQoDSessionInput, + QoDSessionFullResponse, +) + +logging.basicConfig( + level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s" +) logger = logging.getLogger("MCP server") root_dir = Path(__file__).resolve().parent.parent env_path = root_dir / ".env" load_dotenv(dotenv_path=env_path) -SESSION_SERVICE_URL = os.getenv("OEG_SERVICE_URL") -if not SESSION_SERVICE_URL: - raise EnvironmentError("OEG_SERVICE_URL environment variable is not set") + +def get_session_service_url() -> str: + session_service_url = os.getenv("OEG_SERVICE_URL") + if not session_service_url: + raise EnvironmentError("OEG_SERVICE_URL environment variable is not set") + return session_service_url async def create_qod_session_oai(inp: CreateQoDSessionOAIInput) -> dict: @@ -44,7 +53,7 @@ async def create_qod_session_oai(inp: CreateQoDSessionOAIInput) -> dict: payload["qosProfile"] = inp.qosProfile payload["duration"] = inp.duration - session_url = f"{SESSION_SERVICE_URL}/sessions" + session_url = f"{get_session_service_url()}/sessions" logger.debug("Create QoD session payload: %s", json.dumps(payload, indent=2)) logger.debug("Calling endpoint: %s", session_url) @@ -67,7 +76,7 @@ async def get_qod_session_oai(inp: GetQoDSessionInput) -> QoDSessionFullResponse Retrieves QoS session based on session id """ - session_url = f"{SESSION_SERVICE_URL}/sessions/{inp.sessionId}" + session_url = f"{get_session_service_url()}/sessions/{inp.sessionId}" logger.debug("Fetching session details from: %s", session_url) try: @@ -94,12 +103,15 @@ async def get_qod_session_oai(inp: GetQoDSessionInput) -> QoDSessionFullResponse duration=resp_json.get("duration"), qosStatus=resp_json.get("qosStatus"), startedAt=resp_json.get("startedAt"), - expiresAt=resp_json.get("expiresAt") + expiresAt=resp_json.get("expiresAt"), ) async def delete_qod_session_oai(inp: GetQoDSessionInput) -> List[Any]: - session_url = f"{SESSION_SERVICE_URL}/sessions/{inp.sessionId}" + """ + Delete QoS session based on session id + """ + session_url = f"{get_session_service_url()}/sessions/{inp.sessionId}" logger.debug("Deleting session at: %s", session_url) async with httpx.AsyncClient(trust_env=True) as client: @@ -122,3 +134,4 @@ async def delete_qod_session_oai(inp: GetQoDSessionInput) -> List[Any]: response.text, ) response.raise_for_status() + return [] diff --git a/tests/ai_agent/test_error_codes.py b/tests/ai_agent/test_error_codes.py index 2401938394564413300942257806c330720d5e6f..1ed8b5a5960afb5f860de8c0ed3b6def8fcf02f4 100644 --- a/tests/ai_agent/test_error_codes.py +++ b/tests/ai_agent/test_error_codes.py @@ -11,7 +11,9 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -spec = importlib.util.spec_from_file_location("test_ai_agent_app", REPO_ROOT / "ai_agent" / "app.py") +spec = importlib.util.spec_from_file_location( + "test_ai_agent_app", REPO_ROOT / "ai_agent" / "app.py" +) ai_agent_app = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(ai_agent_app) @@ -43,7 +45,9 @@ def test_groq_query_agent_failure_returns_500() -> None: agent = AsyncMock() agent.run.side_effect = RuntimeError("backend failure") - with patch("ai_agent.routes.groq.create_groq_agent", new=AsyncMock(return_value=agent)): + with patch( + "ai_agent.routes.groq.create_groq_agent", new=AsyncMock(return_value=agent) + ): response = await _make_request("POST", "/groq-mcp", json={"query": "hello"}) agent.close.assert_awaited_once() diff --git a/tests/mcp_module/test_error_codes.py b/tests/mcp_module/test_error_codes.py index ee7dbb8032203e467eb9d7de7cac13604c4db430..2d62a7d0cacf107dfe4e3107ca1ceb16f2bc1c16 100644 --- a/tests/mcp_module/test_error_codes.py +++ b/tests/mcp_module/test_error_codes.py @@ -10,7 +10,9 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -spec = importlib.util.spec_from_file_location("test_mcp_module_app", REPO_ROOT / "mcp_module" / "app.py") +spec = importlib.util.spec_from_file_location( + "test_mcp_module_app", REPO_ROOT / "mcp_module" / "app.py" +) mcp_module_app = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(mcp_module_app)