From 448b22dabfebc1d46fa0dc9aadef46da84d48415 Mon Sep 17 00:00:00 2001 From: Ondrej Metelka Date: Thu, 7 May 2026 08:58:06 +0200 Subject: [PATCH] =?UTF-8?q?WIP:=20BYOK=20via=20MCP=20=E2=80=94=20knowledge?= =?UTF-8?q?-type=20MCP=20servers=20with=20forced/on-demand=20retrieval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for MCP servers as knowledge sources (BYOK) with two retrieval modes: - `retrieval_mode: always` — forced retrieval on every query, results injected as RAG context (like current reference_content pipeline) - `retrieval_mode: on_demand` — exposed as LLM tool with augmented description via knowledge_description config field Config changes (MCPServerConfig): - type: tool (default) | knowledge - retrieval_mode: always | on_demand - search_tool: which MCP tool to call for retrieval - knowledge_description: context for on_demand mode - max_chunks, priority: retrieval control levers Includes POC setup with Qdrant + mcp-server-qdrant demonstrating the end-to-end flow. Co-authored-by: Cursor --- ols/app/models/config.py | 96 +++++++++++++ ols/src/query_helpers/docs_summarizer.py | 29 +++- ols/utils/mcp_utils.py | 115 +++++++++++++++- poc/byok-mcp/README.md | 168 +++++++++++++++++++++++ poc/byok-mcp/ingest.py | 153 +++++++++++++++++++++ poc/byok-mcp/requirements.txt | 3 + poc/byok-mcp/setup.sh | 84 ++++++++++++ 7 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 poc/byok-mcp/README.md create mode 100644 poc/byok-mcp/ingest.py create mode 100644 poc/byok-mcp/requirements.txt create mode 100755 poc/byok-mcp/setup.sh diff --git a/ols/app/models/config.py b/ols/app/models/config.py index adfc73ca9..07446a765 100644 --- a/ols/app/models/config.py +++ b/ols/app/models/config.py @@ -631,6 +631,10 @@ class MCPServerConfig(BaseModel): MCP (Model Context Protocol) servers provide tools and capabilities to the AI agents. These are configured by this structure. Only MCP servers defined in the olsconfig.yaml configuration are available to the agents. + + Servers with ``type: knowledge`` are queried on every request (forced + retrieval) and their results are injected as additional RAG context. + Servers with ``type: tool`` (the default) expose tools the LLM may call. """ name: str = Field( @@ -643,6 +647,68 @@ class MCPServerConfig(BaseModel): description="URL of the MCP server", ) + type: str = Field( + default="tool", + title="MCP server type", + description=( + "Type of MCP server. " + "'tool' (default) — tools are passed to the LLM which decides when to call them. " + "'knowledge' — a knowledge source queried according to retrieval_mode." + ), + ) + + retrieval_mode: str = Field( + default="always", + title="Retrieval mode", + description=( + "How the knowledge source is queried. Only used when type is 'knowledge'. " + "'always' — search_tool is called on every query and results are injected " + "as RAG context (forced retrieval). " + "'on_demand' — the search_tool is exposed as an LLM tool, the model decides " + "when to call it. Requires 'description' to tell the LLM what the source contains." + ), + ) + + knowledge_description: Optional[str] = Field( + default=None, + title="Knowledge source description", + description=( + "Human-readable description of what this knowledge source contains. " + "Used when retrieval_mode is 'on_demand' to help the LLM decide when to " + "query this source. Example: 'Internal incident response runbooks and " + "security playbooks. Search when the user asks about incident response, " + "ransomware, phishing, or security procedures.'" + ), + ) + + search_tool: Optional[str] = Field( + default=None, + title="Search tool name", + description=( + "Name of the MCP tool to call for knowledge retrieval. " + "Required when type is 'knowledge'. " + "The tool must accept a 'query' parameter." + ), + ) + + max_chunks: int = Field( + default=10, + title="Max chunks", + description=( + "Maximum number of chunks to retrieve from this knowledge source. " + "Only used when type is 'knowledge'." + ), + ) + + priority: int = Field( + default=1, + title="Priority", + description=( + "Priority for ordering results when multiple knowledge sources return results. " + "Lower numbers = higher priority. Only used when type is 'knowledge'." + ), + ) + timeout: Optional[int] = Field( default=None, title="Request timeout", @@ -676,6 +742,36 @@ def resolved_headers(self) -> dict[str, str]: """Resolved headers (computed from headers).""" return self._resolved_headers + @model_validator(mode="after") + def validate_knowledge_config(self) -> "MCPServerConfig": + """Validate that knowledge-type servers have search_tool configured.""" + if self.type not in ("tool", "knowledge"): + raise ValueError( + f"Invalid MCP server type '{self.type}' for server '{self.name}'. " + "Must be 'tool' or 'knowledge'." + ) + if self.type == "knowledge" and not self.search_tool: + raise ValueError( + f"MCP server '{self.name}' has type 'knowledge' but no search_tool configured. " + "Set search_tool to the name of the MCP tool to call for retrieval." + ) + if self.retrieval_mode not in ("always", "on_demand"): + raise ValueError( + f"Invalid retrieval_mode '{self.retrieval_mode}' for server '{self.name}'. " + "Must be 'always' or 'on_demand'." + ) + if ( + self.type == "knowledge" + and self.retrieval_mode == "on_demand" + and not self.knowledge_description + ): + raise ValueError( + f"MCP server '{self.name}' has retrieval_mode 'on_demand' but no " + "knowledge_description. The LLM needs a description to decide when " + "to query this source." + ) + return self + class ToolFilteringConfig(BaseModel): """Configuration for tool filtering using hybrid RAG retrieval. diff --git a/ols/src/query_helpers/docs_summarizer.py b/ols/src/query_helpers/docs_summarizer.py index c99b02cae..7f572358c 100644 --- a/ols/src/query_helpers/docs_summarizer.py +++ b/ols/src/query_helpers/docs_summarizer.py @@ -29,7 +29,12 @@ from ols.src.query_helpers.query_helper import QueryHelper from ols.src.skills.skills_rag import create_skill_support_tool from ols.src.tools.offloaded_content import OffloadManager -from ols.utils.mcp_utils import ClientHeaders, build_mcp_config, get_mcp_tools +from ols.utils.mcp_utils import ( + ClientHeaders, + build_mcp_config, + get_mcp_tools, + retrieve_from_knowledge_mcps, +) from ols.utils.token_handler import ( PromptTooLongError, TokenBudgetTracker, @@ -288,6 +293,28 @@ async def generate_response( # noqa: C901 # pylint: disable=too-many-branches, """ rag_chunks = await self._prepare_prompt_context(query, rag_retriever) + knowledge_results = await retrieve_from_knowledge_mcps( + query, self.user_token, self.client_headers + ) + if knowledge_results: + for kr in knowledge_results: + byok_chunk = RagChunk( + text=kr["text"], + doc_url=kr.get("source", ""), + doc_title=kr.get("title", "BYOK"), + ) + rag_chunks.insert(0, byok_chunk) + byok_tokens = sum( + self._tracker.count_tokens(kr["text"]) for kr in knowledge_results + ) + self._tracker.charge(TokenCategory.RAG, byok_tokens) + logger.info( + "Injected %d BYOK knowledge chunks (%d tokens) from %d MCP sources", + len(knowledge_results), + byok_tokens, + len(knowledge_results), + ) + skill_content: Optional[str] = None has_support_files = False skill = None diff --git a/ols/utils/mcp_utils.py b/ols/utils/mcp_utils.py index f5f16d0f2..9208a2573 100644 --- a/ols/utils/mcp_utils.py +++ b/ols/utils/mcp_utils.py @@ -206,13 +206,31 @@ async def gather_mcp_tools( tool for tool in server_tools if tool.name in allowed_tool_names ] - # Add MCP server name to each tool's metadata + server_cfg = config.mcp_servers_dict.get(server_name) + knowledge_desc = ( + server_cfg.knowledge_description + if server_cfg and server_cfg.type == "knowledge" + and server_cfg.knowledge_description + else None + ) + for tool in server_tools: _normalize_tool_schema(tool) if not hasattr(tool, "metadata") or tool.metadata is None: tool.metadata = {} tool.metadata["mcp_server"] = server_name + if knowledge_desc and server_cfg and tool.name == server_cfg.search_tool: + tool.description = ( + f"{knowledge_desc}\n\n" + f"Original tool description: {tool.description}" + ) + logger.info( + "Augmented tool '%s' with knowledge_description from '%s'", + tool.name, + server_name, + ) + all_tools.extend(server_tools) logger.info( "Loaded %d tools from MCP server '%s'", @@ -347,10 +365,15 @@ async def get_mcp_tools( Returns: List of all tools from MCP servers (filtered if tools_rag configured). """ + tool_servers = [ + s for s in config.mcp_servers.servers + if not (s.type == "knowledge" and s.retrieval_mode == "always") + ] + # If tools_rag is not configured, return all tools if not config.tools_rag: mcp_servers_config, all_tools = await _gather_and_populate_tools( - config.mcp_servers.servers, user_token, client_headers, deduplicate=True + tool_servers, user_token, client_headers, deduplicate=True ) if not mcp_servers_config: @@ -432,6 +455,94 @@ async def get_mcp_tools( return [] +async def retrieve_from_knowledge_mcps( + query: str, + user_token: Optional[str] = None, + client_headers: ClientHeaders | None = None, +) -> list[dict[str, str]]: + """Retrieve context from all knowledge-type MCP servers (forced retrieval). + + Connects to each MCP server with type='knowledge', calls its configured + search_tool with the query, and returns the results as a list of dicts + with 'text', 'title', and 'source' keys. + + Args: + query: The user query to search for. + user_token: Optional user authentication token. + client_headers: Optional client-provided MCP headers. + + Returns: + List of dicts with 'text', 'title', and 'source' keys, + sorted by server priority (lower = higher priority). + """ + knowledge_servers = [ + s for s in config.mcp_servers.servers + if s.type == "knowledge" and s.retrieval_mode == "always" + ] + if not knowledge_servers: + return [] + + servers_config = build_mcp_config(knowledge_servers, user_token, client_headers) + if not servers_config: + return [] + + all_results: list[tuple[int, dict[str, str]]] = [] + + mcp_client = MultiServerMCPClient(servers_config) + + for server_cfg in knowledge_servers: + if server_cfg.name not in servers_config: + continue + try: + tools = await mcp_client.get_tools(server_name=server_cfg.name) + search_tool = next( + (t for t in tools if t.name == server_cfg.search_tool), None + ) + if search_tool is None: + logger.error( + "Knowledge MCP '%s': search_tool '%s' not found. Available: %s", + server_cfg.name, + server_cfg.search_tool, + [t.name for t in tools], + ) + continue + + result = await search_tool.ainvoke({"query": query}) + result_text = str(result) if result else "" + + if not result_text.strip(): + logger.info( + "Knowledge MCP '%s': no results for query", server_cfg.name + ) + continue + + all_results.append(( + server_cfg.priority, + { + "text": result_text[: 5000], + "title": f"BYOK: {server_cfg.name}", + "source": server_cfg.name, + }, + )) + + logger.info( + "Knowledge MCP '%s': retrieved %d chars via '%s'", + server_cfg.name, + len(result_text), + server_cfg.search_tool, + ) + except Exception as e: + logger.error( + "Failed to retrieve from knowledge MCP '%s': %s: %s", + server_cfg.name, + type(e).__name__, + e, + ) + + all_results.sort(key=lambda x: x[0]) + return [r[1] for r in all_results] + + def build_mcp_config( servers_list: list[MCPServerConfig], user_token: Optional[str], diff --git a/poc/byok-mcp/README.md b/poc/byok-mcp/README.md new file mode 100644 index 000000000..f21704851 --- /dev/null +++ b/poc/byok-mcp/README.md @@ -0,0 +1,168 @@ +# BYOK via MCP — Proof of Concept + +Demonstrates that OLS can consume customer knowledge through external MCP servers +without any custom ingestion pipeline. The customer brings their own knowledge +(documents in a git repo), uses an open-source tool to index it, and OLS queries +it via MCP. + +## What This Proves + +| Claim | How | +|-------|-----| +| No custom ingestion pipeline needed | Standard open-source tools (Qdrant + MCP server) | +| No document format conversion | Ingestion handles markdown/text directly from repo | +| MCP is the interface | OLS connects via existing `mcp_servers` config — zero OLS code changes | +| Same tooling works elsewhere | The Qdrant MCP server works with Claude, Cursor, Windsurf, etc. | + +## Architecture + +``` +Git Repo (demo docs) + │ + │ clone + ▼ + ingest.py ← chunks, embeds with FastEmbed (all-MiniLM-L6-v2) + │ + │ vectors + metadata + ▼ + Qdrant (Docker) ← vector database on localhost:6333 + │ + │ MCP streamable-http + ▼ + mcp-server-qdrant ← official Qdrant MCP server on localhost:8080 + │ + │ MCP streamable-http + ▼ + OLS ← existing MCPServerConfig, zero code changes +``` + +## Demo Content + +Uses [counteractive/incident-response-plan-template](https://github.com/counteractive/incident-response-plan-template) — +a set of security incident response runbooks and playbooks (~220KB of markdown). + +**Demo questions for OLS:** +- "What should we do during a ransomware attack?" +- "Who is the incident commander and what are their duties?" +- "What is our phishing response playbook?" +- "How do we handle a website defacement?" +- "What are the steps after an incident is resolved?" + +Without BYOK, OLS cannot answer these (not OpenShift knowledge). +With BYOK via MCP, OLS answers from the ingested runbooks. + +## Prerequisites + +- Python 3.12+ +- Docker +- OLS running locally (or able to run) + +## Quick Start + +```bash +# One command does everything: clone, start Qdrant, ingest, start MCP server +./setup.sh +``` + +The script will: +1. Clone the demo content repo +2. Start Qdrant in Docker +3. Install Python dependencies (qdrant-client, fastembed, mcp-server-qdrant) +4. Parse and ingest all markdown files into Qdrant +5. Start the MCP server on `http://localhost:8080/mcp` + +Then add to your `olsconfig.yaml`: + +```yaml +ols_config: + mcp_servers: + - name: byok-incident-response + url: http://localhost:8080/mcp +``` + +Restart OLS and ask one of the demo questions. + +## Manual Steps (if not using setup.sh) + +### 1. Start Qdrant + +```bash +docker run -d --name byok-qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant +``` + +### 2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 3. Clone and ingest demo content + +```bash +git clone --depth 1 https://github.com/counteractive/incident-response-plan-template.git demo-docs + +python ingest.py --docs-path ./demo-docs --collection byok-demo +``` + +### 4. Start MCP server + +```bash +QDRANT_URL=http://localhost:6333 \ +COLLECTION_NAME=byok-demo \ +EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 \ +FASTMCP_PORT=8080 \ +mcp-server-qdrant --transport streamable-http +``` + +### 5. Configure OLS + +Add to `olsconfig.yaml`: + +```yaml +ols_config: + mcp_servers: + - name: byok-incident-response + url: http://localhost:8080/mcp +``` + +## How It Works + +### Ingestion (ingest.py) + +- Reads all `.md` and `.txt` files from the demo repo +- Splits into ~500-character chunks with 50-character overlap +- Embeds using FastEmbed (`all-MiniLM-L6-v2` — same model the MCP server uses for queries) +- Stores vectors + metadata (source file, title) in Qdrant + +### MCP Server (mcp-server-qdrant) + +- Official Qdrant MCP server (Apache 2.0, 1,300+ GitHub stars) +- Exposes two MCP tools: + - `qdrant-find`: semantic search over the collection + - `qdrant-store`: store new information (not used in this demo) +- Uses `streamable-http` transport (compatible with OLS's MCP client) + +### OLS Integration + +- OLS discovers the MCP tools via standard MCP protocol +- The LLM decides when to call `qdrant-find` based on the query +- Retrieved chunks are used as context for generating the response + +## Scaling to Real Customer Use + +For a real deployment, replace `ingest.py` with one of these tools: + +| Tool | What It Does | License | +|------|-------------|---------| +| [qdrant-loader](https://github.com/martin-papy/qdrant-loader) | Ingests from Git, Confluence, JIRA, local files. 20+ formats. Comes with its own MCP server. | GPLv3 | +| [unstructured-ingest](https://docs.unstructured.io/open-source/ingestion/ingest-cli) | 130+ document formats. Industrial-grade parsing. Push to Qdrant. | Apache 2.0 | +| [MDDB](https://github.com/tradik/mddb) | All-in-one: embedded DB + MCP server + ingestion. Single binary. | BSD-3 | + +The MCP server stays the same — only the ingestion step changes. + +## Cleanup + +```bash +docker stop byok-qdrant && docker rm byok-qdrant +rm -rf demo-docs +``` diff --git a/poc/byok-mcp/ingest.py b/poc/byok-mcp/ingest.py new file mode 100644 index 000000000..d16a517ad --- /dev/null +++ b/poc/byok-mcp/ingest.py @@ -0,0 +1,153 @@ +"""Ingest markdown files from a local directory into Qdrant. + +Uses FastEmbed with the same model as mcp-server-qdrant (all-MiniLM-L6-v2) +to ensure embedding dimensions match at query time. + +Usage: + python ingest.py --docs-path ./demo-docs --collection byok-demo +""" + +import argparse +import hashlib +import sys +from pathlib import Path + +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, PointStruct, VectorParams + +CHUNK_SIZE = 500 +CHUNK_OVERLAP = 50 +EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" +EMBEDDING_DIM = 384 +VECTOR_NAME = "fast-all-minilm-l6-v2" + + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]: + """Split text into overlapping chunks by character count.""" + chunks = [] + start = 0 + while start < len(text): + end = start + chunk_size + chunk = text[start:end] + if chunk.strip(): + chunks.append(chunk.strip()) + start = end - overlap + return chunks + + +def generate_id(text: str) -> str: + """Generate a deterministic hex ID from text content.""" + return hashlib.md5(text.encode()).hexdigest()[:16] + + +def load_documents(docs_path: Path) -> list[dict]: + """Load all markdown and text files from a directory tree.""" + docs = [] + extensions = {".md", ".txt", ".text", ".rst"} + for file_path in sorted(docs_path.rglob("*")): + if file_path.suffix.lower() in extensions and file_path.is_file(): + content = file_path.read_text(encoding="utf-8", errors="replace") + if content.strip(): + relative = file_path.relative_to(docs_path) + docs.append({ + "path": str(relative), + "content": content, + "title": extract_title(content, relative), + }) + return docs + + +def extract_title(content: str, path: Path) -> str: + """Extract title from first markdown heading or use filename.""" + for line in content.split("\n"): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return path.stem.replace("-", " ").replace("_", " ").title() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Ingest documents into Qdrant for BYOK MCP POC") + parser.add_argument("--docs-path", type=Path, required=True, help="Path to document directory") + parser.add_argument("--collection", default="byok-demo", help="Qdrant collection name") + parser.add_argument("--qdrant-url", default="http://localhost:6333", help="Qdrant server URL") + args = parser.parse_args() + + if not args.docs_path.is_dir(): + print(f"Error: {args.docs_path} is not a directory", file=sys.stderr) + sys.exit(1) + + print(f"Loading documents from {args.docs_path}...") + docs = load_documents(args.docs_path) + if not docs: + print("No documents found.", file=sys.stderr) + sys.exit(1) + print(f"Found {len(docs)} documents") + + all_chunks = [] + for doc in docs: + chunks = chunk_text(doc["content"]) + for i, chunk in enumerate(chunks): + all_chunks.append({ + "text": chunk, + "metadata": { + "source": doc["path"], + "title": doc["title"], + "chunk_index": i, + }, + }) + print(f"Created {len(all_chunks)} chunks") + + print(f"Generating embeddings with {EMBEDDING_MODEL}...") + try: + from fastembed import TextEmbedding + except ImportError: + print("Error: fastembed not installed. Run: pip install fastembed", file=sys.stderr) + sys.exit(1) + + model = TextEmbedding(model_name=EMBEDDING_MODEL) + texts = [c["text"] for c in all_chunks] + embeddings = list(model.embed(texts)) + print(f"Generated {len(embeddings)} embeddings (dim={len(embeddings[0])})") + + print(f"Connecting to Qdrant at {args.qdrant_url}...") + client = QdrantClient(url=args.qdrant_url) + + if client.collection_exists(args.collection): + print(f"Dropping existing collection '{args.collection}'...") + client.delete_collection(args.collection) + + client.create_collection( + collection_name=args.collection, + vectors_config={ + VECTOR_NAME: VectorParams(size=EMBEDDING_DIM, distance=Distance.COSINE), + }, + ) + + points = [ + PointStruct( + id=idx, + vector={VECTOR_NAME: embedding.tolist()}, + payload={ + "document": all_chunks[idx]["text"], + "metadata": all_chunks[idx]["metadata"], + }, + ) + for idx, embedding in enumerate(embeddings) + ] + + batch_size = 100 + for i in range(0, len(points), batch_size): + batch = points[i : i + batch_size] + client.upsert(collection_name=args.collection, points=batch) + print(f" Uploaded {min(i + batch_size, len(points))}/{len(points)} points") + + info = client.get_collection(args.collection) + print(f"\nDone! Collection '{args.collection}' has {info.points_count} points") + print(f"Qdrant URL: {args.qdrant_url}") + print(f"Collection: {args.collection}") + print(f"Embedding model: {EMBEDDING_MODEL}") + + +if __name__ == "__main__": + main() diff --git a/poc/byok-mcp/requirements.txt b/poc/byok-mcp/requirements.txt new file mode 100644 index 000000000..b5f308a14 --- /dev/null +++ b/poc/byok-mcp/requirements.txt @@ -0,0 +1,3 @@ +qdrant-client>=1.12.0 +fastembed>=0.4.0 +mcp-server-qdrant>=0.8.0 diff --git a/poc/byok-mcp/setup.sh b/poc/byok-mcp/setup.sh new file mode 100755 index 000000000..4d6d33b33 --- /dev/null +++ b/poc/byok-mcp/setup.sh @@ -0,0 +1,84 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_REPO="https://github.com/counteractive/incident-response-plan-template.git" +DEMO_DIR="$SCRIPT_DIR/demo-docs" +VENV_DIR="$SCRIPT_DIR/.venv" +COLLECTION="byok-demo" +QDRANT_URL="http://localhost:6333" +MCP_PORT="${MCP_PORT:-8000}" + +echo "=== BYOK via MCP — POC Setup ===" +echo "" + +# Step 1: Clone demo content +if [ -d "$DEMO_DIR" ]; then + echo "[1/6] Demo docs already cloned at $DEMO_DIR" +else + echo "[1/6] Cloning demo content (incident response runbooks)..." + git clone --depth 1 "$DEMO_REPO" "$DEMO_DIR" +fi + +# Step 2: Start Qdrant +if docker ps --format '{{.Names}}' | grep -q '^byok-qdrant$'; then + echo "[2/6] Qdrant already running" +else + echo "[2/6] Starting Qdrant..." + docker run -d \ + --name byok-qdrant \ + -p 6333:6333 -p 6334:6334 \ + qdrant/qdrant + echo " Waiting for Qdrant to be ready..." + sleep 3 + until curl -sf "$QDRANT_URL/healthz" > /dev/null 2>&1; do + sleep 1 + done + echo " Qdrant ready at $QDRANT_URL" +fi + +# Step 3: Create venv and install dependencies +if [ -d "$VENV_DIR" ]; then + echo "[3/6] Virtual environment already exists" +else + echo "[3/6] Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +echo "[4/6] Installing Python dependencies..." +pip install -q -r "$SCRIPT_DIR/requirements.txt" + +# Step 5: Ingest documents +echo "[5/6] Ingesting documents into Qdrant..." +python "$SCRIPT_DIR/ingest.py" \ + --docs-path "$DEMO_DIR" \ + --collection "$COLLECTION" \ + --qdrant-url "$QDRANT_URL" + +# Step 6: Start MCP server +echo "[6/6] Starting Qdrant MCP server on port $MCP_PORT..." +echo "" +echo "============================================" +echo " MCP server starting at:" +echo " http://localhost:$MCP_PORT/mcp" +echo "" +echo " Add to your olsconfig.yaml:" +echo "" +echo " ols_config:" +echo " mcp_servers:" +echo " - name: byok-incident-response" +echo " url: http://localhost:$MCP_PORT/mcp" +echo "" +echo " Then ask OLS:" +echo " - What should we do during a ransomware attack?" +echo " - Who is the incident commander and what are their duties?" +echo " - What is our phishing response playbook?" +echo "============================================" +echo "" + +QDRANT_URL="$QDRANT_URL" \ +COLLECTION_NAME="$COLLECTION" \ +EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \ +mcp-server-qdrant --transport streamable-http --port "$MCP_PORT"