{
"cells": [
{
"metadata": {},
"cell_type": "markdown",
"source": [
"\n",
"# Google ADK Agent with RedisVL MCP and Gemini\n",
"\n",
"
\n",
"\n",
"## Introduction\n",
"\n",
"This notebook shows how to build a [Google ADK](https://google.github.io/adk-docs/) agent that connects to Redis through the [RedisVL MCP](https://docs.redisvl.com/en/stable/user_guide/how_to_guides/mcp.html) server. We will bootstrap a small movie catalog in Redis, generate an MCP config, wire ADK to the server, and run grounded movie-recommendation prompts.\n",
"\n",
"### Key Concepts\n",
"\n",
"Before diving into code, here is a quick orientation on the three technologies this recipe combines:\n",
"\n",
"**[Model Context Protocol (MCP)](https://modelcontextprotocol.io/)**\n",
"\n",
"MCP is an open standard that lets AI agents discover and call external tools through a uniform interface. An MCP *server* advertises a set of typed tools (search, upsert, etc.), and an MCP *client* inside the agent framework calls them. Communication happens over a *transport* -- common options include **stdio** (subprocess over stdin/stdout) and **Streamable HTTP** (an HTTP endpoint). This notebook uses Streamable HTTP for compatibility with notebook environments.\n",
"\n",
"**[Google Agent Development Kit (ADK)](https://google.github.io/adk-docs/)**\n",
"\n",
"ADK is Google's open-source framework for building AI agents in Python. It provides `LlmAgent` for wrapping a model with tools, `Runner` for executing multi-turn conversations, and `McpToolset` for connecting to MCP servers. ADK handles the MCP client lifecycle automatically -- it connects to the server, discovers the available tools, and maps them into the agent's tool list.\n",
"\n",
"**[RedisVL MCP Server](https://docs.redisvl.com/en/stable/user_guide/how_to_guides/mcp.html)**\n",
"\n",
"RedisVL ships an MCP server (`rvl mcp`) that exposes Redis search indexes as MCP tools. You provide a YAML config that binds an index to the server, and it advertises tools such as `search-records` and `upsert-records`. This gives any MCP-compatible agent framework (ADK, Claude Desktop, Cursor, etc.) direct access to Redis-backed retrieval without custom glue code.\n"
],
"id": "e34ddeb8f2eb38c8"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### Architecture\n",
"\n",
"The data flow in this recipe is:\n",
"\n",
"```\n",
"User prompt\n",
" |\n",
" v\n",
"Google ADK Agent (Gemini API)\n",
" | calls MCP tool via Streamable HTTP\n",
" v\n",
"RedisVL MCP Server (background process on localhost)\n",
" | executes vector similarity search\n",
" v\n",
"Redis (vector index)\n",
" | returns ranked results\n",
" v\n",
"RedisVL MCP Server\n",
" | returns structured results\n",
" v\n",
"Google ADK Agent\n",
" | grounds answer in evidence\n",
" v\n",
"Final response to user\n",
"```\n",
"\n",
"The agent never talks to Redis directly. All data access goes through the MCP tool interface, which means the same Redis index could be shared with other MCP-compatible clients without any code changes.\n",
"\n",
"> **Note on transport choice:** MCP supports stdio (subprocess communication) and HTTP-based transports (Streamable HTTP, SSE). This notebook uses **Streamable HTTP** because notebook environments like Colab and Jupyter replace `sys.stderr`/`sys.stdout` with custom stream objects that break stdio subprocess communication. Streamable HTTP runs the MCP server as an independent process and connects over a normal TCP socket, which works reliably everywhere.\n"
],
"id": "40134b232a201815"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## What We'll Build\n",
"\n",
"This tutorial is for readers who already know the basics of Redis and Python and want to see a minimal MCP-powered agent pattern end to end.\n",
"\n",
"**By the end you will be able to:**\n",
"- load a small JSON movie dataset into a Redis index with RedisVL\n",
"- configure RedisVL MCP to expose that index in read-only mode\n",
"- connect a Google ADK `LlmAgent` to the MCP server while using the Gemini API for the model\n",
"- inspect the structured Redis-backed evidence the agent retrieved before it answered\n",
"\n",
"## Outline\n",
"\n",
"1. Install packages.\n",
"2. Configure the Gemini API key and Redis.\n",
"3. Load the movie sample data and create a Redis index.\n",
"4. Generate an MCP YAML config for RedisVL.\n",
"5. Configure the MCP toolset and build the ADK agent.\n",
"6. Validate the MCP connection and define interaction helpers.\n",
"7. Run a first grounded prompt.\n",
"8. Try more prompts.\n",
"9. Exercise.\n"
],
"id": "4c99a5d016be70dc"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## 1. Install Packages\n",
"\n",
"We will use:\n",
"- `redisvl[mcp,sentence-transformers]` for indexing, local embeddings, and the MCP server\n",
"- `google-adk` for the agent runtime (connects to the Gemini API with an API key)\n"
],
"id": "9f0ccbd2fee886ab"
},
{
"metadata": {},
"cell_type": "code",
"source": "%pip install -q \"redisvl[mcp,sentence-transformers]>=0.17.1\" \"google-adk>=1.0.0\" pandas nest_asyncio\n",
"id": "2409cb4d884cc4f",
"outputs": [],
"execution_count": null
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### Colab Only: Download Data and Install Redis\n",
"\n",
"The next two cells are **only needed in Google Colab**. Skip them if you are running locally (the `resources/` directory is already part of the repository, and you should have Redis running).\n",
"\n",
"The first cell downloads the sample movie dataset. The second installs Redis with the Search module.\n"
],
"id": "b1aa4db4bdb1b9e"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"# NBVAL_SKIP\n",
"# Colab only: download the resources directory.\n",
"import os\n",
"if not os.path.exists(\"resources/movies.json\"):\n",
" !curl -sSL -o movies.json https://raw.githubusercontent.com/redis-developer/redis-ai-resources/main/python-recipes/MCP/resources/movies.json\n",
" !mkdir -p resources && mv movies.json resources/\n"
],
"id": "be58b76b03ee8540"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"# NBVAL_SKIP\n",
"# Colab only: install and start Redis with Search module.\n",
"%%sh\n",
"sudo apt-get install -y -qq lsb-release curl gpg > /dev/null\n",
"curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
"sudo chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg\n",
"echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
"sudo apt-get update -qq > /dev/null\n",
"sudo apt-get install -y -qq redis > /dev/null\n",
"\n",
"redis-server --version\n",
"redis-server --daemonize yes --loadmodule /usr/lib/redis/modules/redisearch.so"
],
"id": "5ef2299c5c8d0e"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## 2. Configure the Gemini API Key and Redis\n",
"\n",
"This notebook uses the **Gemini API** (via a simple API key) for the agent model. Redis indexing and RedisVL MCP query embeddings stay local with `HFTextVectorizer`, which keeps the retrieval setup simple and reproducible.\n",
"\n",
"You can get a free Gemini API key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey).\n"
],
"id": "40c3cefa7aa284cf"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:56:43.406875Z",
"start_time": "2026-04-22T14:56:42.561473Z"
}
},
"cell_type": "code",
"source": [
"# NBVAL_SKIP\n",
"import os\n",
"import sys\n",
"import json\n",
"import uuid\n",
"import warnings\n",
"from getpass import getpass\n",
"from pathlib import Path\n",
"\n",
"import nest_asyncio\n",
"import pandas as pd\n",
"import yaml\n",
"from IPython.display import display\n",
"\n",
"warnings.filterwarnings(\"ignore\")\n",
"nest_asyncio.apply()\n",
"\n",
"GOOGLE_API_KEY = os.getenv(\"GOOGLE_API_KEY\")\n",
"if not GOOGLE_API_KEY:\n",
" GOOGLE_API_KEY = getpass(\"GOOGLE_API_KEY: \")\n",
"os.environ[\"GOOGLE_API_KEY\"] = GOOGLE_API_KEY\n",
"\n",
"REDIS_URL = os.getenv(\"REDIS_URL\")\n",
"if not REDIS_URL:\n",
" REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\")\n",
" REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\")\n",
" REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\")\n",
" auth_part = f\":{REDIS_PASSWORD}@\" if REDIS_PASSWORD else \"\"\n",
" REDIS_URL = f\"redis://{auth_part}{REDIS_HOST}:{REDIS_PORT}\"\n",
"\n",
"print({\"redis_url\": REDIS_URL})\n"
],
"id": "65f99d4230a7a546",
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'redis_url': 'redis://localhost:6379'}\n"
]
}
],
"execution_count": 1
},
{
"metadata": {},
"cell_type": "code",
"source": [
"import os\n",
"import json\n",
"import uuid\n",
"import warnings\n",
"from pathlib import Path\n",
"\n",
"import nest_asyncio\n",
"import pandas as pd\n",
"import yaml\n",
"from IPython.display import display\n",
"\n",
"# Keep notebook execution non-interactive for nbval and CI.\n",
"warnings.filterwarnings(\"ignore\")\n",
"nest_asyncio.apply()\n",
"\n",
"REDIS_URL = os.getenv(\"REDIS_URL\")\n",
"if not REDIS_URL:\n",
" REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\")\n",
" REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\")\n",
" REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\")\n",
" auth_part = f\":{REDIS_PASSWORD}@\" if REDIS_PASSWORD else \"\"\n",
" REDIS_URL = f\"redis://{auth_part}{REDIS_HOST}:{REDIS_PORT}\"\n",
"\n",
"print({\"redis_url\": REDIS_URL})\n"
],
"id": "a7d0e0e4cf78491b",
"outputs": [],
"execution_count": null
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:56:43.924747Z",
"start_time": "2026-04-22T14:56:43.409067Z"
}
},
"cell_type": "code",
"source": [
"from redis import Redis\n",
"from redisvl.index import SearchIndex\n",
"from redisvl.query import AggregateHybridQuery\n",
"from redisvl.utils.vectorize import HFTextVectorizer\n",
"\n",
"redis_client = Redis.from_url(REDIS_URL)\n",
"redis_client.ping()\n"
],
"id": "3d8b6e0cb8ded693",
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"execution_count": 2
},
{
"cell_type": "markdown",
"id": "2eb14fdb",
"metadata": {},
"source": [
"## 3. Load the Sample Movie Data\n",
"\n",
"For this tutorial we use a small movie catalog with titles, genres, ratings, and short plot descriptions. It is compact enough to inspect by eye, but still rich enough to show how Redis-backed retrieval changes an agent's answers.\n"
]
},
{
"cell_type": "code",
"id": "851f2637",
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:56:44.182147Z",
"start_time": "2026-04-22T14:56:44.161797Z"
}
},
"source": [
"movies_path = Path(\"resources\") / \"movies.json\"\n",
"with open(movies_path, \"r\", encoding=\"utf-8\") as f:\n",
" movies = json.load(f)\n"
],
"outputs": [],
"execution_count": 3
},
{
"cell_type": "code",
"id": "4418b294",
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:56:44.676776Z",
"start_time": "2026-04-22T14:56:44.605438Z"
}
},
"source": [
"movies_df = pd.DataFrame(movies)\n",
"print(f\"Loaded {len(movies_df)} movie entries\")\n",
"display(movies_df.head())\n"
],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Loaded 20 movie entries\n"
]
},
{
"data": {
"text/plain": [
" id title genre rating \\\n",
"0 1 Explosive Pursuit action 7 \n",
"1 2 Skyfall action 8 \n",
"2 3 Fast & Furious 9 action 6 \n",
"3 4 Black Widow action 7 \n",
"4 5 John Wick action 8 \n",
"\n",
" description \n",
"0 A daring cop chases a notorious criminal acros... \n",
"1 James Bond returns to track down a dangerous n... \n",
"2 Dom and his crew face off against a high-tech ... \n",
"3 Natasha Romanoff confronts her dark past and f... \n",
"4 A retired hitman seeks vengeance against those... "
],
"text/html": [
"
\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" id | \n",
" title | \n",
" genre | \n",
" rating | \n",
" description | \n",
"
\n",
" \n",
" \n",
" \n",
" | 0 | \n",
" 1 | \n",
" Explosive Pursuit | \n",
" action | \n",
" 7 | \n",
" A daring cop chases a notorious criminal acros... | \n",
"
\n",
" \n",
" | 1 | \n",
" 2 | \n",
" Skyfall | \n",
" action | \n",
" 8 | \n",
" James Bond returns to track down a dangerous n... | \n",
"
\n",
" \n",
" | 2 | \n",
" 3 | \n",
" Fast & Furious 9 | \n",
" action | \n",
" 6 | \n",
" Dom and his crew face off against a high-tech ... | \n",
"
\n",
" \n",
" | 3 | \n",
" 4 | \n",
" Black Widow | \n",
" action | \n",
" 7 | \n",
" Natasha Romanoff confronts her dark past and f... | \n",
"
\n",
" \n",
" | 4 | \n",
" 5 | \n",
" John Wick | \n",
" action | \n",
" 8 | \n",
" A retired hitman seeks vengeance against those... | \n",
"
\n",
" \n",
"
\n",
"
"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 4
},
{
"cell_type": "markdown",
"id": "f2312bc6",
"metadata": {},
"source": [
"### Note on the `id` Field\n",
"\n",
"RedisVL MCP reserves `id` for its own response envelope, so we rename the dataset's `id` field to `movie_id` before loading records into Redis. That small change keeps the schema compatible with the MCP server while preserving the original identifier.\n"
]
},
{
"cell_type": "code",
"id": "549df4ca",
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:56:53.095541Z",
"start_time": "2026-04-22T14:56:45.455757Z"
}
},
"source": [
"INDEX_NAME = \"adk-movies\"\n",
"INDEX_PREFIX = \"movie\"\n",
"EMBEDDING_MODEL = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
"\n",
"vectorizer = HFTextVectorizer(model=EMBEDDING_MODEL)\n",
"\n",
"schema = {\n",
" \"index\": {\n",
" \"name\": INDEX_NAME,\n",
" \"prefix\": INDEX_PREFIX,\n",
" \"storage_type\": \"hash\",\n",
" },\n",
" \"fields\": [\n",
" {\"name\": \"movie_id\", \"type\": \"tag\"},\n",
" {\"name\": \"title\", \"type\": \"text\"},\n",
" {\"name\": \"genre\", \"type\": \"tag\"},\n",
" {\"name\": \"rating\", \"type\": \"numeric\"},\n",
" {\"name\": \"description\", \"type\": \"text\"},\n",
" {\n",
" \"name\": \"embedding\",\n",
" \"type\": \"vector\",\n",
" \"attrs\": {\n",
" \"algorithm\": \"flat\",\n",
" \"dims\": vectorizer.dims,\n",
" \"distance_metric\": \"cosine\",\n",
" \"datatype\": \"float32\",\n",
" },\n",
" },\n",
" ],\n",
"}\n",
"\n",
"index = SearchIndex.from_dict(schema, redis_url=REDIS_URL)\n",
"index.create(overwrite=True, drop=True)\n",
"\n",
"records = []\n",
"for movie in movies:\n",
" record = {\n",
" \"movie_id\": movie[\"id\"],\n",
" \"title\": movie[\"title\"],\n",
" \"genre\": movie[\"genre\"],\n",
" \"rating\": movie[\"rating\"],\n",
" \"description\": movie[\"description\"],\n",
" }\n",
" record[\"embedding\"] = vectorizer.embed(record[\"description\"], as_buffer=True)\n",
" records.append(record)\n",
"\n",
"loaded_keys = index.load(records)\n",
"print(f\"Loaded {len(loaded_keys)} movie records into Redis index '{INDEX_NAME}'.\")\n",
"display(pd.DataFrame(records)[[\"movie_id\", \"title\", \"genre\", \"rating\"]].head())\n"
],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Loaded 20 movie records into Redis index 'adk-movies'.\n"
]
},
{
"data": {
"text/plain": [
" movie_id title genre rating\n",
"0 1 Explosive Pursuit action 7\n",
"1 2 Skyfall action 8\n",
"2 3 Fast & Furious 9 action 6\n",
"3 4 Black Widow action 7\n",
"4 5 John Wick action 8"
],
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" movie_id | \n",
" title | \n",
" genre | \n",
" rating | \n",
"
\n",
" \n",
" \n",
" \n",
" | 0 | \n",
" 1 | \n",
" Explosive Pursuit | \n",
" action | \n",
" 7 | \n",
"
\n",
" \n",
" | 1 | \n",
" 2 | \n",
" Skyfall | \n",
" action | \n",
" 8 | \n",
"
\n",
" \n",
" | 2 | \n",
" 3 | \n",
" Fast & Furious 9 | \n",
" action | \n",
" 6 | \n",
"
\n",
" \n",
" | 3 | \n",
" 4 | \n",
" Black Widow | \n",
" action | \n",
" 7 | \n",
"
\n",
" \n",
" | 4 | \n",
" 5 | \n",
" John Wick | \n",
" action | \n",
" 8 | \n",
"
\n",
" \n",
"
\n",
"
"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 5
},
{
"cell_type": "markdown",
"id": "346cba46",
"metadata": {},
"source": [
"### Sanity-Check the Redis Index\n",
"\n",
"Before adding MCP and ADK, it helps to confirm that hybrid search over the Redis index returns sensible matches.\n"
]
},
{
"cell_type": "code",
"id": "d118088c",
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:56:53.762804Z",
"start_time": "2026-04-22T14:56:53.148770Z"
}
},
"source": [
"user_query = \"revenge-driven action story\"\n",
"embedded_query = vectorizer.embed(user_query, as_buffer=True)\n",
"\n",
"hybrid_query = AggregateHybridQuery(\n",
" text=user_query,\n",
" text_field_name=\"description\",\n",
" stopwords=None,\n",
" vector=embedded_query,\n",
" vector_field_name=\"embedding\",\n",
" num_results=3,\n",
" return_fields=[\"movie_id\", \"title\", \"genre\", \"rating\", \"description\"],\n",
")\n",
"\n",
"sanity_results = index.query(hybrid_query)\n",
"sanity_df = pd.DataFrame(sanity_results)\n",
"display(sanity_df[[\"title\", \"genre\", \"rating\", \"hybrid_score\"]])\n"
],
"outputs": [
{
"data": {
"text/plain": [
" title genre rating hybrid_score\n",
"0 John Wick action 8 0.510036081076\n",
"1 Gladiator action 8 0.499173808098\n",
"2 Despicable Me comedy 7 0.485119789839"
],
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" title | \n",
" genre | \n",
" rating | \n",
" hybrid_score | \n",
"
\n",
" \n",
" \n",
" \n",
" | 0 | \n",
" John Wick | \n",
" action | \n",
" 8 | \n",
" 0.510036081076 | \n",
"
\n",
" \n",
" | 1 | \n",
" Gladiator | \n",
" action | \n",
" 8 | \n",
" 0.499173808098 | \n",
"
\n",
" \n",
" | 2 | \n",
" Despicable Me | \n",
" comedy | \n",
" 7 | \n",
" 0.485119789839 | \n",
"
\n",
" \n",
"
\n",
"
"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 6
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## 4. Generate a RedisVL MCP Config\n",
"\n",
"The [MCP config](https://docs.redisvl.com/en/stable/user_guide/how_to_guides/mcp.html) binds one logical server to our existing Redis index. We expose only search in this tutorial by launching the server in read-only mode.\n",
"\n",
"The config has three main sections:\n",
"- **`server`** -- connection details (the `redis_url` to connect to).\n",
"- **`indexes..search`** -- controls how the MCP `search-records` tool queries Redis:\n",
" - `type: vector` means every search uses vector similarity (the query text is embedded and compared against stored vectors). RedisVL MCP also supports `hybrid` (combining BM25 full-text scoring with vector similarity), but hybrid requires native support in your Redis deployment.\n",
"- **`indexes..runtime`** -- field mappings and guardrails:\n",
" - `vector_field_name` tells the server which vector field to use for similarity search.\n",
" - `text_field_name` tells the server which text field to use for full-text query parsing (not used, but required for the config at the moment).\n",
" - `default_embed_text_field` is the field whose content gets embedded when the agent sends a text query.\n",
" - `default_limit` / `max_limit` / `max_result_window` cap result sizes to prevent runaway queries.\n"
],
"id": "e4bd03e4e194341f"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"from redisvl.mcp import load_mcp_config\n",
"\n",
"Path(\"tmp\").mkdir(exist_ok=True)\n",
"mcp_config_path = Path(\"tmp/google_adk_movies_mcp.yaml\").resolve()\n",
"\n",
"mcp_config = {\n",
" \"server\": {\n",
" \"redis_url\": REDIS_URL,\n",
" },\n",
" \"indexes\": {\n",
" \"movies\": {\n",
" \"redis_name\": INDEX_NAME,\n",
" \"vectorizer\": {\n",
" \"class\": \"HFTextVectorizer\",\n",
" \"model\": EMBEDDING_MODEL,\n",
" },\n",
" \"search\": {\n",
" \"type\": \"vector\",\n",
" },\n",
" \"runtime\": {\n",
" \"vector_field_name\": \"embedding\",\n",
" \"text_field_name\": \"description\",\n",
" \"default_embed_text_field\": \"description\",\n",
" \"default_limit\": 5,\n",
" \"max_limit\": 10,\n",
" \"max_result_window\": 100,\n",
" # The first search-records call loads the embedding model into\n",
" # memory, which can take 30+ seconds. Give it plenty of room.\n",
" \"request_timeout_seconds\": 120,\n",
" \"startup_timeout_seconds\": 120,\n",
" },\n",
" }\n",
" },\n",
"}\n",
"\n",
"mcp_config_path.write_text(yaml.safe_dump(mcp_config, sort_keys=False), encoding=\"utf-8\")\n",
"validated_config = load_mcp_config(str(mcp_config_path))\n",
"\n",
"print(f\"Wrote MCP config to {mcp_config_path.resolve()}\")\n",
"print({\n",
" \"binding_id\": validated_config.binding_id,\n",
" \"redis_name\": validated_config.redis_name,\n",
" \"search_type\": validated_config.binding.search.type,\n",
"})\n",
"print(mcp_config_path.read_text(encoding=\"utf-8\"))\n"
],
"id": "6cf7117cd370e066"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## 5. Configure the MCP Toolset and Build the ADK Agent\n",
"\n",
"This section sets up the Google ADK side. We will:\n",
"1. Start the RedisVL MCP server as a background HTTP process.\n",
"2. Create a `McpToolset` that connects to the server over Streamable HTTP.\n",
"3. Define an `LlmAgent` that uses the toolset for retrieval and the Gemini API for generation.\n",
"4. Set up a `Runner` to manage multi-turn conversation state.\n",
"\n",
"> **Why Streamable HTTP instead of stdio?** MCP supports multiple transport modes, including stdio (subprocess communication over stdin/stdout) and Streamable HTTP. Notebook environments like Colab and Jupyter replace `sys.stderr` and `sys.stdout` with custom stream objects that lack `fileno()` support, which breaks stdio transport at the subprocess level. Streamable HTTP avoids this entirely by running the MCP server as an independent HTTP process and connecting over a normal TCP socket.\n",
"\n",
"> **Troubleshooting:** The `uvx` command comes from the [uv](https://docs.astral.sh/uv/) package manager. If it is not on your PATH, install it with `pip install uv` or see the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/). If Gemini API calls fail, verify your `GOOGLE_API_KEY` is valid at [aistudio.google.com/apikey](https://aistudio.google.com/apikey).\n",
"\n",
"### 5a. Override the Search Tool Description\n",
"\n",
"By default the `search-records` tool has a generic description. The model may hallucinate field names (e.g., `name`, `year`) that don't exist in the index. We override the tool description via the `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` environment variable so the model knows exactly which fields and filter operators are available.\n"
],
"id": "32c6b8dbd4418b6a"
},
{
"metadata": {},
"cell_type": "code",
"source": [
"SEARCH_TOOL_DESCRIPTION = \"Search the movie catalog in Redis. Available fields: movie_id (tag), title (text), genre (tag), rating (numeric), description (text). Only use these field names in return_fields and filters.\"\n",
"os.environ[\"REDISVL_MCP_TOOL_SEARCH_DESCRIPTION\"] = SEARCH_TOOL_DESCRIPTION\n"
],
"id": "5d5e6a2984c480e0",
"outputs": [],
"execution_count": null
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### 5b. Start the RedisVL MCP Server Over Streamable HTTP\n",
"\n",
"The MCP server needs to be running before the agent can connect. It listens on `http://127.0.0.1:8000/mcp` and serves the RedisVL tools over Streamable HTTP.\n",
"\n",
"**Option A -- Start from a terminal (recommended for local development):**\n",
"\n",
"Open a separate terminal, `cd` to this notebook's directory, and run:\n",
"\n",
"```bash\n",
"export REDISVL_MCP_TOOL_SEARCH_DESCRIPTION=\"Search the movie catalog in Redis. Available fields: movie_id (tag), title (text), genre (tag), rating (numeric), description (text). Only use these field names in return_fields and filters.\"\n",
"\n",
"uvx --from \"redisvl[mcp,sentence-transformers,nltk]\" rvl mcp \\\n",
" --config tmp/google_adk_movies_mcp.yaml \\\n",
" --read-only \\\n",
" --transport streamable-http \\\n",
" --host 127.0.0.1 \\\n",
" --port 8000\n",
"```\n",
"\n",
"You should see output indicating the server is running (e.g., `Uvicorn running on http://127.0.0.1:8000`). Leave the terminal open, then **skip the next code cell** and continue from step 5c.\n",
"\n",
"**Option B -- Start from the notebook (required for Colab):**\n",
"\n",
"The next cell launches the server as a background subprocess. This is the only option in Colab where there is no separate terminal.\n"
],
"id": "4938a17c389823e"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"# NBVAL_SKIP\n",
"# Skip this cell if you started the server from a terminal (Option A).\n",
"import subprocess\n",
"\n",
"MCP_HOST = \"127.0.0.1\"\n",
"MCP_PORT = 8000\n",
"MCP_URL = f\"http://{MCP_HOST}:{MCP_PORT}/mcp\"\n",
"\n",
"mcp_server_command = [\n",
" \"uvx\",\n",
" \"--from\",\n",
" \"redisvl[mcp,sentence-transformers,nltk]\",\n",
" \"rvl\",\n",
" \"mcp\",\n",
" \"--config\",\n",
" str(mcp_config_path),\n",
" \"--read-only\",\n",
" \"--transport\",\n",
" \"streamable-http\",\n",
" \"--host\",\n",
" MCP_HOST,\n",
" \"--port\",\n",
" str(MCP_PORT),\n",
"]\n",
"\n",
"# Pass the custom tool description and JSON response mode to the subprocess environment.\n",
"mcp_env = os.environ.copy()\n",
"mcp_env[\"REDISVL_MCP_TOOL_SEARCH_DESCRIPTION\"] = SEARCH_TOOL_DESCRIPTION\n",
"mcp_env[\"FASTMCP_JSON_RESPONSE\"] = \"true\"\n",
"\n",
"# Start the MCP server as a background process.\n",
"# stdin=PIPE prevents the subprocess from inheriting the notebook's stdin.\n",
"mcp_process = subprocess.Popen(\n",
" mcp_server_command,\n",
" stdin=subprocess.PIPE,\n",
" stdout=subprocess.DEVNULL,\n",
" stderr=subprocess.DEVNULL,\n",
" env=mcp_env,\n",
")\n",
"\n",
"# Verify that the mcp server is running in another process\n",
"! ps ax | grep \"mcp\" | sed 's|/[^ ]*/||g'\n"
],
"id": "1bc6d791594a9d3b"
},
{
"metadata": {},
"cell_type": "markdown",
"source": "After a few seconds, the MCP server should be up and running. The next cell sends a proper MCP `initialize` handshake and then lists the available tools to confirm the server is configured correctly. You can re-run this cell without restarting the server.\n",
"id": "b88e60a31728b6f4"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"# NBVAL_SKIP\n",
"# Ping and test initialization with the MCP server\n",
"import requests\n",
"import time\n",
"from pprint import pprint\n",
"\n",
"session = requests.Session()\n",
"session.headers.update({\"Accept\": \"application/json\"})\n",
"\n",
"initialize_payload = {\n",
" \"jsonrpc\": \"2.0\",\n",
" \"id\": 0,\n",
" \"method\": \"initialize\",\n",
" \"params\": {\n",
" \"protocolVersion\": \"2025-11-25\",\n",
" \"capabilities\": {},\n",
" \"clientInfo\": {\"name\": \"notebook-client\", \"version\": \"0.1.0\"},\n",
" },\n",
"}\n",
"\n",
"for attempt in range(90):\n",
" if mcp_process.poll() is not None:\n",
" raise RuntimeError(\n",
" f\"MCP server exited before becoming ready with code {mcp_process.returncode}.\"\n",
" )\n",
"\n",
" try:\n",
" resp = session.post(MCP_URL, json=initialize_payload, timeout=2)\n",
" resp.raise_for_status()\n",
" payload = resp.json()\n",
" if payload.get(\"jsonrpc\") == \"2.0\" and payload.get(\"id\") == 0 and \"result\" in payload:\n",
" print(f\"MCP server is ready (PID {mcp_process.pid}).\")\n",
"\n",
" # Capture the session ID for subsequent requests over Streamable HTTP.\n",
" mcp_session_id = resp.headers.get(\"mcp-session-id\")\n",
" if mcp_session_id:\n",
" session.headers.update({\"mcp-session-id\": mcp_session_id})\n",
"\n",
" # List the tools the server exposes.\n",
" tools_resp = session.post(\n",
" MCP_URL,\n",
" json={\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/list\", \"params\": {}},\n",
" timeout=5,\n",
" )\n",
" print(\"Available MCP tools:\")\n",
" pprint(tools_resp.json())\n",
" break\n",
" except (requests.RequestException, ValueError):\n",
" pass\n",
"\n",
" time.sleep(1)\n",
"else:\n",
" raise RuntimeError(\n",
" \"MCP server did not start within 90 s or did not return a valid initialize response. \"\n",
" \"Check that `uvx` is installed and `redisvl[mcp]` is available.\"\n",
" )\n"
],
"id": "76a5e43972df9a8c"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### 5c. Connect ADK to the MCP Server\n",
"\n",
"Now that the server is running, we point `McpToolset` at it using `StreamableHTTPConnectionParams`. The `tool_filter` restricts which MCP tools the agent can call -- here we only expose `search-records` since this is a read-only recipe.\n",
"\n",
"If you started the server from a terminal (Option A), set `MCP_URL` here before proceeding:\n"
],
"id": "70df57917a09e319"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:43.189764Z",
"start_time": "2026-04-22T14:57:41.903648Z"
}
},
"cell_type": "code",
"source": [
"from google.adk.agents import LlmAgent\n",
"from google.adk.runners import Runner\n",
"from google.adk.sessions import InMemorySessionService\n",
"from google.adk.tools.mcp_tool import McpToolset\n",
"from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams\n",
"from google.genai import types\n",
"\n",
"# If you started the server from a terminal (Option A) and skipped the cell\n",
"# above, make sure MCP_URL is defined:\n",
"try:\n",
" MCP_URL\n",
"except NameError:\n",
" MCP_URL = \"http://127.0.0.1:8000/mcp\"\n",
"\n",
"APP_NAME = \"redisvl_mcp_movies_app\"\n",
"USER_ID = \"notebook_user\"\n",
"\n",
"# Using Gemini 2.5 Flash -- a fast, cost-effective model that supports tool calling.\n",
"# See https://ai.google.dev/gemini-api/docs/models for available model names.\n",
"MODEL_NAME = \"gemini-2.5-flash\"\n",
"\n",
"toolset = McpToolset(\n",
" connection_params=StreamableHTTPConnectionParams(url=MCP_URL),\n",
" # Only expose the search tool. Remove this filter to also enable upsert-records, etc.\n",
" tool_filter=[\"search-records\"],\n",
")\n"
],
"id": "4a47814a2b85ae40",
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"/Users/vishal.bala/PycharmProjects/redis-ai-resources/.venv/lib/python3.13/site-packages/authlib/_joserfc_helpers.py:8: AuthlibDeprecationWarning: authlib.jose module is deprecated, please use joserfc instead.\n",
"It will be compatible before version 2.0.0.\n",
" from authlib.jose import ECKey\n"
]
}
],
"execution_count": 8
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### 5d. Define the ADK Agent\n",
"\n",
"The `LlmAgent` wraps the model with the MCP toolset. The `instruction` string is the system prompt -- it tells the model to always call the search tool before answering and to ground responses in the retrieved evidence.\n"
],
"id": "dae66e3a1604c55c"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:45.235498Z",
"start_time": "2026-04-22T14:57:45.222975Z"
}
},
"cell_type": "code",
"source": [
"root_agent = LlmAgent(\n",
" model=MODEL_NAME,\n",
" name=\"movie_mcp_agent\",\n",
" instruction=(\n",
" \"You are a movie recommendation assistant with access to a movie catalog.\"\n",
" ),\n",
" tools=[toolset],\n",
")\n"
],
"id": "9e103134d629242d",
"outputs": [],
"execution_count": 9
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### 5e. Create the Session Service and Runner\n",
"\n",
"ADK uses a `Runner` to execute agent turns and a `SessionService` to manage conversation state. `InMemorySessionService` is sufficient for a notebook -- in production you would use a persistent store.\n"
],
"id": "12b2816e9381e07d"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:46.007208Z",
"start_time": "2026-04-22T14:57:45.980327Z"
}
},
"cell_type": "code",
"source": [
"session_service = InMemorySessionService()\n",
"runner = Runner(\n",
" app_name=APP_NAME,\n",
" agent=root_agent,\n",
" session_service=session_service,\n",
")\n",
"\n",
"print(f\"Agent ready: model={MODEL_NAME}, MCP server={MCP_URL}\")\n"
],
"id": "71c1f707286acaea",
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Agent ready: model=gemini-2.5-flash, MCP server=http://127.0.0.1:8000/mcp\n"
]
}
],
"execution_count": 10
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## 6. Validate the MCP Connection and Define Helpers\n",
"\n",
"Before running full prompts, let's verify that the MCP server starts correctly and exposes the expected tools. Then we define helper functions for interacting with the agent and extracting structured evidence from MCP tool responses.\n",
"\n",
"### 6a. List Available MCP Tools\n",
"\n",
"This cell queries the running MCP server and prints the tools it advertises. If this fails, check that the server started correctly in step 5a.\n"
],
"id": "e4f8d8be4be4c9e0"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:46.687279Z",
"start_time": "2026-04-22T14:57:46.618785Z"
}
},
"cell_type": "code",
"source": [
"# NBVAL_SKIP\n",
"tools = await toolset.get_tools()\n",
"print(f\"MCP server connected -- {len(tools)} tool(s) available:\")\n",
"for tool in tools:\n",
" print(f\" - {tool.name}: {getattr(tool, 'description', 'no description')[:80]}\")\n"
],
"id": "d35537dc35b5178c",
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"MCP server connected -- 1 tool(s) available:\n",
" - search-records: Search the movie catalog in Redis. Available fields: movie_id (tag), title (text\n"
]
}
],
"execution_count": 11
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### 6b. Evidence Extraction Helpers\n",
"\n",
"The MCP server returns structured JSON payloads inside the ADK event stream. These helpers extract and flatten the search results into a pandas DataFrame so we can inspect exactly which records the agent used.\n",
"\n",
"- **`_model_dump`** -- converts Pydantic models to plain dicts for easier inspection.\n",
"- **`_find_search_payloads`** -- recursively walks the tool response tree looking for objects that contain `search_type` and `results` keys (the RedisVL MCP search response shape).\n",
"- **`search_evidence_dataframe`** -- combines the above to produce a flat DataFrame of matched records with their scores.\n"
],
"id": "16614d3c589a48a9"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:49.774979Z",
"start_time": "2026-04-22T14:57:49.739201Z"
}
},
"cell_type": "code",
"source": [
"def _model_dump(value):\n",
" \"\"\"Convert a Pydantic model to a JSON-serializable dict, or return the value as-is.\"\"\"\n",
" if hasattr(value, \"model_dump\"):\n",
" return value.model_dump(mode=\"json\", exclude_none=True)\n",
" return value\n",
"\n",
"\n",
"def _find_search_payloads(value):\n",
" \"\"\"Recursively find RedisVL MCP search response payloads in a nested structure.\"\"\"\n",
" payloads = []\n",
" if isinstance(value, dict):\n",
" if \"search_type\" in value and \"results\" in value:\n",
" payloads.append(value)\n",
" for nested_value in value.values():\n",
" payloads.extend(_find_search_payloads(nested_value))\n",
" elif isinstance(value, list):\n",
" for item in value:\n",
" payloads.extend(_find_search_payloads(item))\n",
" return payloads\n",
"\n",
"\n",
"def search_evidence_dataframe(tool_responses):\n",
" \"\"\"Extract the first search payload from tool responses and return it as a DataFrame.\"\"\"\n",
" payloads = _find_search_payloads(tool_responses)\n",
" if not payloads:\n",
" return None\n",
"\n",
" rows = []\n",
" for item in payloads[0][\"results\"]:\n",
" rows.append(\n",
" {\n",
" **item.get(\"record\", {}),\n",
" \"score\": item.get(\"score\"),\n",
" \"score_type\": item.get(\"score_type\"),\n",
" }\n",
" )\n",
" return pd.DataFrame(rows)\n"
],
"id": "f559a46136f2413c",
"outputs": [],
"execution_count": 12
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"### 6c. The `ask_agent` Interaction Loop\n",
"\n",
"This async function sends a single prompt to the agent, collects the final text response plus any MCP tool calls, and returns them in a dict. Each call creates a fresh session so prompts are independent.\n"
],
"id": "5744b372bc4d4473"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:51.077752Z",
"start_time": "2026-04-22T14:57:51.057318Z"
}
},
"cell_type": "code",
"source": [
"async def ask_agent(query: str, session_id: str | None = None):\n",
" \"\"\"Send a query to the agent and return the answer plus raw tool interactions.\"\"\"\n",
" session = await session_service.create_session(\n",
" app_name=APP_NAME,\n",
" user_id=USER_ID,\n",
" session_id=session_id or f\"session-{uuid.uuid4().hex[:8]}\",\n",
" state={},\n",
" )\n",
"\n",
" user_message = types.Content(role=\"user\", parts=[types.Part(text=query)])\n",
" final_response = \"No final response captured.\"\n",
" tool_calls = [] # outgoing calls (arguments the model sent)\n",
" tool_responses = [] # incoming responses (results from the MCP server)\n",
"\n",
" async for event in runner.run_async(\n",
" session_id=session.id,\n",
" user_id=session.user_id,\n",
" new_message=user_message,\n",
" ):\n",
" # Capture outgoing tool calls (what the model asked for).\n",
" if event.content and event.content.parts:\n",
" for part in event.content.parts:\n",
" fc = getattr(part, \"function_call\", None)\n",
" if fc:\n",
" tool_calls.append(_model_dump(fc))\n",
"\n",
" # Capture incoming tool responses (what the MCP server returned).\n",
" for response in event.get_function_responses():\n",
" tool_responses.append(_model_dump(response))\n",
"\n",
" if event.is_final_response() and event.content and event.content.parts:\n",
" text_parts = [\n",
" part.text for part in event.content.parts if getattr(part, \"text\", None)\n",
" ]\n",
" if text_parts:\n",
" final_response = \"\\n\".join(text_parts)\n",
"\n",
" return {\n",
" \"query\": query,\n",
" \"answer\": final_response,\n",
" \"tool_calls\": tool_calls,\n",
" \"tool_responses\": tool_responses,\n",
" }\n"
],
"id": "826d014f2678c10d",
"outputs": [],
"execution_count": 13
},
{
"cell_type": "markdown",
"id": "8c057cc4",
"metadata": {},
"source": [
"## 7. Run a First Grounded Prompt\n",
"\n",
"The next cell asks the ADK agent for a recommendation, then prints both the natural-language answer and the structured Redis evidence returned through MCP.\n"
]
},
{
"cell_type": "code",
"id": "d212ea2f",
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T14:57:55.156775Z",
"start_time": "2026-04-22T14:57:51.997145Z"
}
},
"source": [
"# NBVAL_SKIP\n",
"result = await ask_agent(\"Recommend an action movie with gadgets and advanced technology.\")\n",
"print(\"=== Agent Answer ===\")\n",
"print(result[\"answer\"])\n",
"\n",
"# Show what the model sent to the tool (useful for debugging bad field names).\n",
"if result[\"tool_calls\"]:\n",
" print(\"\\n=== Tool Calls (outgoing) ===\")\n",
" print(json.dumps(result[\"tool_calls\"], indent=2))\n",
"\n",
"# Show the structured evidence the agent grounded its answer in.\n",
"evidence_df = search_evidence_dataframe(result[\"tool_responses\"])\n",
"if evidence_df is not None:\n",
" display(evidence_df)\n",
"else:\n",
" # If no evidence was extracted, show the raw tool responses for debugging.\n",
" print(\"\\n=== Raw Tool Responses ===\")\n",
" print(json.dumps(result[\"tool_responses\"], indent=2))\n"
],
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"Warning: there are non-text parts in the response: ['function_call'], returning concatenated text result from text parts. Check the full candidates.content.parts accessor to get the full model response.\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Agent Answer ===\n",
"I recommend Fast & Furious 9. It's an action movie where Dom and his crew face off against a high-tech enemy with advanced weapons and technology. It has a rating of 6.\n",
"\n",
"=== Tool Calls (outgoing) ===\n",
"[\n",
" {\n",
" \"id\": \"adk-918244d6-607f-4c0e-9386-5dfb03cf5ab7\",\n",
" \"args\": {\n",
" \"filter\": \"@genre:{action}\",\n",
" \"query\": \"gadgets \\\"advanced technology\\\"\",\n",
" \"return_fields\": [\n",
" \"title\",\n",
" \"genre\",\n",
" \"description\",\n",
" \"rating\"\n",
" ],\n",
" \"limit\": 1\n",
" },\n",
" \"name\": \"search-records\"\n",
" }\n",
"]\n",
"\n",
"=== Evidence from Redis ===\n"
]
},
{
"data": {
"text/plain": [
" title genre rating score score_type \\\n",
"0 Fast & Furious 9 action 6 0.63603 vector_distance_normalized \n",
"\n",
" description \n",
"0 Dom and his crew face off against a high-tech ... "
],
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" title | \n",
" genre | \n",
" rating | \n",
" score | \n",
" score_type | \n",
" description | \n",
"
\n",
" \n",
" \n",
" \n",
" | 0 | \n",
" Fast & Furious 9 | \n",
" action | \n",
" 6 | \n",
" 0.63603 | \n",
" vector_distance_normalized | \n",
" Dom and his crew face off against a high-tech ... | \n",
"
\n",
" \n",
"
\n",
"
"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 14
},
{
"cell_type": "markdown",
"id": "2404b073",
"metadata": {},
"source": [
"## 8. Try More Prompts\n",
"\n",
"These examples stay close to the movie descriptions in the sample dataset, which makes it easy to see whether the agent is grounding correctly.\n"
]
},
{
"cell_type": "code",
"id": "4b0e5959",
"metadata": {},
"source": [
"# NBVAL_SKIP\n",
"demo_queries = [\n",
" \"Find a funny family movie with superhero elements.\",\n",
" \"Which movie best matches a revenge-driven action story?\",\n",
" \"Suggest a movie about spies, technology, and a dangerous mission.\",\n",
"]\n",
"\n",
"for prompt in demo_queries:\n",
" demo_result = await ask_agent(prompt)\n",
" print(f\"\\nPrompt: {prompt}\")\n",
" print(demo_result[\"answer\"])\n"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"id": "fcd325a0",
"metadata": {},
"source": [
"## 9. Exercise\n",
"\n",
"Try changing the agent instruction so it always returns exactly **two** movie suggestions with one sentence of justification each. Then compare how that changes the answers for a comedy-focused prompt.\n"
]
},
{
"cell_type": "code",
"id": "ef1d2ae7",
"metadata": {},
"source": [
"# Your turn:\n",
"# 1. Update `root_agent` so the answer format is stricter.\n",
"# 2. Re-run a prompt such as:\n",
"# \"Recommend a lighthearted animated movie for a family movie night.\"\n",
"# 3. Inspect whether the MCP evidence still matches the final answer.\n"
],
"outputs": [],
"execution_count": null
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"## Cleanup\n",
"\n",
"Close the MCP toolset, terminate the background MCP server process (if started from the notebook), and drop the Redis index. If you started the server from a terminal (Option A), stop it there with Ctrl-C.\n"
],
"id": "6d8a8312f4ce197c"
},
{
"metadata": {},
"cell_type": "code",
"source": [
"# NBVAL_SKIP\n",
"await toolset.close()\n",
"\n",
"# Terminate the background server if it was started from the notebook (Option B).\n",
"if \"mcp_process\" in dir():\n",
" mcp_process.terminate()\n",
" mcp_process.wait(timeout=5)\n",
" print(f\"MCP server process (PID {mcp_process.pid}) terminated.\")\n",
"\n",
"index.delete(drop=True)\n"
],
"id": "9d4e7f9e189b4c8f",
"outputs": [],
"execution_count": null
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"---\n",
"\n",
"# Recap\n",
"\n",
"## What We Built\n",
"\n",
"1. **Redis-backed movie index** -- loaded a JSON dataset into a RedisVL search index with vector, text, tag, and numeric fields.\n",
"2. **MCP config** -- wrote a YAML file that binds the index to the RedisVL MCP server with vector search settings.\n",
"3. **Google ADK agent** -- connected an `LlmAgent` to the MCP server over Streamable HTTP using `McpToolset`, with the Gemini API as the model backend.\n",
"4. **Evidence inspection** -- extracted structured search results from MCP tool responses so we could verify grounding.\n",
"\n",
"## Why Redis for MCP?\n",
"\n",
"- **Sub-millisecond retrieval** -- Redis keeps the index in memory, so even under agent-loop latency budgets, search stays fast.\n",
"- **Hybrid search** -- Redis supports combining BM25 full-text scoring with vector similarity in a single query for better relevance (demonstrated in the sanity-check cell; the MCP config can be switched from `vector` to `hybrid` when your deployment supports it).\n",
"- **Unified data layer** -- the same Redis instance can serve caching, session state, and retrieval, reducing infrastructure complexity.\n",
"- **MCP compatibility** -- RedisVL's built-in MCP server means any MCP-compatible client (ADK, Claude Desktop, Cursor, etc.) can access the same index with zero custom code.\n",
"\n",
"## Common Pitfalls\n",
"\n",
"- **Reserved `id` field** -- RedisVL MCP uses `id` in its response envelope. If your source data has an `id` column, rename it (e.g., to `movie_id`) before loading.\n",
"- **Missing `uvx`** -- the `uvx` command comes from the [uv](https://docs.astral.sh/uv/) package manager. If it is not on your PATH, install it with `pip install uv` or see the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/).\n",
"- **Gemini API key** -- if API calls fail, verify your key is valid at [aistudio.google.com/apikey](https://aistudio.google.com/apikey). Free-tier keys have rate limits.\n",
"\n",
"## Next Steps\n",
"\n",
"- Add a second notebook that enables `upsert-records` for ingestion workflows.\n",
"- Switch from the movie dataset to a domain-specific JSON corpus.\n",
"- Compare direct RedisVL querying with MCP-mediated retrieval side by side.\n",
"\n",
"**Want to learn more?**\n",
"1. [RedisVL MCP documentation](https://docs.redisvl.com/en/stable/user_guide/how_to_guides/mcp.html)\n",
"2. [Google ADK MCP tools guide](https://google.github.io/adk-docs/tools/mcp-tools/)\n",
"3. [MCP protocol specification](https://modelcontextprotocol.io/)\n",
"4. [Redis AI resources repository](https://github.com/redis-developer/redis-ai-resources)\n"
],
"id": "2d74e95ace7f61ac"
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12"
},
"colab": {
"provenance": [],
"toc_visible": true
}
},
"nbformat": 4,
"nbformat_minor": 5
}