Skip to content

Commit 894f658

Browse files
committed
added UI for MCP server
1 parent b06bb19 commit 894f658

File tree

8 files changed

+425
-11
lines changed

8 files changed

+425
-11
lines changed

ai/gen-ai-agents/custom-rag-agent/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
This repository contains the code for the development of a **custom RAG Agent**, based on **OCI Generative AI**, **Oracle 23AI** Vector Store and **LangGraph**
55

66
**Author**: L. Saetta
7-
**Last updated**: 09/09/2025
7+
8+
**Last updated**: 11/09/2025
89

910
## Design and implementation
1011
* The agent is implemented using **LangGraph**
@@ -25,14 +26,17 @@ For example, links to the documentation' chunks are displayed before the final a
2526

2627
### MCP support:
2728
(07/2025) I have added an implementation of an **MCP** server that exposes the Semantic Search feature.
28-
Security can be handled in two ways:
29+
* added a [demo LLM with MCP](./ui_mcp_agent.py) showing how to integrate a generic MCP server in a Chatbot using a LLM.
30+
31+
**Security** can be handled in two ways:
2932
* custom: generate the **JWT token** using the library **PyJWT**
3033
* **OCI**: generate the JWT token using **OCI IAM**
3134

3235
## Status
33-
It is **WIP**.
36+
It is always and proudly **WIP**.
3437

3538
## References
39+
For more information:
3640
* [Integration with OCI APM](https://luigi-saetta.medium.com/enhancing-observability-in-rag-solutions-with-oracle-cloud-6f93b2675f40)
3741

3842
## Advantages of the Agentic approach
@@ -45,5 +49,5 @@ For example, to ensure that final responses do not disclose Personally Identifia
4549
* use Python 3.11
4650
* use the requirements.txt
4751
* create your config_private.py using the template provided
48-
* for MCP server: create a confidential application in OCI IAM
52+
* for MCP server: create a confidential application in **OCI IAM** to handle JWT tokens.
4953

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Private config
3+
"""
4+
5+
# Oracle Vector Store
6+
# VECTOR_DB_USER = "TEST_VECTOR"
7+
# VECTOR_DB_PWD = "LuigiLuigi2025##"
8+
# VECTOR_WALLET_PWD = "welcome1"
9+
# VECTOR_DSN = "aidb_medium"
10+
# VECTOR_WALLET_DIR = "/Users/lsaetta/Progetti/ai_assistant3/WALLET_VECTOR"
11+
12+
# switched to ATP to avoid invalid LOB locator
13+
VECTOR_DB_USER = "AIUSER"
14+
VECTOR_DB_PWD = "Pennolina23ai&"
15+
16+
# point to the environment to test NVIDIA llama3.2 embed model
17+
# VECTOR_DB_USER = "NVIDIAUSER"
18+
# VECTOR_DB_PWD = "Pennolina2025&"
19+
VECTOR_WALLET_PWD = "welcome1"
20+
VECTOR_DSN = "aiatp01_medium"
21+
VECTOR_WALLET_DIR = "/Users/lsaetta/Progetti/custom_rag_agent/wallet_atp"
22+
23+
CONNECT_ARGS = {
24+
"user": VECTOR_DB_USER,
25+
"password": VECTOR_DB_PWD,
26+
"dsn": VECTOR_DSN,
27+
"config_dir": VECTOR_WALLET_DIR,
28+
"wallet_location": VECTOR_WALLET_DIR,
29+
"wallet_password": VECTOR_WALLET_PWD,
30+
}
31+
32+
# integration with APM
33+
APM_PUBLIC_KEY = "6OXZ45BTT5AHD5KYICGOMLXXAZYTTLGT"
34+
35+
# to add JWT to MCP server
36+
JWT_SECRET = "oracle-ai"
37+
# using this in the demo, make it simpler.
38+
# In production should switch to RS256 and use a key-pair
39+
JWT_ALGORITHM = "HS256"
40+
41+
# if using IAM to generate JWT token
42+
OCI_CLIENT_ID = "b51225fce0374a759f615ed264ddd268"
43+
# th ocid of the secret in the vault
44+
SECRET_OCID = "ocid1.vaultsecret.oc1.eu-frankfurt-1.amaaaaaa2xxap7yalre4qru4asevgtxlmn7hwh27awnzmdcrnmsfqu7cia7a"
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""
2+
Test LLM and MCP
3+
Based on fastmcp library.
4+
This one provide also support for security in MCP calls, using JWT token.
5+
6+
This is the backend for the Streamlit MCP UI.
7+
"""
8+
9+
import json
10+
import asyncio
11+
from typing import List, Dict, Any, Callable, Sequence, Optional
12+
13+
from fastmcp import Client as MCPClient
14+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
15+
16+
# our code imports
17+
from oci_jwt_client import OCIJWTClient
18+
from oci_models import get_llm
19+
from utils import get_console_logger
20+
from config import IAM_BASE_URL, ENABLE_JWT_TOKEN
21+
from config_private import SECRET_OCID
22+
from mcp_servers_config import MCP_SERVERS_CONFIG
23+
24+
logger = get_console_logger()
25+
26+
# ---- Config ----
27+
MAX_HISTORY = 10
28+
MCP_URL = MCP_SERVERS_CONFIG["default"]["url"]
29+
TIMEOUT = 60
30+
# the scope for the JWT token
31+
SCOPE = "urn:opc:idm:__myscopes__"
32+
33+
# eventually you can taylor the SYSTEM prompt here
34+
SYSTEM_PROMPT = """You are an AI assistant equipped with an MCP server and several tools.
35+
Provide all the needed information with a detailed query when you use a tool.
36+
If the collection name is not provided in the user's prompt,
37+
use the collection BOOKS to get the additional information you need to answer.
38+
"""
39+
40+
41+
def default_jwt_supplier() -> str:
42+
"""
43+
Get a valid JWT token to make the call to MCP server
44+
"""
45+
if ENABLE_JWT_TOKEN:
46+
# Always return a FRESH token; do not include "Bearer " (FastMCP adds it)
47+
token, _, _ = OCIJWTClient(IAM_BASE_URL, SCOPE, SECRET_OCID).get_token()
48+
else:
49+
# JWT security disabled
50+
token = None
51+
return token
52+
53+
54+
class AgentWithMCP:
55+
"""
56+
LLM + MCP orchestrator.
57+
- Discovers tools from an MCP server (JWT-protected)
58+
- Binds tool JSON Schemas to the LLM
59+
- Executes tool calls emitted by the LLM and loops until completion
60+
61+
This is a rather simple agent, it does only tool calling,
62+
but tools are provided by the MCP server.
63+
The code introspects the MCP server and decide which tool to call
64+
and what parameters to provide.
65+
"""
66+
67+
def __init__(
68+
self,
69+
mcp_url: str,
70+
jwt_supplier: Callable[[], str],
71+
timeout: int,
72+
llm,
73+
):
74+
self.mcp_url = mcp_url
75+
self.jwt_supplier = jwt_supplier
76+
self.timeout = timeout
77+
self.llm = llm
78+
self.model_with_tools = None
79+
# optional: cache tools to avoid re-listing every run
80+
self._tools_cache = None
81+
82+
# ---------- helpers now INSIDE the class ----------
83+
84+
@staticmethod
85+
def _tool_to_schema(t: object) -> dict:
86+
"""
87+
Convert an MCP tool (name, description, inputSchema) to a JSON-Schema dict
88+
that LangChain's ChatCohere.bind_tools accepts (top-level schema).
89+
"""
90+
input_schema = (getattr(t, "inputSchema", None) or {}).copy()
91+
if input_schema.get("type") != "object":
92+
input_schema.setdefault("type", "object")
93+
input_schema.setdefault("properties", {})
94+
return {
95+
"title": getattr(t, "name", "tool"),
96+
"description": getattr(t, "description", "") or "",
97+
**input_schema,
98+
}
99+
100+
async def _list_tools(self):
101+
"""
102+
Fetch tools from the MCP server using FastMCP. Must be async.
103+
"""
104+
jwt = self.jwt_supplier()
105+
106+
logger.info("Listing tools from %s ...", self.mcp_url)
107+
108+
# FastMCP requires async context + await for client ops.
109+
async with MCPClient(self.mcp_url, auth=jwt, timeout=self.timeout) as c:
110+
# returns Tool objects
111+
return await c.list_tools()
112+
113+
async def _call_tool(self, name: str, args: Dict[str, Any]):
114+
"""
115+
Execute a single MCP tool call.
116+
"""
117+
jwt = self.jwt_supplier()
118+
logger.info("Calling MCP tool '%s' with args %s", name, args)
119+
async with MCPClient(self.mcp_url, auth=jwt, timeout=self.timeout) as c:
120+
return await c.call_tool(name, args or {})
121+
122+
@classmethod
123+
async def create(
124+
cls,
125+
mcp_url: str = MCP_URL,
126+
jwt_supplier: Callable[[], str] = default_jwt_supplier,
127+
timeout: int = TIMEOUT,
128+
model_id: str = "cohere.command-a-03-2025",
129+
):
130+
"""
131+
Async factory: fetch tools, bind them to the LLM, return a ready-to-use agent.
132+
Important: Avoids doing awaits in __init__.
133+
"""
134+
# should return a LangChain Chat model supporting .bind_tools(...)
135+
llm = get_llm(model_id=model_id)
136+
# after, we call init()
137+
self = cls(mcp_url, jwt_supplier, timeout, llm)
138+
139+
tools = await self._list_tools()
140+
if not tools:
141+
logger.warning("No tools discovered at %s", mcp_url)
142+
self._tools_cache = tools
143+
144+
schemas = [self._tool_to_schema(t) for t in tools]
145+
self.model_with_tools = self.llm.bind_tools(schemas)
146+
return self
147+
148+
def _build_messages(
149+
self,
150+
history: Sequence[Dict[str, Any]],
151+
system_prompt: str,
152+
current_user_prompt: str,
153+
*,
154+
max_history: Optional[
155+
int
156+
] = MAX_HISTORY, # keep only the last N items; None = keep all
157+
exclude_last: bool = True, # drop the very last history entry before building
158+
) -> List[Any]:
159+
"""
160+
Create: [SystemMessage(system_prompt), <trimmed history except last>,
161+
HumanMessage(current_user_prompt)]
162+
History items are dicts like {"role": "user"|"assistant", "content": "..."}
163+
in chronological order.
164+
"""
165+
# 1) Trim to the last `max_history` entries (if set)
166+
if max_history is not None and max_history > 0:
167+
working = list(history[-max_history:])
168+
else:
169+
working = list(history)
170+
171+
# 2) Optionally remove the final entry from trimmed history
172+
if exclude_last and working:
173+
working = working[:-1]
174+
175+
# 3) Build LangChain messages
176+
msgs: List[Any] = [SystemMessage(content=system_prompt)]
177+
for m in working:
178+
role = (m.get("role") or "").lower()
179+
content: Optional[str] = m.get("content")
180+
if not content:
181+
continue
182+
if role == "user":
183+
msgs.append(HumanMessage(content=content))
184+
elif role == "assistant":
185+
msgs.append(AIMessage(content=content))
186+
# ignore other/unknown roles (e.g., 'system', 'tool') in this simple variant
187+
188+
# 4) Add the current user prompt
189+
msgs.append(HumanMessage(content=current_user_prompt))
190+
return msgs
191+
192+
#
193+
# ---------- main loop ----------
194+
#
195+
async def answer(self, question: str, history: list = None) -> str:
196+
"""
197+
Run the LLM+MCP loop until the model stops calling tools.
198+
"""
199+
messages = self._build_messages(
200+
history=history,
201+
system_prompt=SYSTEM_PROMPT,
202+
current_user_prompt=question,
203+
)
204+
205+
# List[Any] = [
206+
# SystemMessage(content=SYSTEM_PROMPT),
207+
# HumanMessage(content=question),
208+
# ]
209+
210+
while True:
211+
ai: AIMessage = await self.model_with_tools.ainvoke(messages)
212+
213+
tool_calls = getattr(ai, "tool_calls", None) or []
214+
if not tool_calls:
215+
# Final answer
216+
return ai.content
217+
218+
messages.append(ai) # keep the AI msg that requested tools
219+
220+
# Execute tool calls and append ToolMessage for each
221+
for tc in tool_calls:
222+
name = tc["name"]
223+
args = tc.get("args") or {}
224+
try:
225+
# here we call the tool
226+
result = await self._call_tool(name, args)
227+
payload = (
228+
getattr(result, "data", None)
229+
or getattr(result, "content", None)
230+
or str(result)
231+
)
232+
messages.append(
233+
ToolMessage(
234+
content=json.dumps(payload),
235+
# must match the call id
236+
tool_call_id=tc["id"],
237+
name=name,
238+
)
239+
)
240+
except Exception as e:
241+
messages.append(
242+
ToolMessage(
243+
content=json.dumps({"error": str(e)}),
244+
tool_call_id=tc["id"],
245+
name=name,
246+
)
247+
)
248+
249+
250+
# ---- Example CLI usage ----
251+
# this code is good for CLI, not Streamlit. See ui_mcp_agent.py
252+
if __name__ == "__main__":
253+
QUESTION = "Tell me about Luigi Saetta. I need his e-mail address also."
254+
agent = asyncio.run(AgentWithMCP.create())
255+
print(asyncio.run(agent.answer(QUESTION)))

ai/gen-ai-agents/custom-rag-agent/mcp_explorer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from utils import get_console_logger
1313
from config import DEBUG, ENABLE_JWT_TOKEN, IAM_BASE_URL
1414
from config_private import SECRET_OCID
15+
from mcp_servers_config import MCP_SERVERS_CONFIG
1516

1617
# the scope for the JWT token
1718
SCOPE = "urn:opc:idm:__myscopes__"
@@ -22,7 +23,7 @@
2223
st.title("🚀 MCP Tool Explorer")
2324

2425
# Config
25-
DEFAULT_URL = "http://localhost:9000/mcp/"
26+
DEFAULT_URL = MCP_SERVERS_CONFIG["default"]["url"]
2627
server_url = st.text_input("URL MCP:", DEFAULT_URL)
2728
TIMEOUT = 30
2829

@@ -35,7 +36,7 @@
3536

3637
async def fetch_tools():
3738
"""
38-
This function call the MCP sevrer to get list and descriptions of tools
39+
This function call the MCP server to get list and descriptions of tools
3940
"""
4041
if ENABLE_JWT_TOKEN:
4142
# this is a client to OCI IAM to get the JWT token

ai/gen-ai-agents/custom-rag-agent/mcp_semantic_search_with_iam.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def semantic_search(
109109
Perform a semantic search based on the provided query.
110110
Args:
111111
query (str): The search query.
112-
top_k (int): The number of top results to return.
112+
top_k (int): The number of top results to return. Must be at least 5.
113113
collection_name (str): The name of the collection (DB table) to search in.
114114
Returns:
115115
dict: a dictionary containing the relevant documents.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""
2+
MCP server config
3+
"""
4+
5+
MCP_SERVERS_CONFIG = {
6+
"default": {
7+
"transport": "streamable_http",
8+
"url": "http://localhost:9000/mcp/",
9+
},
10+
"oci-semantic-search": {
11+
"transport": "streamable_http",
12+
"url": "http://localhost:9000/mcp/",
13+
},
14+
}

0 commit comments

Comments
 (0)