From 2fa363277c508ce5b7fd3c966c6d4abece0a44fa Mon Sep 17 00:00:00 2001 From: luigisaetta Date: Tue, 30 Sep 2025 14:39:01 +0200 Subject: [PATCH] added first release mcp-oci-integration --- .../mcp-oci-integration/README.md | 84 +++++ .../mcp-oci-integration/check_code.sh | 6 + .../mcp-oci-integration/config.py | 107 ++++++ .../config_private_template.py | 33 ++ .../custom_rest_embeddings.py | 123 +++++++ .../mcp-oci-integration/db_utils.py | 258 ++++++++++++++ .../mcp-oci-integration/how_to_start_mcp.md | 71 ++++ .../mcp-oci-integration/images/mcp_cli.png | Bin 0 -> 102510 bytes .../mcp-oci-integration/integrate_chatgpt.md | 33 ++ .../mcp-oci-integration/llm_with_mcp.py | 336 ++++++++++++++++++ .../mcp-oci-integration/log_helpers.py | 143 ++++++++ .../mcp_deep_research_with_iam.py | 235 ++++++++++++ .../mcp-oci-integration/mcp_selectai.py | 101 ++++++ .../mcp_semantic_search_with_iam.py | 165 +++++++++ .../mcp-oci-integration/mcp_servers_config.py | 12 + .../mcp-oci-integration/minimal_mcp_server.py | 107 ++++++ .../mcp-oci-integration/oci_jwt_client.py | 107 ++++++ .../mcp-oci-integration/oci_models.py | 136 +++++++ .../mcp-oci-integration/readme.txt | 4 + .../mcp-oci-integration/requirements.txt | 126 +++++++ .../start_deep_research_with_iam.sh | 2 + .../mcp-oci-integration/start_mcp_selectai.sh | 2 + .../start_mcp_semantic_search_with_oci_iam.sh | 2 + .../mcp-oci-integration/start_mcp_ui.sh | 2 + .../start_minimal_mcp_server.sh | 2 + .../mcp-oci-integration/test_selectai01.py | 19 + .../mcp-oci-integration/ui_mcp_agent.py | 118 ++++++ .../update_rows_with_id.py | 28 ++ ai/gen-ai-agents/mcp-oci-integration/utils.py | 146 ++++++++ 29 files changed, 2508 insertions(+) create mode 100644 ai/gen-ai-agents/mcp-oci-integration/README.md create mode 100755 ai/gen-ai-agents/mcp-oci-integration/check_code.sh create mode 100644 ai/gen-ai-agents/mcp-oci-integration/config.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/config_private_template.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/custom_rest_embeddings.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/db_utils.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/how_to_start_mcp.md create mode 100644 ai/gen-ai-agents/mcp-oci-integration/images/mcp_cli.png create mode 100644 ai/gen-ai-agents/mcp-oci-integration/integrate_chatgpt.md create mode 100644 ai/gen-ai-agents/mcp-oci-integration/llm_with_mcp.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/log_helpers.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/mcp_deep_research_with_iam.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/mcp_selectai.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/mcp_semantic_search_with_iam.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/mcp_servers_config.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/minimal_mcp_server.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/oci_jwt_client.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/oci_models.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/readme.txt create mode 100644 ai/gen-ai-agents/mcp-oci-integration/requirements.txt create mode 100755 ai/gen-ai-agents/mcp-oci-integration/start_deep_research_with_iam.sh create mode 100755 ai/gen-ai-agents/mcp-oci-integration/start_mcp_selectai.sh create mode 100755 ai/gen-ai-agents/mcp-oci-integration/start_mcp_semantic_search_with_oci_iam.sh create mode 100755 ai/gen-ai-agents/mcp-oci-integration/start_mcp_ui.sh create mode 100755 ai/gen-ai-agents/mcp-oci-integration/start_minimal_mcp_server.sh create mode 100644 ai/gen-ai-agents/mcp-oci-integration/test_selectai01.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/ui_mcp_agent.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/update_rows_with_id.py create mode 100644 ai/gen-ai-agents/mcp-oci-integration/utils.py 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 0000000000000000000000000000000000000000..14aec9273e5b82fa754810078f4880687d3d1591 GIT binary patch literal 102510 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 AZ~y=R literal 0 HcmV?d00001 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("")