From ba45cd9c314c042d6f44177aab12b4032f598f24 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Wed, 18 Mar 2026 14:16:20 +0200 Subject: [PATCH 01/17] feature: agent memory, #6 --- ai_agent/README.md | 29 ++++++- ai_agent/memory.py | 114 ++++++++++++++++++++++++++ ai_agent/requirements.txt | 1 + ai_agent/routes/groq.py | 21 ++++- ai_agent/utils.py | 12 ++- deployment/docker/docker-compose.yaml | 13 +++ 6 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 ai_agent/memory.py diff --git a/ai_agent/README.md b/ai_agent/README.md index 23285d3..4bb1b7b 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/memory.py b/ai_agent/memory.py new file mode 100644 index 0000000..39e5f3b --- /dev/null +++ b/ai_agent/memory.py @@ -0,0 +1,114 @@ +import hashlib +import json +import logging +import os +from typing import Any + +from redis.asyncio import Redis +from redis.asyncio import from_url as redis_from_url + +logger = logging.getLogger(__name__) + + +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 = await client.lrange(key, 0, -1) + if raw_messages: + 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 client.rpush(key, *entries) + await client.ltrim(key, -max_messages, -1) + 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/requirements.txt b/ai_agent/requirements.txt index df48aa5..ba78b3b 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/groq.py b/ai_agent/routes/groq.py index 328bc52..f3271fd 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,19 @@ 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/utils.py b/ai_agent/utils.py index 81cc300..2135c35 100644 --- a/ai_agent/utils.py +++ b/ai_agent/utils.py @@ -1,6 +1,7 @@ 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,12 +25,21 @@ 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) event_id += 1 diff --git a/deployment/docker/docker-compose.yaml b/deployment/docker/docker-compose.yaml index 0abbb0e..d0b916d 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:-6} + MEMORY_TTL_SECONDS: ${MEMORY_TTL_SECONDS:-3600} ports: - "9013:9013" networks: -- GitLab From 5d00e0b15e3d04d9f0bae6002c11196532a498c9 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Thu, 16 Apr 2026 12:44:52 +0300 Subject: [PATCH 02/17] fix: docker network name --- deployment/docker/docker-compose.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deployment/docker/docker-compose.yaml b/deployment/docker/docker-compose.yaml index d0b916d..71388c1 100644 --- a/deployment/docker/docker-compose.yaml +++ b/deployment/docker/docker-compose.yaml @@ -38,7 +38,7 @@ services: 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:-6} + MEMORY_WINDOW_TURNS: ${MEMORY_WINDOW_TURNS:-10} MEMORY_TTL_SECONDS: ${MEMORY_TTL_SECONDS:-3600} ports: - "9013:9013" @@ -47,4 +47,5 @@ services: networks: ai_net: + name: ai_net driver: bridge -- GitLab From ef167401f303fedc61c1ee7f0762adcadd57045e Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 20 Apr 2026 17:00:02 +0300 Subject: [PATCH 03/17] refactor: static type checking - ai agent, #8 --- ai_agent/__init__.py | 1 + ai_agent/memory.py | 19 +++++++++++++------ ai_agent/routes/__init__.py | 1 + ai_agent/routes/debug.py | 2 +- ai_agent/sub_agents/__init__.py | 1 + ai_agent/sub_agents/groq_agent.py | 2 +- 6 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 ai_agent/__init__.py create mode 100644 ai_agent/routes/__init__.py create mode 100644 ai_agent/sub_agents/__init__.py diff --git a/ai_agent/__init__.py b/ai_agent/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_agent/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_agent/memory.py b/ai_agent/memory.py index 39e5f3b..d7dc18b 100644 --- a/ai_agent/memory.py +++ b/ai_agent/memory.py @@ -2,7 +2,8 @@ import hashlib import json import logging import os -from typing import Any +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 @@ -10,6 +11,12 @@ 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: @@ -51,9 +58,9 @@ class RedisShortTermMemory: key = self._session_key(session_id) try: - raw_messages = await client.lrange(key, 0, -1) + raw_messages = cast(list[str], await _maybe_await(client.lrange(key, 0, -1))) if raw_messages: - await client.expire(key, self.ttl_seconds) + await _maybe_await(client.expire(key, self.ttl_seconds)) except Exception: logger.exception("Failed reading memory key %s", key) return [] @@ -102,9 +109,9 @@ class RedisShortTermMemory: max_messages = self.window_turns * 2 try: - await client.rpush(key, *entries) - await client.ltrim(key, -max_messages, -1) - await client.expire(key, self.ttl_seconds) + 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) diff --git a/ai_agent/routes/__init__.py b/ai_agent/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /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 cfd3bb7..a1e8446 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/sub_agents/__init__.py b/ai_agent/sub_agents/__init__.py new file mode 100644 index 0000000..8b13789 --- /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 4934012..b47c5d8 100644 --- a/ai_agent/sub_agents/groq_agent.py +++ b/ai_agent/sub_agents/groq_agent.py @@ -2,7 +2,7 @@ import os import logging from dotenv import load_dotenv 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) -- GitLab From cfca4e5826c7be0331205f72e8a464db7ac68343 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 20 Apr 2026 17:04:36 +0300 Subject: [PATCH 04/17] refactor: static type checking - mcp module, #8 --- mcp_module/__init__.py | 1 + mcp_module/app.py | 13 ++++++++----- mcp_module/models/__init__.py | 1 + mcp_module/security/__init__.py | 1 + mcp_module/tools/__init__.py | 1 + mcp_module/tools/qod.py | 3 ++- 6 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 mcp_module/__init__.py create mode 100644 mcp_module/models/__init__.py create mode 100644 mcp_module/security/__init__.py create mode 100644 mcp_module/tools/__init__.py diff --git a/mcp_module/__init__.py b/mcp_module/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mcp_module/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/app.py b/mcp_module/app.py index 3ed690b..a3e9da4 100644 --- a/mcp_module/app.py +++ b/mcp_module/app.py @@ -1,12 +1,15 @@ 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 get_app_definitions +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") logger = logging.getLogger("MCP server") @@ -17,7 +20,7 @@ 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, diff --git a/mcp_module/models/__init__.py b/mcp_module/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mcp_module/models/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/security/__init__.py b/mcp_module/security/__init__.py new file mode 100644 index 0000000..8b13789 --- /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 0000000..8b13789 --- /dev/null +++ b/mcp_module/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/mcp_module/tools/qod.py b/mcp_module/tools/qod.py index b1c754d..7202fea 100644 --- a/mcp_module/tools/qod.py +++ b/mcp_module/tools/qod.py @@ -2,11 +2,11 @@ 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") logger = logging.getLogger("MCP server") @@ -122,3 +122,4 @@ async def delete_qod_session_oai(inp: GetQoDSessionInput) -> List[Any]: response.text, ) response.raise_for_status() + return [] -- GitLab From c4e929a17f514cc4a33cf7e4280f27e7e43d78f6 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Wed, 22 Apr 2026 16:16:20 +0300 Subject: [PATCH 05/17] feat(prompts): add different types of prompts, #6 --- ai_agent/prompts/__init__.py | 7 +++++++ ai_agent/prompts/memory.py | 5 +++++ ai_agent/prompts/tool_agent.py | 6 ++++++ ai_agent/sub_agents/groq_agent.py | 10 ++-------- 4 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 ai_agent/prompts/__init__.py create mode 100644 ai_agent/prompts/memory.py create mode 100644 ai_agent/prompts/tool_agent.py diff --git a/ai_agent/prompts/__init__.py b/ai_agent/prompts/__init__.py new file mode 100644 index 0000000..d9084f2 --- /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 0000000..911e7e5 --- /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 0000000..a09fd39 --- /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/sub_agents/groq_agent.py b/ai_agent/sub_agents/groq_agent.py index b47c5d8..78b3545 100644 --- a/ai_agent/sub_agents/groq_agent.py +++ b/ai_agent/sub_agents/groq_agent.py @@ -1,6 +1,7 @@ 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 # type: ignore[import-untyped] @@ -43,17 +44,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, + system_prompt=CONVERSATION_MEMORY_PROMPT, llm=llm, verbose=True, ) -- GitLab From fe83b4a3a4be885b5c7d3f049393ae864cb55ba4 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Tue, 28 Apr 2026 13:25:30 +0300 Subject: [PATCH 06/17] refactor: linting and formatting, #8 --- ai_agent/memory.py | 21 ++++++++++++++++----- ai_agent/routes/groq.py | 9 +++++++-- ai_agent/sub_agents/groq_agent.py | 19 +++++++++++++------ ai_agent/utils.py | 4 +++- mcp_module/app.py | 13 ++++++++++--- mcp_module/models/qod_models.py | 22 ++++++++++++++++++---- mcp_module/tools/edge_application.py | 14 +++++++++++--- mcp_module/tools/qod.py | 14 ++++++++++---- 8 files changed, 88 insertions(+), 28 deletions(-) diff --git a/ai_agent/memory.py b/ai_agent/memory.py index d7dc18b..3a5341a 100644 --- a/ai_agent/memory.py +++ b/ai_agent/memory.py @@ -24,7 +24,9 @@ def _int_env(name: str, default: int) -> int: try: value = int(raw_value) except ValueError: - logger.warning("Invalid %s value '%s'. Using default %s", name, raw_value, default) + logger.warning( + "Invalid %s value '%s'. Using default %s", name, raw_value, default + ) return default return max(1, value) @@ -34,7 +36,10 @@ class RedisShortTermMemory: 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.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) @@ -42,7 +47,9 @@ class RedisShortTermMemory: 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) + 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: @@ -58,7 +65,9 @@ class RedisShortTermMemory: key = self._session_key(session_id) try: - raw_messages = cast(list[str], await _maybe_await(client.lrange(key, 0, -1))) + 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: @@ -94,7 +103,9 @@ class RedisShortTermMemory: 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: + 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() diff --git a/ai_agent/routes/groq.py b/ai_agent/routes/groq.py index f3271fd..c5dba17 100644 --- a/ai_agent/routes/groq.py +++ b/ai_agent/routes/groq.py @@ -35,7 +35,10 @@ 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), session_id: 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"}) @@ -47,7 +50,9 @@ async def groq_stream(query: str | None = Query(default=None), session_id: str | 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) + 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/groq_agent.py b/ai_agent/sub_agents/groq_agent.py index 78b3545..f6c7025 100644 --- a/ai_agent/sub_agents/groq_agent.py +++ b/ai_agent/sub_agents/groq_agent.py @@ -10,12 +10,14 @@ 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: @@ -25,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( diff --git a/ai_agent/utils.py b/ai_agent/utils.py index 2135c35..1b584a5 100644 --- a/ai_agent/utils.py +++ b/ai_agent/utils.py @@ -3,6 +3,7 @@ import json import logging from collections.abc import Awaitable, Callable + # --- SSE Helper --- def sse_event(data, event="message", id=None, retry=None): lines = [] @@ -41,7 +42,7 @@ async def stream_agent_response( 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) @@ -50,4 +51,5 @@ async def stream_agent_response( finally: await agent.close() logging.info("Agent closed after streaming") + return generator diff --git a/mcp_module/app.py b/mcp_module/app.py index a3e9da4..1ea364b 100644 --- a/mcp_module/app.py +++ b/mcp_module/app.py @@ -9,9 +9,15 @@ from fastapi import FastAPI from fastapi.responses import PlainTextResponse from dotenv import load_dotenv from mcp_module.tools.edge_application import get_app_definitions -from mcp_module.tools.qod import create_qod_session_oai, get_qod_session_oai, delete_qod_session_oai +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() @@ -39,16 +45,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/models/qod_models.py b/mcp_module/models/qod_models.py index f4e64ed..f9805cc 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/tools/edge_application.py b/mcp_module/tools/edge_application.py index 8020ec8..0661f7b 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -3,8 +3,11 @@ import httpx from dotenv import load_dotenv from pathlib import Path import os + # 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 @@ -17,6 +20,7 @@ SESSION_SERVICE_URL = os.getenv("OEG_SERVICE_URL") if not SESSION_SERVICE_URL: raise EnvironmentError("OEG_SERVICE_URL environment variable is not set") + async def get_app_definitions() -> dict: """ Get the list of all existing Application definitions from the Edge Cloud Provider @@ -28,7 +32,9 @@ async def get_app_definitions() -> dict: 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 +50,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( diff --git a/mcp_module/tools/qod.py b/mcp_module/tools/qod.py index 7202fea..407b872 100644 --- a/mcp_module/tools/qod.py +++ b/mcp_module/tools/qod.py @@ -6,9 +6,15 @@ from typing import Any, List import httpx from dotenv import load_dotenv -from mcp_module.models.qod_models import * - -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 @@ -94,7 +100,7 @@ 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"), ) -- GitLab From a248b88d92ebac133a66c4148b955d55821b81d7 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Tue, 28 Apr 2026 13:37:18 +0300 Subject: [PATCH 07/17] ci pipeline enhancement, based on issue 8 refactoring, #7 --- .gitlab-ci.yml | 68 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fb91380..136ce37 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,73 @@ +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/" + +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 +108,4 @@ build-mcp-module: only: - main - develop - - tags + - tags \ No newline at end of file -- GitLab From d0ba9249930f081f766342304c63c2aa71fa22f6 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Tue, 28 Apr 2026 13:43:42 +0300 Subject: [PATCH 08/17] refactor: formatting test cases, #8 --- tests/ai_agent/test_error_codes.py | 8 ++++++-- tests/mcp_module/test_error_codes.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/ai_agent/test_error_codes.py b/tests/ai_agent/test_error_codes.py index 2401938..1ed8b5a 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 ee7dbb8..2d62a7d 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) -- GitLab From c21535e37383d7728f683d5ec65d7a64d449153f Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Tue, 28 Apr 2026 14:13:20 +0300 Subject: [PATCH 09/17] refactor(mcp_module): change env variable import time failure to runtime --- mcp_module/tools/edge_application.py | 12 ++++++++---- mcp_module/tools/qod.py | 15 +++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/mcp_module/tools/edge_application.py b/mcp_module/tools/edge_application.py index 0661f7b..7f07639 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -16,9 +16,13 @@ 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 async def get_app_definitions() -> dict: @@ -26,7 +30,7 @@ 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) diff --git a/mcp_module/tools/qod.py b/mcp_module/tools/qod.py index 407b872..7d94965 100644 --- a/mcp_module/tools/qod.py +++ b/mcp_module/tools/qod.py @@ -21,9 +21,12 @@ 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: @@ -50,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) @@ -73,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: @@ -105,7 +108,7 @@ async def get_qod_session_oai(inp: GetQoDSessionInput) -> QoDSessionFullResponse async def delete_qod_session_oai(inp: GetQoDSessionInput) -> List[Any]: - session_url = f"{SESSION_SERVICE_URL}/sessions/{inp.sessionId}" + 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: -- GitLab From ff2d5150674ceb106f7f6061c6a018396c3bbe3d Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 4 May 2026 17:39:09 +0000 Subject: [PATCH 10/17] Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist --- .gitlab-ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a92e0f6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,13 @@ +# You can override the included template(s) by including variable overrides +# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings +# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure +# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings +# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings +# Note that environment variables can be set in several places +# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence +stages: +- test +sast: + stage: test +include: +- template: Security/SAST.gitlab-ci.yml -- GitLab From 7505ed157a29fccf39af17ef7b77136a70daa02f Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 11 May 2026 13:52:40 +0300 Subject: [PATCH 11/17] feat: add tools for edge apps definitions, #9 --- mcp_module/app.py | 10 +- .../app_register_app_definition_request.json | 22 ++++ mcp_module/models/edge_application_models.py | 29 +++++ mcp_module/tools/edge_application.py | 121 ++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 mcp_module/json_templates/app_register_app_definition_request.json create mode 100644 mcp_module/models/edge_application_models.py diff --git a/mcp_module/app.py b/mcp_module/app.py index 1ea364b..9f3918d 100644 --- a/mcp_module/app.py +++ b/mcp_module/app.py @@ -8,7 +8,12 @@ from fastmcp import FastMCP from fastapi import FastAPI from fastapi.responses import PlainTextResponse from dotenv import load_dotenv -from mcp_module.tools.edge_application import get_app_definitions +from mcp_module.tools.edge_application import ( + delete_app_definition, + get_app_definitions, + get_app_definition, + register_app_definition, +) from mcp_module.tools.qod import ( create_qod_session_oai, get_qod_session_oai, @@ -31,6 +36,9 @@ SAFE_TOOLS: list[Callable[..., Any]] = [ get_qod_session_oai, delete_qod_session_oai, get_app_definitions, + register_app_definition, + delete_app_definition, + get_app_definition, ] for tool in SAFE_TOOLS: 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 0000000..e217d59 --- /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/edge_application_models.py b/mcp_module/models/edge_application_models.py new file mode 100644 index 0000000..2fd2bf8 --- /dev/null +++ b/mcp_module/models/edge_application_models.py @@ -0,0 +1,29 @@ +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) diff --git a/mcp_module/tools/edge_application.py b/mcp_module/tools/edge_application.py index 7f07639..50b4f14 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -1,8 +1,16 @@ import logging +import json 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, + GetAppDefinitionInput, + RegisterAppDefinitionInput, +) # Configure logging logging.basicConfig( @@ -25,6 +33,15 @@ def get_session_service_url() -> str: return session_service_url +def _derive_app_name(image_path: str) -> str: + parsed_path = urlparse(image_path).path + last_segment = parsed_path.rstrip("/").split("/")[-1] + name_without_tag = last_segment.split(":", 1)[0] + if not name_without_tag: + raise ValueError("imagePath must include an app name in the final URL segment") + return name_without_tag + + async def get_app_definitions() -> dict: """ Get the list of all existing Application definitions from the Edge Cloud Provider @@ -65,3 +82,107 @@ 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. + """ + 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} -- GitLab From 273378c0429f686cc094ca282763ea02361e8875 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 11 May 2026 14:54:29 +0300 Subject: [PATCH 12/17] feat: added tools for edge zone retrieval and app instantiation,#9 --- mcp_module/app.py | 4 + .../app_instantiate_request.json | 14 +++ mcp_module/models/edge_application_models.py | 19 ++++ mcp_module/tools/edge_application.py | 88 ++++++++++++++++++- 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 mcp_module/json_templates/app_instantiate_request.json diff --git a/mcp_module/app.py b/mcp_module/app.py index 9f3918d..3fde476 100644 --- a/mcp_module/app.py +++ b/mcp_module/app.py @@ -10,8 +10,10 @@ from fastapi.responses import PlainTextResponse from dotenv import load_dotenv from mcp_module.tools.edge_application import ( delete_app_definition, + get_edge_cloud_zones, get_app_definitions, get_app_definition, + instantiate_app, register_app_definition, ) from mcp_module.tools.qod import ( @@ -39,6 +41,8 @@ SAFE_TOOLS: list[Callable[..., Any]] = [ register_app_definition, delete_app_definition, get_app_definition, + get_edge_cloud_zones, + instantiate_app, ] for tool in SAFE_TOOLS: 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 0000000..53c5e9f --- /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/models/edge_application_models.py b/mcp_module/models/edge_application_models.py index 2fd2bf8..8d0b3d2 100644 --- a/mcp_module/models/edge_application_models.py +++ b/mcp_module/models/edge_application_models.py @@ -27,3 +27,22 @@ class DeleteAppDefinitionInput(BaseModel): 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 diff --git a/mcp_module/tools/edge_application.py b/mcp_module/tools/edge_application.py index 50b4f14..e30214f 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -1,5 +1,6 @@ import logging import json +from copy import deepcopy import httpx from dotenv import load_dotenv from pathlib import Path @@ -9,6 +10,7 @@ from urllib.parse import urlparse from mcp_module.models.edge_application_models import ( DeleteAppDefinitionInput, GetAppDefinitionInput, + InstantiateAppInput, RegisterAppDefinitionInput, ) @@ -34,9 +36,11 @@ def get_session_service_url() -> str: def _derive_app_name(image_path: str) -> str: - parsed_path = urlparse(image_path).path - last_segment = parsed_path.rstrip("/").split("/")[-1] - name_without_tag = last_segment.split(":", 1)[0] + 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] + name_without_tag = name_without_digest.split(":", 1)[0] if not name_without_tag: raise ValueError("imagePath must include an app name in the final URL segment") return name_without_tag @@ -186,3 +190,81 @@ async def get_app_definition(inp: GetAppDefinitionInput) -> dict: if isinstance(data, dict): return data return {"response": data} + + +async def get_edge_cloud_zones() -> dict: + """ + Retrieve edge cloud zones with unknown status. + """ + url = f"{get_session_service_url()}/edge-cloud-zones" + headers = {"accept": "application/json"} + params = {"status": "unknown"} + 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, + params=params, + 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} -- GitLab From d31551790ab4725eb0559c514ae08532e7b413f3 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 18 May 2026 16:31:43 +0300 Subject: [PATCH 13/17] feat: additional mcp tools for edge app API endpoints,#9 --- mcp_module/app.py | 4 ++ mcp_module/models/edge_application_models.py | 4 ++ mcp_module/tools/edge_application.py | 69 ++++++++++++++++++-- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/mcp_module/app.py b/mcp_module/app.py index 3fde476..6c3b97f 100644 --- a/mcp_module/app.py +++ b/mcp_module/app.py @@ -10,9 +10,11 @@ from fastapi.responses import PlainTextResponse from dotenv import load_dotenv 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, ) @@ -43,6 +45,8 @@ SAFE_TOOLS: list[Callable[..., Any]] = [ get_app_definition, get_edge_cloud_zones, instantiate_app, + get_app_instances, + delete_app_instance, ] for tool in SAFE_TOOLS: diff --git a/mcp_module/models/edge_application_models.py b/mcp_module/models/edge_application_models.py index 8d0b3d2..ef4937d 100644 --- a/mcp_module/models/edge_application_models.py +++ b/mcp_module/models/edge_application_models.py @@ -46,3 +46,7 @@ class InstantiateAppInput(BaseModel): 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/tools/edge_application.py b/mcp_module/tools/edge_application.py index e30214f..9a42d99 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -1,6 +1,7 @@ import logging import json from copy import deepcopy +from uuid import uuid4 import httpx from dotenv import load_dotenv from pathlib import Path @@ -9,6 +10,7 @@ from urllib.parse import urlparse from mcp_module.models.edge_application_models import ( DeleteAppDefinitionInput, + DeleteAppInstanceInput, GetAppDefinitionInput, InstantiateAppInput, RegisterAppDefinitionInput, @@ -40,10 +42,11 @@ def _derive_app_name(image_path: str) -> str: 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] - name_without_tag = name_without_digest.split(":", 1)[0] - if not name_without_tag: + 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") - return name_without_tag + unique_id = uuid4().hex[:8] + return f"{base_name}-{unique_id}" async def get_app_definitions() -> dict: @@ -194,11 +197,10 @@ async def get_app_definition(inp: GetAppDefinitionInput) -> dict: async def get_edge_cloud_zones() -> dict: """ - Retrieve edge cloud zones with unknown status. + Retrieve edge cloud zones """ url = f"{get_session_service_url()}/edge-cloud-zones" headers = {"accept": "application/json"} - params = {"status": "unknown"} logger.debug("Fetching edge cloud zones from: %s", url) try: @@ -206,7 +208,6 @@ async def get_edge_cloud_zones() -> dict: response = await client.get( url, headers=headers, - params=params, follow_redirects=True, timeout=15, ) @@ -268,3 +269,59 @@ async def instantiate_app(inp: InstantiateAppInput) -> dict: 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. + """ + 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} -- GitLab From 2ba081b7b53cd9cf7aa7ca4e716b6f3e1349273f Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Wed, 20 May 2026 11:53:22 +0300 Subject: [PATCH 14/17] fix: description of qod deletion --- mcp_module/tools/qod.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mcp_module/tools/qod.py b/mcp_module/tools/qod.py index 7d94965..8872436 100644 --- a/mcp_module/tools/qod.py +++ b/mcp_module/tools/qod.py @@ -108,6 +108,9 @@ async def get_qod_session_oai(inp: GetQoDSessionInput) -> QoDSessionFullResponse async def delete_qod_session_oai(inp: GetQoDSessionInput) -> List[Any]: + """ + 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) -- GitLab From b5c3f098a48f4d5d5bb36987ebd0a6c2ddea8737 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Wed, 20 May 2026 13:31:01 +0300 Subject: [PATCH 15/17] feat: amendments for multi-step agentic operations --- ai_agent/sub_agents/groq_agent.py | 2 +- mcp_module/tools/edge_application.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ai_agent/sub_agents/groq_agent.py b/ai_agent/sub_agents/groq_agent.py index f6c7025..d987046 100644 --- a/ai_agent/sub_agents/groq_agent.py +++ b/ai_agent/sub_agents/groq_agent.py @@ -53,7 +53,7 @@ async def create_groq_agent(): agent = MCPAgent( client=client, - max_steps=10, + max_steps=20, system_prompt=CONVERSATION_MEMORY_PROMPT, llm=llm, verbose=True, diff --git a/mcp_module/tools/edge_application.py b/mcp_module/tools/edge_application.py index 9a42d99..a93b052 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -141,7 +141,7 @@ async def register_app_definition(inp: RegisterAppDefinitionInput) -> dict: async def delete_app_definition(inp: DeleteAppDefinitionInput) -> dict: """ - Delete all the information and content related to an Application. + 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) @@ -301,7 +301,7 @@ async def get_app_instances() -> dict: async def delete_app_instance(inp: DeleteAppInstanceInput) -> dict: """ - Delete an instantiated or deployed application by app instance id. + 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) -- GitLab From 6c009356ffebd8c16e718be1c01fc86af021e3bc Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Mon, 25 May 2026 17:45:00 +0300 Subject: [PATCH 16/17] doc(mcp_module) to reflect the changes of the last commits --- mcp_module/README.md | 57 ++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/mcp_module/README.md b/mcp_module/README.md index 4062e4e..0f5a40b 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. - - -- GitLab From 8324752d6ab6eac89e70e17fce37932c17bb92d8 Mon Sep 17 00:00:00 2001 From: Kostas Chartsias Date: Wed, 3 Jun 2026 10:22:49 +0300 Subject: [PATCH 17/17] minor formatting issue --- mcp_module/tools/edge_application.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp_module/tools/edge_application.py b/mcp_module/tools/edge_application.py index a93b052..375dbaf 100644 --- a/mcp_module/tools/edge_application.py +++ b/mcp_module/tools/edge_application.py @@ -100,7 +100,9 @@ async def register_app_definition(inp: RegisterAppDefinitionInput) -> dict: - imagePath: image or Helm chart URL - port: exposed TCP port """ - template_path = root_dir / "json_templates" / "app_register_app_definition_request.json" + template_path = ( + root_dir / "json_templates" / "app_register_app_definition_request.json" + ) if not template_path.exists(): raise FileNotFoundError(f"{template_path} not found") -- GitLab