diff --git a/ai/gen-ai-agents/mcp-oci-integration/README.md b/ai/gen-ai-agents/mcp-oci-integration/README.md new file mode 100644 index 000000000..07347e8a5 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/README.md @@ -0,0 +1,84 @@ +# MCP Oracle OCI integrations +This repository contains code and examples to help in the following tasks: +* **Develop** MCP servers in **Python** +* **Run** MCP servers on **Oracle OCI** +* **Integrate** MCP servers with **AI Agents** +* **Integrate** MCP servers with other **OCI resources** (ADB, Select AI, ...) +* **Integrate** MCP Servers running on OCI with AI Assistants like **ChatGPT**, Claude.ai, MS Copilot +* **Integrate** MCP Servers with OCI **APM** for **Observability** + +![MCP console](./images/mcp_cli.png) + +## What is MCP? +**MCP (Model Context Protocol)** is an **open-source standard** that lets AI models (e.g. LLMs or agents) connect bidirectionally with external tools, data sources, and services via a unified interface. + +It replaces the “N×M” integration problem (where each AI × data source requires custom code) with one standard protocol. + +MCP supports **dynamic discovery** of available tools and context, enabling: +* AI Assistants to get access to relevant information, available in Enterprise Knowledge base. +* Agents to reason and chain actions across disparate systems. + +It’s quickly gaining traction: major players like OpenAI, Google DeepMind, Oracle are adopting it to make AI systems more composable and interoperable. + +In today’s landscape of agentic AI, MCP is critical because it allows models to act meaningfully in real-world systems rather than remaining isolated black boxes. + +## Develop MCP Servers in Python +The easiest way is to use the [FastMCP](https://gofastmcp.com/getting-started/welcome) library. + +**Examples**: +* in [Minimal MCP Server](./minimal_mcp_server.py) you'll find a **good, minimal example** of a server exposing two tools, with the option to protect them using [JWT](https://www.jwt.io/introduction#what-is-json-web-token). + +If you want to start with **something simpler**, have a look at [how to start developing MCP](./how_to_start_mcp.md). It is simpler, with no support for JWT tokens. + +## How to test +If you want to quickly test the MCP server you developed (or the minimal example provided here) you can use the [Streamlit UI](./ui_mcp_agent.py). + +In the Streamlit application, you can: +* Specify the URL of the MCP server (default is in [mcp_servers_config.py](./mcp_servers_config.py)) +* Select one of models available in OCI Generative AI +* Test making questions answered using the tools exposed by the MCP server. + +In [llm_with_mcp.py](./llm_with_mcp.py) there is the complete implementation of the **tool-calling** loop. + +## Semantic Search +In this repository there is a **complete implementation of an MCP server** implementing **Semantic Search** on top of **Oracle 23AI**. +To use it, you need only: +* To load the documents in the Oracle DB +* To put the right configuration, to connect to DB, in config_private.py. + +The code is available [here](./mcp_semantic_search_with_iam.py). + +Access to Oracle 23AI Vector Search is through the **new** [langchain-oci integration library](https://github.com/oracle/langchain-oracle) + +## Adding security +If you want to put your **MCP** server in production, you need to add security, at several levels. + +Just to mention few important points: +* You don't want to expose directly the MCP server over Internet +* The communication with the MCP server must be encrypted (i.e: using TLS) +* You want to authenticate and authorize the clients + +Using **OCI services** there are several things you can do to get the right level of security: +* You can put an **OCI API Gateway** in front, using it as TLS termination +* You can enable authentication using **JWT** tokens +* You can use **OCI IAM** to generate **JWT** tokens +* You can use OCI network security + +More details in a dedicate page. + +## Integrate MCP Semantic Search with ChatGPT +If you deploy the [MCP Semantic Search](./mcp_semantic_search_with_iam.py) server you can test the integration with **ChatGPT** in **Developer Mode**. It provides a **search** tool, compliant with **OpenAI** specs. + +Soon, we'll add a server fully compliant with **OpenAI** specifications, that can be integrated in **Deep Research**. The server must implement two methods (**search** and **fetch**) with a different behaviour, following srictly OpenAI specs. + +An initial implementation is available [here](./mcp_deep_research_with_iam.py) + +Details available [here](./integrate_chatgpt.md) + +## Integrate OCI ADB Select AI +Another option is to use an MCP server to be able to integrate OCI **SelectAI** in ChatGPT or other assistants supporting MCP. +In this way you have an option to do full **Text2SQL** search, over your database schema. Then, the AI assistant can process your retrieved data. + +An example is [here](./mcp_selectai.py) + + diff --git a/ai/gen-ai-agents/mcp-oci-integration/check_code.sh b/ai/gen-ai-agents/mcp-oci-integration/check_code.sh new file mode 100755 index 000000000..505acecca --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/check_code.sh @@ -0,0 +1,6 @@ +# format code +black *.py + +# check +pylint *.py + diff --git a/ai/gen-ai-agents/mcp-oci-integration/config.py b/ai/gen-ai-agents/mcp-oci-integration/config.py new file mode 100644 index 000000000..5ea37c8f3 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/config.py @@ -0,0 +1,107 @@ +""" +File name: config.py +Author: Luigi Saetta +Date last modified: 2025-07-02 +Python Version: 3.11 + +Description: + This module provides general configurations + + +Usage: + Import this module into other scripts to use its functions. + Example: + import config + +License: + This code is released under the MIT License. + +Notes: + This is a part of a demo showing how to implement an advanced + RAG solution as a LangGraph agent. + +Warnings: + This module is in development, may change in future versions. +""" + +DEBUG = False + +# type of OCI auth +AUTH = "API_KEY" + +# embeddings +# added this to distinguish between Cohere end REST NVIDIA models +# can be OCI or NVIDIA +EMBED_MODEL_TYPE = "OCI" +# EMBED_MODEL_TYPE = "NVIDIA" +EMBED_MODEL_ID = "cohere.embed-multilingual-v3.0" + +# this one needs to specify the dimension, default is 1536 +# EMBED_MODEL_ID = "cohere.embed-v4.0" +# used only for NVIDIA models +NVIDIA_EMBED_MODEL_URL = "" + + +# LLM +# this is the default model +LLM_MODEL_ID = "meta.llama-3.3-70b-instruct" +TEMPERATURE = 0.1 +MAX_TOKENS = 4000 + +# OCI general +# REGION = "eu-frankfurt-1" +REGION = "us-chicago-1" +SERVICE_ENDPOINT = f"https://inference.generativeai.{REGION}.oci.oraclecloud.com" + +if REGION == "us-chicago-1": + # for now only available in chicago region + MODEL_LIST = [ + "xai.grok-3", + "xai.grok-4", + "openai.gpt-4.1", + "openai.gpt-4o", + "openai.gpt-5", + "meta.llama-3.3-70b-instruct", + "cohere.command-a-03-2025", + ] +else: + MODEL_LIST = [ + "openai.gpt-4.1", + "openai.gpt-5", + "meta.llama-3.3-70b-instruct", + "cohere.command-a-03-2025", + ] + +# semantic search +TOP_K = 6 +COLLECTION_LIST = ["BOOKS", "NVIDIA_BOOKS2"] +DEFAULT_COLLECTION = "BOOKS" + + +# history management (put -1 if you want to disable trimming) +# consider that we have pair (human, ai) so use an even (ex: 6) value +MAX_MSGS_IN_HISTORY = 6 + +# reranking enabled or disabled from UI + +# for loading +CHUNK_SIZE = 4000 +CHUNK_OVERLAP = 100 + +# for MCP server +TRANSPORT = "streamable-http" +# bind to all interfaces +HOST = "0.0.0.0" +PORT = 9000 + +# with this we can toggle JWT token auth +ENABLE_JWT_TOKEN = False +# for JWT token with OCI +# put your domain URL here +IAM_BASE_URL = "https://idcs-930d7b2ea2cb46049963ecba3049f509.identity.oraclecloud.com" +# these are used during the verification of the token +ISSUER = "https://identity.oraclecloud.com/" +AUDIENCE = ["urn:opc:lbaas:logicalguid=idcs-930d7b2ea2cb46049963ecba3049f509"] + +# for Select AI +SELECT_AI_PROFILE = "OCI_GENERATIVE_AI_PROFILE" diff --git a/ai/gen-ai-agents/mcp-oci-integration/config_private_template.py b/ai/gen-ai-agents/mcp-oci-integration/config_private_template.py new file mode 100644 index 000000000..834cf9749 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/config_private_template.py @@ -0,0 +1,33 @@ +""" +Private config +""" + +# +VECTOR_DB_USER = "your-db-user" +VECTOR_DB_PWD = "your-db-pwd" + +VECTOR_WALLET_PWD = "wallet-pwd" +VECTOR_DSN = "db-psn" +VECTOR_WALLET_DIR = "/Users/xxx/yyy" + +CONNECT_ARGS = { + "user": VECTOR_DB_USER, + "password": VECTOR_DB_PWD, + "dsn": VECTOR_DSN, + "config_dir": VECTOR_WALLET_DIR, + "wallet_location": VECTOR_WALLET_DIR, + "wallet_password": VECTOR_WALLET_PWD, +} + +COMPARTMENT_ID = "ocid1.compartment.oc1.your-compartment-ocid" + +# to add JWT to MCP server +JWT_SECRET = "secret" +# using this in the demo, make it simpler. +# In production should switch to RS256 and use a key-pair +JWT_ALGORITHM = "HS256" + +# if using IAM to generate JWT token +OCI_CLIENT_ID = "client-id" +# th ocid of the secret in the vault +SECRET_OCID = "ocid1.vaultsecret.oc1.eu-frankfurt-1.secret-ocid" diff --git a/ai/gen-ai-agents/mcp-oci-integration/custom_rest_embeddings.py b/ai/gen-ai-agents/mcp-oci-integration/custom_rest_embeddings.py new file mode 100644 index 000000000..2531a19a6 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/custom_rest_embeddings.py @@ -0,0 +1,123 @@ +""" +Custom class to support Embeddings model deployed using NVIDIA E. + +License: MIT +""" + +from typing import List +from langchain_core.embeddings import Embeddings +import requests +from utils import get_console_logger + +# list of allowed values for dims, input_type and truncate parms +ALLOWED_DIMS = {384, 512, 768, 1024, 2048} +ALLOWED_INPUT_TYPES = {"passage", "query"} +ALLOWED_TRUNCATE_VALUES = {"NONE", "START", "END"} + +# list of models with tunable dimensions +MATRIOSKA_MODELS = {"nvidia/llama-3.2-nv-embedqa-1b-v2"} + +logger = get_console_logger() + + +class CustomRESTEmbeddings(Embeddings): + """ + Custom class to wrap an embedding model with rest interface from NVIDIA NIM + + see: + https://docs.api.nvidia.com/nim/reference/nvidia-llama-3_2-nv-embedqa-1b-v2-infer + """ + + def __init__(self, api_url: str, model: str, batch_size: int = 10, dimensions=2048): + """ + Init + + as of now, no security + args: + api_url: the endpoint + model: the model id string + batch_size + dimensions: dim of the embedding vector + """ + self.api_url = api_url + self.model = model + self.batch_size = batch_size + + if self.model in MATRIOSKA_MODELS: + self.dimensions = dimensions + else: + # changing dimensions is not supported + self.dimensions = None + + # Validation at init time + if self.dimensions is not None and self.dimensions not in ALLOWED_DIMS: + raise ValueError( + f"Invalid dimensions {self.dimensions!r}: must be one of {sorted(ALLOWED_DIMS)}" + ) + + def embed_documents( + self, + texts: List[str], + # must be passage and not document + input_type: str = "passage", + truncate: str = "NONE", + ) -> List[List[float]]: + """ + Embed a list of documents using batching. + """ + # normalize + truncate = truncate.upper() + + logger.info("Calling NVIDIA embeddings, embed_documents...") + + if input_type not in ALLOWED_INPUT_TYPES: + raise ValueError( + f"Invalid value for input_types: must be one of {ALLOWED_INPUT_TYPES}" + ) + if truncate not in ALLOWED_TRUNCATE_VALUES: + raise ValueError( + f"Invalid value for truncate: must be one of {ALLOWED_TRUNCATE_VALUES}" + ) + + all_embeddings: List[List[float]] = [] + + for i in range(0, len(texts), self.batch_size): + batch = texts[i : i + self.batch_size] + # process a single batch + if self.model in MATRIOSKA_MODELS: + json_request = { + "model": self.model, + "input": batch, + "input_type": input_type, + "truncate": truncate, + "dimensions": self.dimensions, + } + else: + json_request = { + "model": self.model, + "input": batch, + "input_type": input_type, + "truncate": truncate, + "dimensions": self.dimensions, + } + + resp = requests.post( + self.api_url, + json=json_request, + timeout=30, + ) + resp.raise_for_status() + data = resp.json().get("data", []) + + if len(data) != len(batch): + raise ValueError(f"Expected {len(batch)} embeddings, got {len(data)}") + all_embeddings.extend(item["embedding"] for item in data) + return all_embeddings + + def embed_query(self, text: str) -> List[float]: + """ + Embed the query (a str) + """ + logger.info("Calling NVIDIA embeddings, embed_query...") + + return self.embed_documents([text], input_type="query")[0] diff --git a/ai/gen-ai-agents/mcp-oci-integration/db_utils.py b/ai/gen-ai-agents/mcp-oci-integration/db_utils.py new file mode 100644 index 000000000..b8ae39af6 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/db_utils.py @@ -0,0 +1,258 @@ +""" +File name: db_utils.py +Author: Luigi Saetta +Date last modified: 2025-07-30 +Python Version: 3.11 + +Description: + This module contains utility functions for database operations, + + +Usage: + Import this module into other scripts to use its functions. + Example: + import config + +License: + This code is released under the MIT License. + +Notes: + This is a part of a demo showing how to implement an advanced + RAG solution as a LangGraph agent. + +Warnings: + This module is in development, may change in future versions. +""" + +import re +from typing import List, Tuple, Optional, Any, Dict +import decimal +import datetime +import oracledb + +from utils import get_console_logger +from config import DEBUG +from config_private import CONNECT_ARGS + +logger = get_console_logger() + + +# +# Helpers +# +def get_connection(): + """ + get a connection to the DB + """ + return oracledb.connect(**CONNECT_ARGS) + + +def read_lob(value: Any) -> Optional[str]: + """ + Return a Python string from an oracledb LOB (CLOB) or a plain value. + If value is None, returns None. + """ + if value is None: + return None + # oracledb returns CLOBs as LOB objects; read() -> str + if isinstance(value, oracledb.LOB): + return value.read() + # Sometimes drivers may already return a str + return str(value) + + +def normalize_sql(sql_text: str) -> str: + """ + Strip trailing semicolons and whitespace to avoid ORA-00911 in driver. + """ + return sql_text.strip().rstrip(";\n\r\t ") + + +def is_safe_select(sql_text: str) -> bool: + """ + Minimal safety check: only allow pure SELECTs (no DML/DDL/PLSQL). + """ + s = sql_text.strip().upper() + if not s.startswith("SELECT "): + return False + forbidden = r"\b(INSERT|UPDATE|DELETE|MERGE|ALTER|DROP|CREATE|GRANT|REVOKE|TRUNCATE|BEGIN|EXEC|CALL)\b" + return not re.search(forbidden, s) + + +def _to_jsonable(value: Any): + # Convert DB/native types → JSON-safe + if value is None: + return None + if isinstance(value, oracledb.LOB): + return value.read() # CLOB/BLOB → str/bytes; CLOB becomes str + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).hex() # or base64 if you prefer + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, decimal.Decimal): + # choose float or str; str preserves precision + return float(value) + # Tuples/lists/sets inside cells (rare) → list + if isinstance(value, (tuple, set)): + return list(value) + return value + + +def list_collections(): + """ + return a list of all collections (tables) with a type vector + in the schema in use + """ + + query = """ + SELECT DISTINCT table_name + FROM user_tab_columns + WHERE data_type = 'VECTOR' + ORDER by table_name ASC + """ + _collections = [] + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(query) + + rows = cursor.fetchall() + + for row in rows: + _collections.append(row[0]) + + return sorted(_collections) + + +def list_books_in_collection(collection_name: str) -> list: + """ + get the list of books/documents names in the collection + taken from metadata + expect metadata contains the field source + + modified to return also the numb. of chunks + """ + query = f""" + SELECT DISTINCT json_value(METADATA, '$.source') AS books, + count(*) as n_chunks + FROM {collection_name} + group by books + ORDER by books ASC + """ + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(query) + + rows = cursor.fetchall() + + list_books = [] + for row in rows: + list_books.append((row[0], row[1])) + + return sorted(list_books) + + +def fetch_text_by_id(id: str, collection_name: str) -> str: + """ + Given the ID of a chunk return the text + """ + sql = """ + SELECT TEXT, json_value(METADATA, '$.source') + FROM {collection_name} + WHERE ID = :id + """ + + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + sql.format(collection_name=collection_name), + {"id": id}, + ) + + # we expect 0 or 1 rows + row = cursor.fetchone() + if row: + clob = row[0] + text_value = clob.read() if clob is not None else None + source = row[1] + else: + source = None + text_value = None + + return {"text_value": text_value, "source": source} + + +# --------------------------- +# Select AI utilities +# --------------------------- +def generate_sql_from_prompt(profile_name: str, prompt: str) -> str: + """ + Use DBMS_CLOUD_AI.GENERATE to get the SQL for a natural language prompt. + Returns the SQL as a Python string (CLOB -> .read()). + """ + stmt = """ + SELECT DBMS_CLOUD_AI.GENERATE( + prompt => :p, + profile_name => :prof, + action => 'showsql' + ) + FROM dual + """ + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(stmt, {"p": prompt, "prof": profile_name}) + # CLOB (LOB) or str + raw = cursor.fetchone()[0] + sql_text = read_lob(raw) or "" + return normalize_sql(sql_text) + + +def execute_generated_sql( + generated_sql: str, limit: Optional[int] = None +) -> Dict[str, Any]: + """ + Execute the SQL and return a JSON-serializable dict: + { + "columns": [ ... ], + "rows": [ [ ... ], ... ], + "sql": "..." + } + """ + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(generated_sql) + + columns: List[str] = [d[0] for d in cursor.description] + + # Fetch + raw_rows = cursor.fetchmany(limit) if limit else cursor.fetchall() + + # Normalize every cell to JSON-safe + rows: List[List[Any]] = [ + [_to_jsonable(cell) for cell in row] for row in raw_rows + ] + + return { + "columns": columns, + "rows": rows, # list of lists (JSON arrays) + "sql": generated_sql, # useful for logging/debug + } + + +def run_select_ai( + prompt: str, profile_name: str, limit: Optional[int] = None +) -> Tuple[List[str], List[tuple], str]: + """ + Generate SQL via Select AI for the given prompt, execute it, and return: + (columns, rows, generated_sql) + + If 'limit' is provided, fetch at most that many rows. + """ + generated_sql = generate_sql_from_prompt(profile_name, prompt) + + if DEBUG: + logger.info(generated_sql) + + # if not is_safe_select(generated_sql): + # raise ValueError("Refusing to execute non-SELECT SQL generated by Select AI.") + + return execute_generated_sql(prompt, limit) diff --git a/ai/gen-ai-agents/mcp-oci-integration/how_to_start_mcp.md b/ai/gen-ai-agents/mcp-oci-integration/how_to_start_mcp.md new file mode 100644 index 000000000..9c0f5b9df --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/how_to_start_mcp.md @@ -0,0 +1,71 @@ +# How to start developing an MCP server +The code provided in [minimal_mcp_server](./minimal_mcp_server.py) is a good starting point. + +But, in reality, for a super-minimal MCP server you need less code. +For example, you can remove support for JWT, if you don't need it. + +Therefore: + +``` +from fastmcp import FastMCP + +from config import ( + TRANSPORT, + # needed only if transport is streamable-http + HOST, + PORT, +) + +mcp = FastMCP("MCP server with few lines of code", auth=None) + +# +# MCP tools definition +# add and write the code for the tools here +# mark each tool with the annotation +# +@mcp.tool +def say_the_truth(user: str) -> str: + """ + Return a secret truth message addressed to the specified user. + + Args: + user (str): The name or identifier of the user to whom the truth is addressed. + + Returns: + str: A message containing a secret truth about the user. + + Examples: + >>> say_the_truth("Luigi") + "Luigi: Less is more!" + """ + # here you'll put the code that reads and return the info requested + # it is important to provide a good description of the tool in the docstring + return f"{user}: Less is more!" + +# +# Run the MCP server +# +if __name__ == "__main__": + if TRANSPORT not in {"stdio", "streamable-http"}: + raise RuntimeError(f"Unsupported TRANSPORT: {TRANSPORT}") + + # don't use sse! it is deprecated! + if TRANSPORT == "stdio": + # stdio doesn’t support host/port args + mcp.run(transport=TRANSPORT) + else: + # For streamable-http transport, host/port are valid + mcp.run( + transport=TRANSPORT, + host=HOST, + port=PORT, + ) +``` + +## Discoverability and internal documentation +One of the key features in MCP is **discoverability**. +They provide an endpoint where any AI assistant can discover the list of available tools and the way these tools can be called (params, return value). + +But, to make it works, you need to put in place **clear documentation**. +If you implement it using FastMCP, the documntation for each tool is automatically generated from the docstring. +So, it is mandatory to **write a complete and clear docstring** (Google style). \ No newline at end of file diff --git a/ai/gen-ai-agents/mcp-oci-integration/images/mcp_cli.png b/ai/gen-ai-agents/mcp-oci-integration/images/mcp_cli.png new file mode 100644 index 000000000..14aec9273 Binary files /dev/null and b/ai/gen-ai-agents/mcp-oci-integration/images/mcp_cli.png differ diff --git a/ai/gen-ai-agents/mcp-oci-integration/integrate_chatgpt.md b/ai/gen-ai-agents/mcp-oci-integration/integrate_chatgpt.md new file mode 100644 index 000000000..22a59a5bc --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/integrate_chatgpt.md @@ -0,0 +1,33 @@ +# Integrate with ChatGPT + +As of September 2025, there are two options to integrate private knowledge bases into ChatGPT, when hosted on Oracle OCI: + +1. Deep Research (strict OpenAI MCP compliance) + +2. Developer Mode (flexible integration) + +The goal in both cases is to ground ChatGPT’s answers not only in its internal knowledge and web search results, but also in private data securely hosted in an **Oracle 23AI Database**, retrieved via **Vector Search**. + +Both approaches rely on **MCP** (Model Context Protocol). + +### Option 1: Deep Research +Requires full adherence to the official OpenAI MCP specifications. +Your MCP must implement two tools: + +* search → returns a list of document snippet IDs relevant to the query. +* fetch → retrieves the full content of a document given its ID. + +If these are not correctly implemented, ChatGPT will not enable the MCP integration. + +### Option 2: Developer Mode +Available if you enable **Developer Mode** in ChatGPT settings. +More flexible: your MCP can expose arbitrary tools. +At a minimum, you must provide a search method to surface relevant data. + +Official [OpenAI MCP specification](https://platform.openai.com/docs/mcp#create-an-mcp-server) + +## Security +The MCP server must be exposed on Internet via a public IP. For this reason additional **security** is mandatory. +One option is to expose the MCP server using an **API Gateway** in OCI. In this way only the Gateway endpoint is available,through TLS, over Internet and the MCP server, hosted in OCI, is reachable via private IP only by the gateway. + +In addition, authorization can be handled using **OAUTH 2.0**. diff --git a/ai/gen-ai-agents/mcp-oci-integration/llm_with_mcp.py b/ai/gen-ai-agents/mcp-oci-integration/llm_with_mcp.py new file mode 100644 index 000000000..97a0a22b8 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/llm_with_mcp.py @@ -0,0 +1,336 @@ +""" +Test LLM and MCP +Based on fastmcp library. +This one provide also support for security in MCP calls, using JWT token. + +This is the backend for the Streamlit MCP UI. + +15/09: the code is a bit long to handle some exceptions regarding tool calling +with alle the non-cohere models through Langchain. +As for now, it is working fine with: Cohere, GPT and grok, +some problems with llama 3.3 +""" + +import json +import asyncio +import logging +from typing import List, Dict, Any, Callable, Sequence, Optional +import oci + +from fastmcp import Client as MCPClient +from pydantic import BaseModel, Field, create_model +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + +# our code imports +from oci_jwt_client import OCIJWTClient +from oci_models import get_llm +from utils import get_console_logger +from config import IAM_BASE_URL, ENABLE_JWT_TOKEN, DEBUG +from config_private import SECRET_OCID +from mcp_servers_config import MCP_SERVERS_CONFIG + +from log_helpers import ( + log_tool_schemas, + log_history_tail, + log_ai_tool_calls, + check_linkage_or_die, + _dump_pair_for_oci_debug, +) + +# for debugging +if DEBUG: + logging.basicConfig(level=logging.DEBUG) + logging.getLogger("oci").setLevel(logging.DEBUG) + oci.base_client.is_http_log_enabled(True) + +logger = get_console_logger() + +# ---- Config ---- +# trim the history to max MAX_HOSTORY msgs +MAX_HISTORY = 10 + +MCP_URL = MCP_SERVERS_CONFIG["default"]["url"] +TIMEOUT = 60 +# the scope for the JWT token +SCOPE = "urn:opc:idm:__myscopes__" + +# eventually you can taylor the SYSTEM prompt here +# modified to be compliant to OpenAI spec. +SYSTEM_PROMPT = """You are an AI assistant equipped with an MCP server and several tools. +Provide all the needed information with a detailed query when you use a tool. +If the collection name is not provided in the user's prompt, +use the collection BOOKS to get the additional information you need to answer. +If you need to use a tool called **fetch**, remember that the document ID is provided by the result of a search call. +It is NOT the document name. +""" + + +def default_jwt_supplier() -> str: + """ + Get a valid JWT token to make the call to MCP server + """ + if ENABLE_JWT_TOKEN: + # Always return a FRESH token; do not include "Bearer " (FastMCP adds it) + token, _, _ = OCIJWTClient(IAM_BASE_URL, SCOPE, SECRET_OCID).get_token() + else: + # JWT security disabled + token = None + return token + + +# mappings for schema to pyd +_JSON_TO_PY = {"string": str, "integer": int, "number": float, "boolean": bool} + + +# patch for OpenAI, xAI +def schemas_to_pydantic_models(schemas: List[Dict[str, Any]]) -> List[type[BaseModel]]: + """ + transform the dict with schemas in a Pydantic object to + solve the problems we have with non-cohere models + """ + out = [] + for s in schemas: + name = s.get("title", "tool") + desc = s.get("description", "") or "" + props = s.get("properties", {}) or {} + required = set(s.get("required", []) or {}) + fields = {} + for pname, spec in props.items(): + spec = spec or {} + jtype = spec.get("type", "string") + py = _JSON_TO_PY.get(jtype, Any) + default = ... if pname in required else None + # prefer property title, then description for the arg docstring + arg_desc = spec.get("title") or spec.get("description", "") + fields[pname] = (py, Field(default, description=arg_desc)) + model = create_model(name, __base__=BaseModel, **fields) + model.__doc__ = desc + out.append(model) + return out + + +class AgentWithMCP: + """ + LLM + MCP orchestrator. + - Discovers tools from an MCP server (JWT-protected) + - Binds tool JSON Schemas to the LLM + - Executes tool calls emitted by the LLM and loops until completion + + This is a rather simple agent, it does only tool calling, + but tools are provided by the MCP server. + The code introspects the MCP server and decide which tool to call + and what parameters to provide. + """ + + def __init__( + self, + mcp_url: str, + jwt_supplier: Callable[[], str], + timeout: int, + llm, + ): + self.mcp_url = mcp_url + self.jwt_supplier = jwt_supplier + self.timeout = timeout + self.llm = llm + self.model_with_tools = None + # optional: cache tools to avoid re-listing every run + self._tools_cache = None + + self.logger = logger + + # ---------- helpers now INSIDE the class ---------- + + @staticmethod + def _tool_to_schema(t: object) -> dict: + """ + Convert an MCP tool (name, description, inputSchema) to a JSON-Schema dict + that LangChain's ChatCohere.bind_tools accepts (top-level schema). + """ + input_schema = (getattr(t, "inputSchema", None) or {}).copy() + if input_schema.get("type") != "object": + input_schema.setdefault("type", "object") + input_schema.setdefault("properties", {}) + return { + "title": getattr(t, "name", "tool"), + "description": getattr(t, "description", "") or "", + **input_schema, + } + + async def _list_tools(self): + """ + Fetch tools from the MCP server using FastMCP. Must be async. + """ + jwt = self.jwt_supplier() + + logger.info("Listing tools from %s ...", self.mcp_url) + + # FastMCP requires async context + await for client ops. + async with MCPClient(self.mcp_url, auth=jwt, timeout=self.timeout) as c: + # returns Tool objects + return await c.list_tools() + + async def _call_tool(self, name: str, args: Dict[str, Any]): + """ + Execute a single MCP tool call. + """ + jwt = self.jwt_supplier() + logger.info("Calling MCP tool '%s' with args %s", name, args) + async with MCPClient(self.mcp_url, auth=jwt, timeout=self.timeout) as c: + return await c.call_tool(name, args or {}) + + @classmethod + async def create( + cls, + mcp_url: str = MCP_URL, + jwt_supplier: Callable[[], str] = default_jwt_supplier, + timeout: int = TIMEOUT, + model_id: str = "cohere.command-a-03-2025", + ): + """ + Async factory: fetch tools, bind them to the LLM, return a ready-to-use agent. + Important: Avoids doing awaits in __init__. + """ + # should return a LangChain Chat model supporting .bind_tools(...) + llm = get_llm(model_id=model_id) + # after, we call init() + self = cls(mcp_url, jwt_supplier, timeout, llm) + + tools = await self._list_tools() + if not tools: + logger.warning("No tools discovered at %s", mcp_url) + self._tools_cache = tools + + schemas = [self._tool_to_schema(t) for t in tools] + + # wrapped with schemas_to_pyd to solve compatibility issues with non-cohere models + pyd_models = schemas_to_pydantic_models(schemas) + + if DEBUG: + log_tool_schemas(pyd_models, self.logger) + + self.model_with_tools = self.llm.bind_tools(pyd_models) + + return self + + def _build_messages( + self, + history: Sequence[Dict[str, Any]], + system_prompt: str, + current_user_prompt: str, + *, + max_history: Optional[ + int + ] = MAX_HISTORY, # keep only the last N items; None = keep all + exclude_last: bool = True, # drop the very last history entry before building + ) -> List[Any]: + """ + Create: [SystemMessage(system_prompt), , + HumanMessage(current_user_prompt)] + History items are dicts like {"role": "user"|"assistant", "content": "..."} + in chronological order. + """ + # 1) Trim to the last `max_history` entries (if set) + if max_history is not None and max_history > 0: + working = list(history[-max_history:]) + else: + working = list(history) + + # 2) Optionally remove the final entry from trimmed history + if exclude_last and working: + working = working[:-1] + + # 3) Build LangChain messages + msgs: List[Any] = [SystemMessage(content=system_prompt)] + for m in working: + role = (m.get("role") or "").lower() + content: Optional[str] = m.get("content") + if not content: + continue + if role == "user": + msgs.append(HumanMessage(content=content)) + elif role == "assistant": + msgs.append(AIMessage(content=content)) + # ignore other/unknown roles (e.g., 'system', 'tool') in this simple variant + + # 4) Add the current user prompt + msgs.append(HumanMessage(content=current_user_prompt)) + return msgs + + # + # ---------- main loop ---------- + # + async def answer(self, question: str, history: list = None) -> str: + """ + Run the LLM+MCP loop until the model stops calling tools. + """ + # add the SYSTEM PROMPT and current request + messages = self._build_messages( + history=history, + system_prompt=SYSTEM_PROMPT, + current_user_prompt=question, + ) + + while True: + ai: AIMessage = await self.model_with_tools.ainvoke(messages) + + if DEBUG: + log_history_tail(messages, k=4, log=self.logger) + log_ai_tool_calls(ai, log=self.logger) + + tool_calls = getattr(ai, "tool_calls", None) or [] + if not tool_calls: + # Final answer + return ai.content + + messages.append(ai) # keep the AI msg that requested tools + + # Execute tool calls and append ToolMessage for each + tool_msgs = [] + for tc in tool_calls: + name = tc["name"] + args = tc.get("args") or {} + try: + # here we call the tool + result = await self._call_tool(name, args) + payload = ( + getattr(result, "data", None) + or getattr(result, "content", None) + or str(result) + ) + # to avoid double encoding + tool_content = ( + json.dumps(payload, ensure_ascii=False) + if isinstance(payload, (dict, list)) + else str(payload) + ) + tm = ToolMessage( + content=tool_content, + # must match the call id + tool_call_id=tc["id"], + name=name, + ) + messages.append(tm) + + # this is for debugging, if needed + if DEBUG: + tool_msgs.append(tm) + except Exception as e: + messages.append( + ToolMessage( + content=json.dumps({"error": str(e)}), + tool_call_id=tc["id"], + name=name, + ) + ) + if DEBUG: + check_linkage_or_die(ai, tool_msgs, log=self.logger) + _dump_pair_for_oci_debug(messages, self.logger) + + +# ---- Example CLI usage ---- +# this code is good for CLI, not Streamlit. See ui_mcp_agent.py +if __name__ == "__main__": + QUESTION = "Tell me about Luigi Saetta. I need his e-mail address also." + agent = asyncio.run(AgentWithMCP.create()) + print(asyncio.run(agent.answer(QUESTION))) diff --git a/ai/gen-ai-agents/mcp-oci-integration/log_helpers.py b/ai/gen-ai-agents/mcp-oci-integration/log_helpers.py new file mode 100644 index 000000000..b3de5b00f --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/log_helpers.py @@ -0,0 +1,143 @@ +""" +log helpers, to help debugging +""" + +from __future__ import annotations +import json +import logging +from typing import Any, Dict, List, Sequence +from langchain_core.messages import BaseMessage, AIMessage, ToolMessage + +logger = logging.getLogger(__name__) # module-level logger + + +def short(s: str, n: int = 500) -> str: + """ + shorten a msg + """ + return s if len(s) <= n else s[:n] + f"... <{len(s)-n} more>" + + +def msg_summary(m: BaseMessage) -> Dict[str, Any]: + """ + provide the msg in a structured format for logging + """ + d = {"type": m.__class__.__name__} + if hasattr(m, "content"): + content = getattr(m, "content") or "" + d["content.len"] = len(content) + d["content.preview"] = short(content, 200) + if isinstance(m, AIMessage): + d["tool_calls.count"] = len(getattr(m, "tool_calls", []) or []) + if isinstance(m, ToolMessage): + d["tool_call_id"] = getattr(m, "tool_call_id", None) + return d + + +def log_tool_schemas(schemas: List[Any], log: logging.Logger | None = None) -> None: + """ + log schemas + """ + log = log or logger + log.info("=== Bound tool schemas (%d) ===", len(schemas)) + for i, s in enumerate(schemas): + name = ( + getattr(s, "__name__", None) + or getattr(s, "name", None) + or getattr(s, "title", "") + ) + log.info("[%d] %s | type=%s", i, name, type(s)) + + +def log_history_tail( + messages: Sequence[BaseMessage], k: int = 6, log: logging.Logger | None = None +) -> None: + """ + log history tail + """ + log = log or logger + log.info( + "=== History tail (last %d of %d) ===", min(k, len(messages)), len(messages) + ) + for i, m in enumerate(messages[-k:]): + log.info(" %d: %s", len(messages) - k + i, msg_summary(m)) + + +def log_ai_tool_calls(ai: AIMessage, log: logging.Logger | None = None) -> None: + """ + log tool calls + """ + log = log or logger + calls = getattr(ai, "tool_calls", []) or [] + log.info("=== Assistant tool_calls (%d) ===", len(calls)) + for i, c in enumerate(calls): + log.info( + " #%d id=%s name=%s args=%s", + i, + c.get("id"), + c.get("name"), + json.dumps(c.get("args") or {}, ensure_ascii=False), + ) + raw = getattr(ai, "additional_kwargs", None) + if raw: + log.info( + "additional_kwargs (assistant): %s", + short(json.dumps(raw, ensure_ascii=False), 1000), + ) + + +def check_linkage_or_die( + ai: AIMessage, tool_msgs: List[ToolMessage], log: logging.Logger | None = None +) -> None: + """ + further check and logs + """ + log = log or logger + want = {c["id"] for c in (getattr(ai, "tool_calls", []) or []) if "id" in c} + got = {tm.tool_call_id for tm in tool_msgs} + missing = want - got + extra = got - want + log.info( + "=== Linkage check === want=%s got=%s missing=%s extra=%s", + list(want), + list(got), + list(missing), + list(extra), + ) + if missing: + raise RuntimeError(f"Missing ToolMessage(s) for ids: {list(missing)}") + if extra: + raise RuntimeError(f"ToolMessage(s) reference unknown ids: {list(extra)}") + + +def _dump_pair_for_oci_debug(messages, log): + """ + Find the last assistant message and the following tool messages + """ + last_ai = None + tools_for_last_ai = [] + for m in reversed(messages): + if isinstance(m, ToolMessage): + tools_for_last_ai.append( + {"tool_call_id": m.tool_call_id, "content.len": len(m.content or "")} + ) + continue + if isinstance(m, AIMessage): + last_ai = m + break + # stop scan if we encounter another role before reaching an AIMessage + break + + log.info("=== OCI preflight pair ===") + if last_ai is None: + log.warning("No trailing AIMessage found before tool messages.") + return + log.info( + "AIMessage: content.len=%s, tool_calls=%s", + len(last_ai.content or ""), + [ + {"id": c.get("id"), "name": c.get("name")} + for c in (last_ai.tool_calls or []) + ], + ) + log.info("ToolMessages: %s", tools_for_last_ai) diff --git a/ai/gen-ai-agents/mcp-oci-integration/mcp_deep_research_with_iam.py b/ai/gen-ai-agents/mcp-oci-integration/mcp_deep_research_with_iam.py new file mode 100644 index 000000000..e0f63708c --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/mcp_deep_research_with_iam.py @@ -0,0 +1,235 @@ +""" +Semantic Search exposed as an MCP tool +with added security with OCI IAM and JWT tokens. +This version implements OpenAI spec and can be integrated +with ChatGPT Deep Research. + +To be fully compliant you need to implement two tools: +* search (returns the list of IDs) +* fetch (return the text given the ID) + +Author: L. Saetta +License: MIT +""" + +from typing import Annotated, Dict, Any +from pydantic import Field + +from fastmcp import FastMCP + +# to verify the JWT token +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.dependencies import get_http_headers + +from utils import get_console_logger +from oci_models import get_embedding_model, get_oracle_vs +from db_utils import ( + get_connection, + list_collections, + list_books_in_collection, + fetch_text_by_id, +) +from config import EMBED_MODEL_TYPE, DEFAULT_COLLECTION +from config import DEBUG, IAM_BASE_URL, ENABLE_JWT_TOKEN, ISSUER, AUDIENCE +from config import TRANSPORT, HOST, PORT + +logger = get_console_logger() + +AUTH = None + +if ENABLE_JWT_TOKEN: + # check that a valid JWT token is provided + # see docs here: https://gofastmcp.com/servers/auth/bearer + AUTH = JWTVerifier( + # this is the url to get the public key from IAM + # the PK is used to check the JWT + jwks_uri=f"{IAM_BASE_URL}/admin/v1/SigningCert/jwk", + issuer=ISSUER, + audience=AUDIENCE, + ) + +# create the app +# cool, the OAUTH 2.1 provider is pluggable +mcp = FastMCP("Demo Deep Search as MCP server", auth=AUTH) + + +# +# Helper functions +# +def log_headers(): + """ + if DEBUG log the headers in the HTTP request + """ + if DEBUG: + headers = get_http_headers(include_all=True) + logger.info("Headers: %s", headers) + + +# +# MCP tools definition +# +@mcp.tool +def search( + query: Annotated[ + str, Field(description="The deep search query to find relevant documents.") + ], + top_k: Annotated[int, Field(description="TOP_K parameter for search")] = 10, + collection_name: Annotated[ + str, Field(description="The name of DB table") + ] = DEFAULT_COLLECTION, +) -> dict: + """ + Perform a deep search based on the provided query. + Args: + query (str): The search query. + top_k (int): The number of top results to return. + collection_name (str): The name of the collection (DB table) to search in. + Returns: + dict: a dictionary containing the relevant documents. + """ + # here only log, no verification here, delegated to AuthProvider + if ENABLE_JWT_TOKEN: + log_headers() + + try: + # must be the same embedding model used during load in the Vector Store + embed_model = get_embedding_model(EMBED_MODEL_TYPE) + + # get a connection to the DB and init VS + with get_connection() as conn: + v_store = get_oracle_vs( + conn=conn, + collection_name=collection_name, + embed_model=embed_model, + ) + relevant_docs = v_store.similarity_search(query=query, k=top_k) + + if DEBUG: + logger.info("Result from the similarity search:") + logger.info(relevant_docs) + + except Exception as e: + logger.error("Error in MCP deep search: %s", e) + error = str(e) + return {"error": error} + + # process relevant docs to be OpenAI compliant + results = [] + for doc in relevant_docs: + result = { + "id": doc.metadata["ID"], + "title": doc.metadata["source"], + # here we return a snippet of text + "text": doc.page_content, + "url": "", + } + results.append(result) + + if DEBUG: + logger.info(result) + + return {"results": results} + + +@mcp.tool +def fetch( + id: Annotated[ + str, Field(description="The ID of the document as returned by search call.") + ], + collection_name: str = DEFAULT_COLLECTION, +) -> Dict[str, Any]: + """ + Retrieve complete document content by ID for detailed + analysis and citation. This tool fetches the full document + content from Oracle 23AI. Use this after finding + relevant documents with the search tool to get complete + information for analysis and proper citation. + + Args: + id: doc ID from Vector Store. It is the value retrieved by search, NOT the document name. + + Returns: + Complete document with id, title, full text content, + optional URL, and metadata + + Raises: + ValueError: If the specified ID is not found + """ + if not id: + raise ValueError("Document ID is required") + + # execute the query on the DB + result = fetch_text_by_id(id=id, collection_name=collection_name) + + # formatting result as required by OpenAI specs + # see: https://platform.openai.com/docs/mcp#create-an-mcp-server + # we could add metadata + result = { + "id": id, + "title": result["source"], + "text": result["text_value"], + "url": "", + "metadata": None, + } + + if DEBUG: + logger.info(result) + + return result + + +@mcp.tool +def get_collections() -> list: + """ + Get the list of collections (DB tables) available in the Oracle Vector Store. + Returns: + list: A list of collection names. + """ + # check that a valid JWT is provided + if ENABLE_JWT_TOKEN: + log_headers() + + return list_collections() + + +@mcp.tool +def get_books_in_collection( + collection_name: Annotated[ + str, Field(description="The name of the collection (DB table) to search in.") + ] = DEFAULT_COLLECTION, +) -> list: + """ + Get the list of books in a specific collection. + Args: + collection_name (str): The name of the collection (DB table) to search in. + Returns: + list: A list of book titles in the specified collection. + """ + # check that a valid JWT is provided + if ENABLE_JWT_TOKEN: + log_headers() + + try: + books = list_books_in_collection(collection_name) + return books + except Exception as e: + logger.error("Error getting books in collection: %s", e) + return [] + + +# +# Run the MCP server +# +if __name__ == "__main__": + if DEBUG: + LOG_LEVEL = "DEBUG" + else: + LOG_LEVEL = "INFO" + + mcp.run( + transport=TRANSPORT, + # Bind to all interfaces + host=HOST, + port=PORT, + log_level=LOG_LEVEL, + ) diff --git a/ai/gen-ai-agents/mcp-oci-integration/mcp_selectai.py b/ai/gen-ai-agents/mcp-oci-integration/mcp_selectai.py new file mode 100644 index 000000000..e5a2f8179 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/mcp_selectai.py @@ -0,0 +1,101 @@ +""" +Text2SQL MCP server based on ADB Select AI + +It requires that a Select AI profile has already been created +in the DB schema used for the DB connection. +""" + +from fastmcp import FastMCP + +# to verify the JWT token +# if you don't need to add security, you can remove this +# uses the new verifier from latest FastMCP +from fastmcp.server.auth.providers.jwt import JWTVerifier + +# here is the function that calls Select AI +from db_utils import generate_sql_from_prompt, execute_generated_sql + +from config import ( + # first four needed only to manage JWT + ENABLE_JWT_TOKEN, + IAM_BASE_URL, + ISSUER, + AUDIENCE, + TRANSPORT, + # needed only if transport is streamable-http + HOST, + PORT, + # select ai + SELECT_AI_PROFILE, +) + +AUTH = None + +# +# if you don't need to add security, you can remove this part and set +# AUTH = None, or simply set ENABLE_JWT_TOKEN = False +# +if ENABLE_JWT_TOKEN: + # check that a valid JWT token is provided + AUTH = JWTVerifier( + # this is the url to get the public key from IAM + # the PK is used to check the JWT + jwks_uri=f"{IAM_BASE_URL}/admin/v1/SigningCert/jwk", + issuer=ISSUER, + audience=AUDIENCE, + ) + +mcp = FastMCP("OCI Select AI MCP server", auth=AUTH) + +# helpers + + +# +# MCP tools definition +# add and write the code for the tools here +# mark each tool with the annotation +# +@mcp.tool +def generate_sql(user_request: str) -> str: + """ + Return the SQL generated for the user request. + + Args: + user_request (str): the request to be translated in SQL. + + Returns: + str: the SQL generated. + + Examples: + >>> generate_sql("List top 5 customers by sales") + SQL... + """ + return generate_sql_from_prompt(SELECT_AI_PROFILE, user_request) + + +@mcp.tool +def execute_sql(sql: str): + """ + Execute the SQL generated + """ + return execute_generated_sql(sql) + + +# +# Run the Select AI MCP server +# +if __name__ == "__main__": + if TRANSPORT not in {"stdio", "streamable-http"}: + raise RuntimeError(f"Unsupported TRANSPORT: {TRANSPORT}") + + # don't use sse! it is deprecated! + if TRANSPORT == "stdio": + # stdio doesn’t support host/port args + mcp.run(transport=TRANSPORT) + else: + # For streamable-http transport, host/port are valid + mcp.run( + transport=TRANSPORT, + host=HOST, + port=PORT, + ) diff --git a/ai/gen-ai-agents/mcp-oci-integration/mcp_semantic_search_with_iam.py b/ai/gen-ai-agents/mcp-oci-integration/mcp_semantic_search_with_iam.py new file mode 100644 index 000000000..f57122946 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/mcp_semantic_search_with_iam.py @@ -0,0 +1,165 @@ +""" +Semantic Search exposed as an MCP tool +with added security with OCI IAM and JWT tokens + +Author: L. Saetta +License: MIT +""" + +from typing import Annotated +from pydantic import Field + +from fastmcp import FastMCP + +# to verify the JWT token +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.dependencies import get_http_headers + +from utils import get_console_logger +from oci_models import get_embedding_model, get_oracle_vs +from db_utils import get_connection, list_collections, list_books_in_collection +from config import EMBED_MODEL_TYPE, DEFAULT_COLLECTION +from config import DEBUG, IAM_BASE_URL, ENABLE_JWT_TOKEN, ISSUER, AUDIENCE +from config import TRANSPORT, HOST, PORT + +logger = get_console_logger() + +AUTH = None + +if ENABLE_JWT_TOKEN: + # check that a valid JWT token is provided + # see docs here: https://gofastmcp.com/servers/auth/bearer + AUTH = JWTVerifier( + # this is the url to get the public key from IAM + # the PK is used to check the JWT + jwks_uri=f"{IAM_BASE_URL}/admin/v1/SigningCert/jwk", + issuer=ISSUER, + audience=AUDIENCE, + ) + +# create the app +# cool, the OAUTH 2.1 provider is pluggable +mcp = FastMCP("Demo Semantic Search as MCP server", auth=AUTH) + + +# +# Helper functions +# +def log_headers(): + """ + if DEBUG log the headers in the HTTP request + """ + if DEBUG: + headers = get_http_headers(include_all=True) + logger.info("Headers: %s", headers) + + +# +# MCP tools definition +# +@mcp.tool +def get_collections() -> list: + """ + Get the list of collections (DB tables) available in the Oracle Vector Store. + Returns: + list: A list of collection names. + """ + # check that a valid JWT is provided + if ENABLE_JWT_TOKEN: + log_headers() + + return list_collections() + + +@mcp.tool +def get_books_in_collection( + collection_name: Annotated[ + str, Field(description="The name of the collection (DB table) to search in.") + ] = DEFAULT_COLLECTION, +) -> list: + """ + Get the list of books in a specific collection. + Args: + collection_name (str): The name of the collection (DB table) to search in. + Returns: + list: A list of book titles in the specified collection. + """ + # check that a valid JWT is provided + if ENABLE_JWT_TOKEN: + log_headers() + + try: + books = list_books_in_collection(collection_name) + return books + except Exception as e: + logger.error("Error getting books in collection: %s", e) + return [] + + +@mcp.tool +def search( + query: Annotated[ + str, Field(description="The search query to find relevant documents.") + ], + top_k: Annotated[int, Field(description="TOP_K parameter for search")] = 5, + collection_name: Annotated[ + str, Field(description="The name of DB table") + ] = DEFAULT_COLLECTION, +) -> dict: + """ + Perform a semantic search based on the provided query. + Args: + query (str): The search query. + top_k (int): The number of top results to return. Must be at least 5. + collection_name (str): The name of the collection (DB table) to search in. + Returns: + dict: a dictionary containing the relevant documents. + """ + # here only log + if ENABLE_JWT_TOKEN: + log_headers() + # no verification here, delegated to BearerAuthProvider + + try: + # must be the same embedding model used during load in the Vector Store + embed_model = get_embedding_model(EMBED_MODEL_TYPE) + + # get a connection to the DB and init VS + with get_connection() as conn: + v_store = get_oracle_vs( + conn=conn, + collection_name=collection_name, + embed_model=embed_model, + ) + relevant_docs = v_store.similarity_search(query=query, k=top_k) + + if DEBUG: + logger.info("Result from the similarity search:") + logger.info(relevant_docs) + + except Exception as e: + logger.error("Error in MCP similarity search: %s", e) + error = str(e) + return {"error": error} + + result = {"relevant_docs": relevant_docs} + + return result + + +# +# Run the MCP server +# +if __name__ == "__main__": + if DEBUG: + LOG_LEVEL = "DEBUG" + else: + LOG_LEVEL = "INFO" + + mcp.run( + transport=TRANSPORT, + # Bind to all interfaces + host=HOST, + port=PORT, + log_level=LOG_LEVEL, + ) diff --git a/ai/gen-ai-agents/mcp-oci-integration/mcp_servers_config.py b/ai/gen-ai-agents/mcp-oci-integration/mcp_servers_config.py new file mode 100644 index 000000000..bef199fb9 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/mcp_servers_config.py @@ -0,0 +1,12 @@ +""" +MCP server config + +You can put the infor required to access MCP server here +""" + +MCP_SERVERS_CONFIG = { + "default": { + "transport": "streamable_http", + "url": "http://localhost:9000/mcp", + }, +} diff --git a/ai/gen-ai-agents/mcp-oci-integration/minimal_mcp_server.py b/ai/gen-ai-agents/mcp-oci-integration/minimal_mcp_server.py new file mode 100644 index 000000000..0e733157c --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/minimal_mcp_server.py @@ -0,0 +1,107 @@ +""" +Minimal MCP server + +This should be the starting point for any MCP server built with FastMCP. +This is the version with new FastMCP library. +Biggest difference: the class used to verify JWT. +""" + +from fastmcp import FastMCP + +# to verify the JWT token +# if you don't need to add security, you can remove this +# uses the new verifier from latest FastMCP +from fastmcp.server.auth.providers.jwt import JWTVerifier + +from config import ( + # first four needed only to manage JWT + ENABLE_JWT_TOKEN, + IAM_BASE_URL, + ISSUER, + AUDIENCE, + TRANSPORT, + # needed only if transport is streamable-http + HOST, + PORT, +) + +AUTH = None + +# +# if you don't need to add security, you can remove this part and set +# AUTH = None, or simply set ENABLE_JWT_TOKEN = False +# +if ENABLE_JWT_TOKEN: + # check that a valid JWT token is provided + AUTH = JWTVerifier( + # this is the url to get the public key from IAM + # the PK is used to check the JWT + jwks_uri=f"{IAM_BASE_URL}/admin/v1/SigningCert/jwk", + issuer=ISSUER, + audience=AUDIENCE, + ) + +mcp = FastMCP("OCI MCP server with few lines of code", auth=AUTH) + + +# +# MCP tools definition +# add and write the code for the tools here +# mark each tool with the annotation +# +@mcp.tool +def say_the_truth(user: str) -> str: + """ + Return a secret truth message addressed to the specified user. + + Args: + user (str): The name or identifier of the user to whom the truth is addressed. + + Returns: + str: A message containing a secret truth about the user. + + Examples: + >>> say_the_truth("Luigi") + "Luigi: Less is more!" + """ + # here you'll put the code that reads and return the info requested + # it is important to provide a good description of the tool in the docstring + return f"{user}: Less is more!" + + +@mcp.tool +def get_weather(location: str) -> str: + """ + Provide a human-readable description of the current weather in the given location. + + Args: + location (str): The name of the city or area for which to fetch weather information. + + Returns: + str: A description of current weather conditions in `location`. + + Examples: + >>> get_weather("Rome") + "In Rome: weather is fine!" + """ + return f"In {location}: weather is fine!" + + +# +# Run the MCP server +# +if __name__ == "__main__": + if TRANSPORT not in {"stdio", "streamable-http"}: + raise RuntimeError(f"Unsupported TRANSPORT: {TRANSPORT}") + + # don't use sse! it is deprecated! + if TRANSPORT == "stdio": + # stdio doesn’t support host/port args + mcp.run(transport=TRANSPORT) + else: + # For streamable-http transport, host/port are valid + mcp.run( + transport=TRANSPORT, + host=HOST, + port=PORT, + ) diff --git a/ai/gen-ai-agents/mcp-oci-integration/oci_jwt_client.py b/ai/gen-ai-agents/mcp-oci-integration/oci_jwt_client.py new file mode 100644 index 000000000..e24db6506 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/oci_jwt_client.py @@ -0,0 +1,107 @@ +""" +Client to get the JWT token from OCI IAM + +Author: L. Saetta +License: MIT + +for now it assumes API_KEY auth, can be changed for INSTANCE_PRINCIPAL +""" + +import base64 +import oci +import requests +from utils import get_console_logger +from config import DEBUG + +# this is the cliend_id defined in the config of the +# confidential application in OCI IAM +from config_private import OCI_CLIENT_ID + +logger = get_console_logger() + + +class OCIJWTClient: + """ + Client for obtaining JWT access tokens from Oracle Identity Cloud Service (IDCS) + via the OAuth2 client credentials grant. + + Attributes: + base_url (str): Base URL for the IDCS tenant. + scope (str): OAuth2 scope to include in the token request. + client_id (str): OCI client ID (from config). + client_secret (str): OCI client secret (from config). + token_url (str): Full URL for the token endpoint. + + Methods: + get_token() -> Tuple[str, str, int]: + Requests a token and returns (access_token, token_type, expires_in). + """ + + def __init__(self, base_url, scope, secret_ocid): + """ + Initializes the token client. + + Args: + base_url: The base URL of the IDCS tenant. + scope: The requested OAuth2 scope. + secret_ocid: the ocid of the secret in the vault containing client_secret + """ + self.base_url = base_url + self.scope = scope + # this is the endpoint to request a JWT token + self.token_url = f"{self.base_url}/oauth2/v1/token" + self.client_id = OCI_CLIENT_ID + self.client_secret = self.get_client_secret(secret_ocid) + self.timeout = 60 + + def get_client_secret(self, secret_ocid: str): + """ + Read the client secret from OCI vault + """ + oci_config = oci.config.from_file() + secrets_client = oci.secrets.SecretsClient(oci_config) + + # Retrieve the current secret bundle + response = secrets_client.get_secret_bundle(secret_id=secret_ocid) + b64 = response.data.secret_bundle_content.content + + # Decode and use + return base64.b64decode(b64).decode("utf-8") + + def get_token(self): + """ + Requests a client_credentials access token from IDCS. + + Returns: + Tuple of access token (str), token type (str), and expiration (int seconds). + + Raises: + HTTPError if the request fails. + """ + data = {"grant_type": "client_credentials", "scope": self.scope} + headers = {"Content-Type": "application/x-www-form-urlencoded"} + response = requests.post( + self.token_url, + data=data, + headers=headers, + # auth is like basic auth + auth=(self.client_id, self.client_secret), + timeout=self.timeout, + ) + + if DEBUG: + logger.info("-------------------------------------------") + logger.info("---- HTTP response text with JWT token ----") + logger.info("-------------------------------------------") + logger.info(response.text) + + # check for any error + response.raise_for_status() + + token_data = response.json() + + return ( + token_data["access_token"], + token_data["token_type"], + token_data["expires_in"], + ) diff --git a/ai/gen-ai-agents/mcp-oci-integration/oci_models.py b/ai/gen-ai-agents/mcp-oci-integration/oci_models.py new file mode 100644 index 000000000..096ed8599 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/oci_models.py @@ -0,0 +1,136 @@ +""" +File name: oci_models.py +Author: Luigi Saetta +Date last modified: 2025-06-30 +Python Version: 3.11 + +Description: + This module enables easy access to OCI GenAI LLM/Embeddings. + + +Usage: + Import this module into other scripts to use its functions. + Example: + from oci_models import get_llm + +License: + This code is released under the MIT License. + +Notes: + This is a part of a demo showing how to implement an advanced + RAG solution as a LangGraph agent. + + modified to support xAI and OpenAI models through Langchain + +Warnings: + This module is in development, may change in future versions. +""" + +# switched to the new OCI langchain integration +from langchain_oci import ChatOCIGenAI +from langchain_oci import OCIGenAIEmbeddings +from langchain_community.vectorstores.utils import DistanceStrategy +from langchain_oracledb.vectorstores import OracleVS + +from custom_rest_embeddings import CustomRESTEmbeddings +from utils import get_console_logger +from config import ( + AUTH, + SERVICE_ENDPOINT, + # used only for defaults + LLM_MODEL_ID, + TEMPERATURE, + MAX_TOKENS, + EMBED_MODEL_ID, + NVIDIA_EMBED_MODEL_URL, +) +from config_private import COMPARTMENT_ID + +logger = get_console_logger() + +ALLOWED_EMBED_MODELS_TYPE = {"OCI", "NVIDIA"} + +# for gpt5, since max tokens is not supported +MODELS_WITHOUT_KWARGS = { + "openai.gpt-5", + "openai.gpt-4o-search-preview", + "openai.gpt-4o-search-preview-2025-03-11", +} + + +def get_llm(model_id=LLM_MODEL_ID, temperature=TEMPERATURE, max_tokens=MAX_TOKENS): + """ + Initialize and return an instance of ChatOCIGenAI with the specified configuration. + + Returns: + ChatOCIGenAI: An instance of the OCI GenAI language model. + """ + if model_id not in MODELS_WITHOUT_KWARGS: + _model_kwargs = { + "temperature": temperature, + "max_tokens": max_tokens, + } + else: + # for some models (OpenAI search) you cannot set those params + _model_kwargs = None + + llm = ChatOCIGenAI( + auth_type=AUTH, + model_id=model_id, + service_endpoint=SERVICE_ENDPOINT, + compartment_id=COMPARTMENT_ID, + # changed to solve OpenAI issue + is_stream=False, + model_kwargs=_model_kwargs, + ) + return llm + + +def get_embedding_model(model_type="OCI"): + """ + Initialize and return an instance of OCIGenAIEmbeddings with the specified configuration. + Returns: + OCIGenAIEmbeddings: An instance of the OCI GenAI embeddings model. + """ + # check model type + if model_type not in ALLOWED_EMBED_MODELS_TYPE: + raise ValueError( + f"Invalid value for model_type: must be one of {ALLOWED_EMBED_MODELS_TYPE}" + ) + + embed_model = None + + if model_type == "OCI": + embed_model = OCIGenAIEmbeddings( + auth_type=AUTH, + model_id=EMBED_MODEL_ID, + service_endpoint=SERVICE_ENDPOINT, + compartment_id=COMPARTMENT_ID, + ) + elif model_type == "NVIDIA": + embed_model = CustomRESTEmbeddings( + api_url=NVIDIA_EMBED_MODEL_URL, model=EMBED_MODEL_ID + ) + + logger.info("Embedding model is: %s", EMBED_MODEL_ID) + + return embed_model + + +def get_oracle_vs(conn, collection_name, embed_model): + """ + Initialize and return an instance of OracleVS for vector search. + + Args: + conn: The database connection object. + collection_name (str): The name of the collection (DB table) to search in. + embed_model: The embedding model to use for vector search. + """ + oracle_vs = OracleVS( + client=conn, + table_name=collection_name, + distance_strategy=DistanceStrategy.COSINE, + embedding_function=embed_model, + ) + + return oracle_vs diff --git a/ai/gen-ai-agents/mcp-oci-integration/readme.txt b/ai/gen-ai-agents/mcp-oci-integration/readme.txt new file mode 100644 index 000000000..7ba3cb285 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/readme.txt @@ -0,0 +1,4 @@ +Conda environment to use: + +custom_rag_agent_2026 + diff --git a/ai/gen-ai-agents/mcp-oci-integration/requirements.txt b/ai/gen-ai-agents/mcp-oci-integration/requirements.txt new file mode 100644 index 000000000..f3a38e9da --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/requirements.txt @@ -0,0 +1,126 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +altair==5.5.0 +annotated-types==0.7.0 +anyio==4.10.0 +astroid==3.3.11 +attrs==25.3.0 +Authlib==1.6.3 +black==25.1.0 +blinker==1.9.0 +cachetools==6.2.0 +certifi==2025.8.3 +cffi==2.0.0 +charset-normalizer==3.4.3 +circuitbreaker==2.1.3 +click==8.2.1 +cryptography==44.0.3 +cyclopts==3.24.0 +dataclasses-json==0.6.7 +dill==0.4.0 +dnspython==2.8.0 +docstring_parser==0.17.0 +docutils==0.22 +email-validator==2.3.0 +exceptiongroup==1.3.0 +fastmcp==2.12.2 +frozenlist==1.7.0 +gitdb==4.0.12 +GitPython==3.1.45 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +httpx-sse==0.4.1 +idna==3.10 +isodate==0.7.2 +isort==6.0.1 +Jinja2==3.1.6 +jsonpatch==1.33 +jsonpointer==3.0.0 +jsonschema==4.25.1 +jsonschema-path==0.3.4 +jsonschema-specifications==2025.9.1 +langchain==0.3.27 +langchain-community==0.3.29 +langchain-core==0.3.76 +langchain-oci==0.1.5 +langchain-text-splitters==0.3.11 +langgraph==0.6.7 +langgraph-checkpoint==2.1.1 +langgraph-prebuilt==0.6.4 +langgraph-sdk==0.2.6 +langsmith==0.4.27 +lazy-object-proxy==1.12.0 +markdown-it-py==4.0.0 +MarkupSafe==3.0.2 +marshmallow==3.26.1 +mccabe==0.7.0 +mcp==1.14.0 +mdurl==0.1.2 +more-itertools==10.8.0 +multidict==6.6.4 +mypy_extensions==1.1.0 +narwhals==2.5.0 +numpy==2.3.3 +oci==2.160.2 +openapi-core==0.19.5 +openapi-pydantic==0.5.1 +openapi-schema-validator==0.6.3 +openapi-spec-validator==0.7.2 +oracledb==3.3.0 +orjson==3.11.3 +ormsgpack==1.10.0 +packaging==25.0 +pandas==2.3.2 +parse==1.20.2 +pathable==0.4.4 +pathspec==0.12.1 +pillow==11.3.0 +platformdirs==4.4.0 +propcache==0.3.2 +protobuf==6.32.1 +pyarrow==21.0.0 +pycparser==2.23 +pydantic==2.11.7 +pydantic-settings==2.10.1 +pydantic_core==2.33.2 +pydeck==0.9.1 +Pygments==2.19.2 +pylint==3.3.8 +pyOpenSSL==24.3.0 +pyperclip==1.9.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +python-multipart==0.0.20 +pytz==2025.2 +PyYAML==6.0.2 +referencing==0.36.2 +requests==2.32.5 +requests-toolbelt==1.0.0 +rfc3339-validator==0.1.4 +rich==14.1.0 +rich-rst==1.3.1 +rpds-py==0.27.1 +six==1.17.0 +smmap==5.0.2 +sniffio==1.3.1 +SQLAlchemy==2.0.43 +sse-starlette==3.0.2 +starlette==0.47.3 +streamlit==1.49.1 +tenacity==9.1.2 +toml==0.10.2 +tomlkit==0.13.3 +tornado==6.5.2 +typing-inspect==0.9.0 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +tzdata==2025.2 +urllib3==2.5.0 +uvicorn==0.35.0 +watchdog==6.0.0 +Werkzeug==3.1.1 +xxhash==3.5.0 +yarl==1.20.1 +zstandard==0.24.0 diff --git a/ai/gen-ai-agents/mcp-oci-integration/start_deep_research_with_iam.sh b/ai/gen-ai-agents/mcp-oci-integration/start_deep_research_with_iam.sh new file mode 100755 index 000000000..b324d96be --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/start_deep_research_with_iam.sh @@ -0,0 +1,2 @@ +python mcp_deep_research_with_iam.py + diff --git a/ai/gen-ai-agents/mcp-oci-integration/start_mcp_selectai.sh b/ai/gen-ai-agents/mcp-oci-integration/start_mcp_selectai.sh new file mode 100755 index 000000000..e54f5750b --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/start_mcp_selectai.sh @@ -0,0 +1,2 @@ +python mcp_selectai.py + diff --git a/ai/gen-ai-agents/mcp-oci-integration/start_mcp_semantic_search_with_oci_iam.sh b/ai/gen-ai-agents/mcp-oci-integration/start_mcp_semantic_search_with_oci_iam.sh new file mode 100755 index 000000000..f0b4eec8a --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/start_mcp_semantic_search_with_oci_iam.sh @@ -0,0 +1,2 @@ +python mcp_semantic_search_with_iam.py + diff --git a/ai/gen-ai-agents/mcp-oci-integration/start_mcp_ui.sh b/ai/gen-ai-agents/mcp-oci-integration/start_mcp_ui.sh new file mode 100755 index 000000000..38f37f3cf --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/start_mcp_ui.sh @@ -0,0 +1,2 @@ +streamlit run ui_mcp_agent.py + diff --git a/ai/gen-ai-agents/mcp-oci-integration/start_minimal_mcp_server.sh b/ai/gen-ai-agents/mcp-oci-integration/start_minimal_mcp_server.sh new file mode 100755 index 000000000..da7628107 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/start_minimal_mcp_server.sh @@ -0,0 +1,2 @@ +python minimal_mcp_server.py + diff --git a/ai/gen-ai-agents/mcp-oci-integration/test_selectai01.py b/ai/gen-ai-agents/mcp-oci-integration/test_selectai01.py new file mode 100644 index 000000000..fb83cef98 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/test_selectai01.py @@ -0,0 +1,19 @@ +""" +Test Select AI on SH schema +""" + +from db_utils import run_select_ai + +PROFILE_NAME = "OCI_GENERATIVE_AI_PROFILE" +NL_REQUEST = "List top 10 customers by sales in Europe" + +# Option A: one-shot (generate → execute) +cols, rows, sql_text = run_select_ai(NL_REQUEST, PROFILE_NAME) + +print("=== Generated SQL ===") +print(sql_text) + +print("----------------------") +print("Result columns:", cols) +for r in rows: + print(r) diff --git a/ai/gen-ai-agents/mcp-oci-integration/ui_mcp_agent.py b/ai/gen-ai-agents/mcp-oci-integration/ui_mcp_agent.py new file mode 100644 index 000000000..39e05cb34 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/ui_mcp_agent.py @@ -0,0 +1,118 @@ +""" +Streamlit UI for MCP servers +""" + +import asyncio +import traceback +import streamlit as st +from config import MODEL_LIST +from mcp_servers_config import MCP_SERVERS_CONFIG + +# this one contains the backend and the test code only for console +from llm_with_mcp import AgentWithMCP, default_jwt_supplier + +from utils import get_console_logger + +logger = get_console_logger() + +# ---------- Page setup ---------- +st.set_page_config(page_title="MCP UI", page_icon="🛠️", layout="wide") +st.title("🛠️ LLM powered by MCP") + +# ---------- Sidebar: connection settings ---------- +with st.sidebar: + st.header("Connection") + mcp_url = st.text_input("MCP URL", value=MCP_SERVERS_CONFIG["default"]["url"]) + + model_id = st.selectbox( + "Model", + MODEL_LIST, + index=0, + ) + timeout = st.number_input( + "Timeout (s)", min_value=5, max_value=300, value=60, step=5 + ) + + connect = st.button("🔌 Connect / Reload tools", use_container_width=True) + +# ---------- Session state ---------- +if "agent" not in st.session_state: + st.session_state.agent = None +if "chat" not in st.session_state: + # list of {"role": "user"|"assistant", "content": str} + st.session_state.chat = [] + + +def reset_conversation(): + """Reset the chat history.""" + st.session_state.chat = [] + + +# ---------- Connect / reload ---------- +if connect: + with st.spinner("Connecting to MCP server and loading tools…"): + try: + # Create an agent (async factory) and cache it in session_state + st.session_state.agent = asyncio.run( + AgentWithMCP.create( + mcp_url=mcp_url, + # returns a fresh raw JWT + jwt_supplier=default_jwt_supplier, + timeout=timeout, + model_id=model_id, + ) + ) + st.success("Connected. Tools loaded.") + except Exception as e: + st.session_state.agent = None + st.error(f"Failed to connect: {e}") + logger.error(e) + STACK_STR = traceback.format_exc() + logger.error(STACK_STR) + +# Reset button +if st.sidebar.button("Clear Chat History"): + reset_conversation() + +# ---------- Chat history (display) ---------- +for msg in st.session_state.chat: + with st.chat_message("user" if msg["role"] == "user" else "assistant"): + st.write(msg["content"]) + +# ---------- Input box ---------- +prompt = st.chat_input("Ask your question…") + +if prompt: + # Show the user message immediately + st.session_state.chat.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.write(prompt) + + if st.session_state.agent is None: + st.warning( + "Not connected. Click ‘Connect / Reload tools’ in the sidebar first." + ) + else: + with st.chat_message("assistant"): + with st.spinner("Thinking with support from MCP tools…"): + try: + ANSWER = asyncio.run( + # we pass also the history (chat) + st.session_state.agent.answer(prompt, st.session_state.chat) + ) + except Exception as e: + ANSWER = f"Error: {e}" + st.write(ANSWER) + st.session_state.chat.append({"role": "assistant", "content": ANSWER}) + +# ---------- The small debug panel in the bottom ---------- +with st.expander("🔎 Debug / State"): + st.json( + { + "connected": st.session_state.agent is not None, + "messages_in_memory": len(st.session_state.chat), + "mcp_url": mcp_url, + "model_id": model_id, + "timeout": timeout, + } + ) diff --git a/ai/gen-ai-agents/mcp-oci-integration/update_rows_with_id.py b/ai/gen-ai-agents/mcp-oci-integration/update_rows_with_id.py new file mode 100644 index 000000000..d3ee6ae75 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/update_rows_with_id.py @@ -0,0 +1,28 @@ +""" +Update metadata adding ID + +SQL to chek (it must return zero rows) + +SELECT ID, + RAWTOHEX(ID) AS ID_COLONNA, + JSON_VALUE(METADATA, '$.ID') AS ID_METADATA +FROM BOOKS +WHERE RAWTOHEX(ID) != JSON_VALUE(METADATA, '$.ID'); + +""" + +from db_utils import get_connection + +SQL = """ +UPDATE BOOKS +SET METADATA = JSON_TRANSFORM( + METADATA, + SET '$.ID' = RAWTOHEX(ID) +) +""" + +with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(SQL) + print(f"Rows updated: {cur.rowcount}") + conn.commit() diff --git a/ai/gen-ai-agents/mcp-oci-integration/utils.py b/ai/gen-ai-agents/mcp-oci-integration/utils.py new file mode 100644 index 000000000..e8a3fcba4 --- /dev/null +++ b/ai/gen-ai-agents/mcp-oci-integration/utils.py @@ -0,0 +1,146 @@ +""" +File name: utils.py +Author: Luigi Saetta +Date last modified: 2025-03-31 +Python Version: 3.11 + +Description: + Utility functions here. + +Usage: + Import this module into other scripts to use its functions. + Example: + from utils import ... + +License: + This code is released under the MIT License. + +Notes: + This is a part of a demo showing how to implement an advanced + RAG solution as a LangGraph agent. + +Warnings: + This module is in development, may change in future versions. +""" + +import os +from typing import List +import logging +import re +import json +from langchain.schema import Document + + +def get_console_logger(name: str = "ConsoleLogger", level: str = "INFO"): + """ + To get a logger to print on console + """ + logger = logging.getLogger(name) + + # to avoid duplication of logging + if not logger.handlers: + logger.setLevel(level) + + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + + formatter = logging.Formatter("%(asctime)s - %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + + logger.propagate = False + + return logger + + +def extract_text_triple_backticks(_text): + """ + Extracts all text enclosed between triple backticks (```) from a string. + + :param text: The input string to analyze. + :return: A list containing the texts found between triple backticks. + """ + logger = get_console_logger() + + # Uses (.*?) to capture text between backticks in a non-greedy way + pattern = r"```(.*?)```" + # re.DOTALL allows capturing multiline content + + try: + _result = [block.strip() for block in re.findall(pattern, _text, re.DOTALL)][0] + except Exception as e: + logger.info("no triple backtickes in extract_text_triple_backticks: %s", e) + + # try to be resilient, return the entire text + _result = _text + + return _result + + +def extract_json_from_text(text): + """ + Extracts JSON content from a given text and returns it as a Python dictionary. + + Args: + text (str): The input text containing JSON content. + + Returns: + dict: Parsed JSON data. + """ + try: + # Use regex to extract JSON content (contained between {}) + json_match = re.search(r"\{.*\}", text, re.DOTALL) + if json_match: + json_content = json_match.group(0) + return json.loads(json_content) + + # If no JSON content is found, raise an error + raise ValueError("No JSON content found in the text.") + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON format: {e}") from e + + +# for the loading utility +def remove_path_from_ref(ref_pathname): + """ + remove the path from source (ref) + """ + ref = ref_pathname + # check if / or \ is contained + if len(ref_pathname.split(os.sep)) > 0: + ref = ref_pathname.split(os.sep)[-1] + + return ref + + +def docs_serializable(docs: List[Document]) -> dict: + """ + Convert Langchain document in dict json serializable. + + (30/06/2025): this function has been introduced to transform Langchain Document in dict, + that can be easily serializable (for the streaming API) + Args: + docs (List[Document]): Lista di Document da convertire. + Returns: + """ + _docs_serializable = [ + {"page_content": doc.page_content, "metadata": doc.metadata or {}} + for doc in docs + ] + return _docs_serializable + + +def print_mcp_available_tools(tools): + """ + Print the available tools in a readable format. + + Args: + tools (list): List of tools to print. + """ + print("\n--- MCP Available tools:") + for tool in tools: + print(f"Tool: {tool.name} - {tool.description}") + print("Input Schema:") + pretty_schema = json.dumps(tool.inputSchema, indent=4, sort_keys=True) + print(pretty_schema) + print("")