From 73773761818a5f2e21ad9e7549bd63056325c6ff Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Wed, 8 Jul 2026 16:55:29 +0200 Subject: [PATCH 01/10] Add MCP server --- Makefile | 1 + template/.mcp.json.jinja | 8 + template/.vibe/config.toml.jinja | 5 + template/Makefile.jinja | 21 +- template/README.md.jinja | 43 ++- template/pyproject.toml.jinja | 4 + template/src/entrypoints/mcp_server.py | 352 +++++++++++++++++++++++++ 7 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 template/.mcp.json.jinja create mode 100644 template/.vibe/config.toml.jinja create mode 100644 template/src/entrypoints/mcp_server.py diff --git a/Makefile b/Makefile index ace9629..23a6b43 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ _test-wheel-contents: cd $(GENERATED_PROJECT) && rm -rf dist && uv build --wheel > /dev/null cd $(GENERATED_PROJECT) && unzip -l dist/*.whl \ | grep -q "entrypoints/ingest.py" \ + && unzip -l dist/*.whl | grep -q "entrypoints/mcp_server.py" \ && unzip -l dist/*.whl | grep -q "vespa_app/__init__.py" \ && echo "OK: wheel contains entrypoints and vespa_app" \ || (echo "FAIL: wheel is missing required packages" && exit 1) diff --git a/template/.mcp.json.jinja b/template/.mcp.json.jinja new file mode 100644 index 0000000..1e0e1ac --- /dev/null +++ b/template/.mcp.json.jinja @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "{{ _copier_conf.dst_path.name }}": { + "command": "uv", + "args": ["run", "python", "-m", "entrypoints.mcp_server"] + } + } +} diff --git a/template/.vibe/config.toml.jinja b/template/.vibe/config.toml.jinja new file mode 100644 index 0000000..a8faccc --- /dev/null +++ b/template/.vibe/config.toml.jinja @@ -0,0 +1,5 @@ +[[mcp_servers]] +name = "{{ _copier_conf.dst_path.name }}" +transport = "stdio" +command = "uv" +args = ["run", "python", "-m", "entrypoints.mcp_server"] diff --git a/template/Makefile.jinja b/template/Makefile.jinja index ec52e93..688082e 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -1,4 +1,4 @@ -.PHONY: installdeps install-workflows setup-vespa start-vespa stop-vespa reset-vespa migrate-vespa verify-vespa ingest search bruno generate-vespa-lock start-examples execute-ingestion +.PHONY: installdeps setup-vespa start-vespa stop-vespa reset-vespa migrate-vespa verify-vespa ingest search mcp bruno generate-vespa-lock ifneq (,$(wildcard .env)) include .env @@ -10,6 +10,8 @@ VESPA_QUERY_PORT := $(or $(VESPA_QUERY_PORT),18080) VESPA_CONFIG_PORT := $(or $(VESPA_CONFIG_PORT),19072) VESPA_ENDPOINT := $(or $(VESPA_ENDPOINT),http://localhost:$(VESPA_QUERY_PORT)) VESPA_CONFIG_URL := $(or $(VESPA_CONFIG_URL),http://localhost:$(VESPA_CONFIG_PORT)) +MCP_HOST := $(or $(host),127.0.0.1) +MCP_PORT := $(or $(port),8000) ## Install dependencies installdeps: @@ -53,6 +55,11 @@ ingest: search: uv run python -m entrypoints.search "$(query)" $(if $(top_k),--top-k $(top_k),) $(if $(query_profile),--query-profile $(query_profile),) +## Start the MCP server in HTTP mode +## Usage: make mcp [host=0.0.0.0] [port=8000] +mcp: + uv run python -m entrypoints.mcp_server --http --host $(MCP_HOST) --port $(MCP_PORT) + ## Generate Bruno API files under vespa/bruno/vespa/ (requires WORKSPACE_ROOT in .env) bruno: uv run mistral-vespa bruno \ @@ -66,15 +73,3 @@ generate-vespa-lock: --app-dir src/vespa_app \ --path ./vespa.lock -## Install optional workflows dependency (required for examples/workflows/) -install-workflows: - uv sync --extra workflows - -## Start a worker that registers the example workflows (requires install-workflows) -start-examples: install-workflows - uv run python -m examples.workflows.worker - -## Execute the ingestion workflow via the Mistral Workflows API -## Usage: make execute-ingestion input='{"file_path": "sample_data/hello.txt", "collection_name": "mydocs"}' -execute-ingestion: install-workflows - uv run python -m examples.workflows.start --workflow document-ingestion $(if $(input),--input '$(input)',--input '{"file_path":"sample_data/hello.txt"}') diff --git a/template/README.md.jinja b/template/README.md.jinja index bda5618..84f5376 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -48,14 +48,38 @@ Uses `QueryEngine` with `VectorRetriever` (hybrid BM25 + vector via Vespa): make search query="hello world" ``` -#### Custom query profile +### MCP server -Ranking weights live in a Vespa **query profile**, not in the request. The search defaults to the `hybrid-search` profile (tuned BM25 + vector weights, defined in `src/vespa_app/migrations/001_create_index_schema.py`). +The sample app includes an MCP server which exposes search, agentic navigation, and ingest as MCP tools so agents (Vibe, Claude Code, etc.) can query and populate the local index directly. -To use your own, register it with `add_query_profiles([...])` in a new `00X_*.py` migration, rerun `make migrate-vespa`, then select it by name with `query_profile=`: +The server fails fast at startup with a clear error if `MISTRAL_API_KEY` is missing or if the search index does not support agentic navigation. Vespa must be running before you start the server (`make start-vespa`). + +**Available tools:** + +| Tool | Description | +|------|-------------| +| `search(query, top_k=5)` | Hybrid BM25 + vector search; returns ranked chunks with score, content, source_id, locator, **start_offset**, **end_offset**, and metadata | +| `ingest(uri)` | Ingest a local path/directory, `file://` URI, or `http(s)://` URL; text files use plain-text extraction, everything else uses Mistral OCR | +| `open_source(source_id, start_offset, end_offset, window=2)` | Expand around a search hit — returns the anchor chunk plus `window` neighbours on each side, in reading order | +| `navigate_source(source_id, start_offset, end_offset, direction, top_k=1)` | Step through a document from a known position; `direction` is `"next"` or `"previous"` | +| `read_source(source_id, start_offset=None, end_offset=None, top_k=20)` | Fetch a known offset range directly; omit either bound to read from the start or to the end | +| `grep_source(source_id, pattern, mode="phrase", top_k=5)` | Lexical search within a single source; `mode` is `"phrase"` (ordered) or `"term"` (any order) | + +### Vibe CLI + +Run `vibe` from this project directory. It automatically reads `.vibe/config.toml` and connects to the server via stdio — no manual setup needed. You can immediately ask Vibe to search or ingest documents. + +> On first run, Vibe will ask you to trust this directory before loading the project config. Accept the prompt, or pass `--trust` to skip it for that session. + +### Claude Code + +Open this project directory in Claude Code. It automatically reads `.mcp.json` and connects to the server via stdio — no manual setup needed. You can immediately ask Claude to search or ingest documents. + +### MCP Inspector ```bash -make search query="hello world" query_profile=my-profile +make mcp +npx @modelcontextprotocol/inspector http://127.0.0.1:8000/mcp ``` ### Bruno API files (optional) @@ -77,11 +101,14 @@ make generate-vespa-lock ``` src/ ├── entrypoints/ -│ ├── ingest.py # mistralai.search.toolkit.ingestion.pipelines.Pipeline -│ └── search.py # mistralai.search.toolkit.retrieval.QueryEngine +│ ├── ingest.py # mistralai.search.toolkit.ingestion.pipelines.Pipeline +│ ├── mcp_server.py # MCP server (search + ingest tools, stdio transport) +│ └── search.py # mistralai.search.toolkit.retrieval.QueryEngine └── vespa_app/ - ├── __init__.py # VespaApp — mistralai.search.toolkit.plugins.vespa - └── migrations/ # mistral-vespa migrate + ├── __init__.py # VespaApp — mistralai.search.toolkit.plugins.vespa + └── migrations/ # mistral-vespa migrate +.mcp.json # MCP server config (auto-loaded by Claude Code) +.vibe/config.toml # MCP server config (auto-loaded by Vibe CLI) sample_data/ # Sample documents vespa/bruno/vespa/ # Generated by `make bruno` (optional) ``` diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index f3a20f3..46d0d61 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -4,11 +4,15 @@ version = "0.1.0" description = "A Mistral Search Toolkit project" requires-python = ">=3.12,<3.15" dependencies = [ + "fastmcp>=3.4.3", + "httpx>=0.27", "mistralai-search-toolkit[text-splitter-langchain,vespa]==0.0.10", "python-dotenv>=0.9.9", ] [project.optional-dependencies] +# Kept for users who want to build workflow examples on top of this starter app. +# The Makefile no longer installs this automatically; run `uv sync --extra workflows` manually. workflows = [ "mistralai-workflows[mistralai]>=3.0.0,<4", "pydantic", diff --git a/template/src/entrypoints/mcp_server.py b/template/src/entrypoints/mcp_server.py new file mode 100644 index 0000000..030442f --- /dev/null +++ b/template/src/entrypoints/mcp_server.py @@ -0,0 +1,352 @@ +"""MCP server exposing search and ingest tools for the local Vespa index.""" + +import os +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import url2pathname + +import httpx +from dotenv import load_dotenv +from fastmcp import FastMCP +from mistralai.client import Mistral +from mistralai.search.toolkit.embedders import MistralEmbedder +from mistralai.search.toolkit.ingestion import File +from mistralai.search.toolkit.ingestion.extractors import ( + MistralOCRExtractor, + PlainTextExtractor, +) +from mistralai.search.toolkit.ingestion.loaders import FilesystemFileLoader +from mistralai.search.toolkit.ingestion.pipelines import Pipeline +from mistralai.search.toolkit.ingestion.text_splitters import ( + MarkdownTextSplitter, + MarkdownTextSplitterConfig, +) +from mistralai.search.toolkit.plugins.vespa import VespaClientConfig +from mistralai.search.toolkit.retrieval import QueryEngine, VectorRetriever +from mistralai.search.toolkit.search import GrepMode, NavigableIndex, NavigationDirection +from vespa_app import app, vespa_endpoint + +load_dotenv(override=True) + +_TEXT_SUFFIXES = {".txt", ".md", ".markdown", ".csv", ".json"} + +# --------------------------------------------------------------------------- +# Startup — fail fast if the environment is misconfigured +# --------------------------------------------------------------------------- + +_api_key = os.environ.get("MISTRAL_API_KEY", "") +if not _api_key: + raise RuntimeError("MISTRAL_API_KEY is not set. Check your .env file.") + +_collection_name = os.environ.get("COLLECTION_NAME", "exampledocs") + +_mistral_client = Mistral( + api_key=_api_key, + server_url=os.getenv("MISTRAL_API_URL", "https://api.mistral.ai"), +) +_embedder = MistralEmbedder(client=_mistral_client) +_vector_store = app.get_search_index( + VespaClientConfig(endpoint=vespa_endpoint()), + collection_name=_collection_name, +) +if not isinstance(_vector_store, NavigableIndex): + raise RuntimeError( + "The search index does not support agentic navigation. " + "Ensure IndexingMode.DOCUMENT_PER_CHUNK is used in the schema migration." + ) +_navigable_store: NavigableIndex = _vector_store +_query_engine = QueryEngine( + retriever=[VectorRetriever(client=_vector_store, embedder=_embedder)], +) + +_loader = FilesystemFileLoader() +_text_splitter = MarkdownTextSplitter( + MarkdownTextSplitterConfig(chunk_size=4096, chunk_overlap=50) +) +_plain_text_pipeline = Pipeline( + loader=_loader, + extractor=PlainTextExtractor(), + text_splitter=_text_splitter, + embedder=_embedder, + stores=_vector_store, +) +_ocr_pipeline = Pipeline( + loader=_loader, + extractor=MistralOCRExtractor(client=_mistral_client), + text_splitter=_text_splitter, + embedder=_embedder, + stores=_vector_store, +) + +# --------------------------------------------------------------------------- +# MCP server +# --------------------------------------------------------------------------- + +mcp = FastMCP( + "Search Starter App Documents", + instructions=( + "Provides search over a local document index for the starter app. " + "Supports both quick lookup and deep retrieval across long documents, " + "including navigation within a document after an initial search hit. " + "Also supports ingesting new documents into the index." + ), +) + + +def _format_chunks(results: list) -> list[dict]: + """Serialise SearchResult objects into a consistent dict shape. + + Includes start_offset / end_offset so the model can pass them directly + to the agentic navigation tools (open_source, navigate_source, …). + """ + return [ + { + "score": hit.score, + "content": hit.chunk.content, + "source_id": hit.chunk.source_id, + "locator": hit.chunk.locator, + "start_offset": hit.chunk.start_offset, + "end_offset": hit.chunk.end_offset, + "metadata": hit.chunk.metadata, + } + for hit in results + ] + + +@mcp.tool() +async def search(query: str, top_k: int = 5) -> list[dict]: + """Search the indexed document collection using hybrid BM25 + vector retrieval. + + Returns up to top_k chunks ranked by relevance. Each result contains the + chunk content, relevance score, source document identifier, character offsets, + and metadata. + + Use the returned source_id, start_offset, and end_offset with the agentic + navigation tools (open_source, navigate_source, read_source, grep_source) to + drill into a promising document without re-running a global search. + + Args: + query: Natural-language search query. + top_k: Maximum number of results to return (default 5). + """ + result = await _query_engine.search( + query=query, + top_k=top_k, + include_metadata=True, + include_content=True, + ) + return _format_chunks(result.results) + + +def _pipeline_for_name(name: str) -> Pipeline: + """Return the plain-text or OCR pipeline based on the file extension.""" + return ( + _plain_text_pipeline + if Path(name).suffix.lower() in _TEXT_SUFFIXES + else _ocr_pipeline + ) + + +def _filename_from_url(url: str, headers: httpx.Headers) -> str: + """Derive a filename from a Content-Disposition header or the URL path.""" + cd = headers.get("content-disposition", "") + if cd: + for part in cd.split(";"): + part = part.strip() + if part.lower().startswith("filename="): + return part.split("=", 1)[1].strip().strip('"') + name = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] + return name or "download" + + +async def _ingest_http(url: str) -> str: + async with httpx.AsyncClient(follow_redirects=True) as client: + r = await client.get(url) + r.raise_for_status() + name = _filename_from_url(url, r.headers) + content = r.content + + file = File(path=url, name=name, raw=content, source_id=url) + doc = await _pipeline_for_name(name).run_file(file) + return f"Indexed {len(doc.chunks)} chunks from '{url}' into '{_collection_name}'." + + +async def _ingest_local(root: Path) -> str: + if not root.exists(): + return f"Error: path not found: {root}" + + if root.is_file(): + documents = [root] + elif root.is_dir(): + documents = sorted(p for p in root.rglob("*") if p.is_file()) + if not documents: + return f"Error: no files found under {root}" + else: + return f"Error: {root} is neither a file nor a directory" + + total_chunks = 0 + for doc_path in documents: + total_chunks += await _pipeline_for_name(doc_path.name).run( + documents=[doc_path], use_checkpoint=False + ) + return ( + f"Indexed {total_chunks} chunks from {len(documents)} file(s)" + f" into '{_collection_name}'." + ) + + +@mcp.tool() +async def ingest(uri: str) -> str: + """Ingest a document into the Vespa search index. + + Accepts a local path, a file:// URI, or an http/https URL. Directories are + walked recursively when given a local path. Text files (.txt, .md, .csv, + .json) use plain-text extraction; all other formats (PDF, DOCX, …) are + processed with Mistral OCR. + + Args: + uri: Local file/directory path, file:// URI, or http(s):// URL. + + Returns: + A summary of how many chunks were indexed, or an error message. + """ + parsed = urlparse(uri) + + if parsed.scheme in ("http", "https"): + return await _ingest_http(uri) + + if parsed.scheme == "file": + return await _ingest_local(Path(url2pathname(parsed.path))) + + # Bare local path (no scheme) + return await _ingest_local(Path(uri)) + + +# --------------------------------------------------------------------------- +# Agentic navigation tools (RFC: Agentic Search Loop) +# --------------------------------------------------------------------------- + +@mcp.tool() +async def open_source( + source_id: str, start_offset: int, end_offset: int, window: int = 2 +) -> list[dict]: + """Expand around a retrieved chunk to read its surrounding context. + + Use this after a search() hit when the relevant answer may be just outside + the retrieved chunk. Returns up to `window` chunks before and after the + anchor position, plus the anchor chunk itself, all in reading order. + + Args: + source_id: Source identifier from a search() result. + start_offset: start_offset from the anchor search() result. + end_offset: end_offset from the anchor search() result. + window: Number of adjacent chunks to fetch in each direction (default 2). + """ + prev = await _navigable_store.navigate( + source_id, start_offset, end_offset, NavigationDirection.PREVIOUS, top_k=window + ) + current = await _navigable_store.read(source_id, start_offset, end_offset) + nxt = await _navigable_store.navigate( + source_id, start_offset, end_offset, NavigationDirection.NEXT, top_k=window + ) + return _format_chunks(prev + current + nxt) + + +@mcp.tool() +async def navigate_source( + source_id: str, + start_offset: int, + end_offset: int, + direction: str, + top_k: int = 1, +) -> list[dict]: + """Step forward or backward through a document from a known position. + + Use this to move page-by-page through a long document after opening it, + or to scan in a specific direction from a retrieved anchor chunk. + + Args: + source_id: Source identifier from a search() or open_source() result. + start_offset: start_offset of the current anchor chunk. + end_offset: end_offset of the current anchor chunk. + direction: "next" to move forward, "previous" to move backward. + top_k: Number of chunks to retrieve in the given direction (default 1). + """ + nav_dir = NavigationDirection(direction) + results = await _navigable_store.navigate( + source_id, start_offset, end_offset, nav_dir, top_k=top_k + ) + return _format_chunks(results) + + +@mcp.tool() +async def read_source( + source_id: str, + start_offset: int | None = None, + end_offset: int | None = None, + top_k: int = 20, +) -> list[dict]: + """Read chunks from a known character-offset range within a source. + + Use this when you already know the approximate region of interest (e.g. a + page range or section offset) and want to fetch its content directly without + running a new global search. + + Pass None for start_offset to read from the beginning, or None for + end_offset to read to the end of the document. + + Args: + source_id: Source identifier from a search() result. + start_offset: Inclusive lower bound (None = start of document). + end_offset: Inclusive upper bound (None = end of document). + top_k: Maximum number of chunks to return (default 20). + """ + results = await _navigable_store.read(source_id, start_offset, end_offset, top_k=top_k) + return _format_chunks(results) + + +@mcp.tool() +async def grep_source( + source_id: str, + pattern: str, + mode: str = "phrase", + top_k: int = 5, +) -> list[dict]: + """Lexical search for a pattern within a single source document. + + Use this to locate a specific term, name, or exact phrase inside a document + you have already identified via search(), without re-running a global search. + + Args: + source_id: Source identifier from a search() result. + pattern: Text to search for. + mode: "phrase" (default) — terms must appear in exact order; + "term" — all terms must appear but in any order. + top_k: Maximum number of matching chunks to return (default 5). + """ + grep_mode = GrepMode(mode) + results = await _navigable_store.grep(source_id, pattern, mode=grep_mode, top_k=top_k) + return _format_chunks(results) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Run the MCP server.") + parser.add_argument( + "--http", + action="store_true", + help="Start in HTTP (streamable-HTTP) mode instead of the default stdio mode.", + ) + parser.add_argument( + "--host", default="127.0.0.1", help="Bind host (HTTP mode only, default: 127.0.0.1)." + ) + parser.add_argument( + "--port", type=int, default=8000, help="Bind port (HTTP mode only, default: 8000)." + ) + args = parser.parse_args() + + if args.http: + mcp.run(transport="http", host=args.host, port=args.port) + else: + mcp.run() From 258ddab55ec33f2fb9e6cf9ccb4ed72cc3664acb Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Wed, 8 Jul 2026 17:04:13 +0200 Subject: [PATCH 02/10] fix: restore workflows Makefile targets accidentally removed with MCP server Co-Authored-By: Claude Sonnet 4.6 --- .idea/.gitignore | 5 +++++ template/Makefile.jinja | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .idea/.gitignore diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/template/Makefile.jinja b/template/Makefile.jinja index 688082e..f3c927b 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -1,4 +1,4 @@ -.PHONY: installdeps setup-vespa start-vespa stop-vespa reset-vespa migrate-vespa verify-vespa ingest search mcp bruno generate-vespa-lock +.PHONY: installdeps setup-vespa start-vespa stop-vespa reset-vespa migrate-vespa verify-vespa ingest search mcp bruno generate-vespa-lock install-workflows start-examples execute-ingestion ifneq (,$(wildcard .env)) include .env @@ -73,3 +73,15 @@ generate-vespa-lock: --app-dir src/vespa_app \ --path ./vespa.lock +## Install optional workflows dependency (required for examples/workflows/) +install-workflows: + uv sync --extra workflows + +## Start a worker that registers the example workflows (requires install-workflows) +start-examples: install-workflows + uv run python -m examples.workflows.worker + +## Execute the ingestion workflow via the Mistral Workflows API +## Usage: make execute-ingestion input='{"file_path": "sample_data/hello.txt", "collection_name": "mydocs"}' +execute-ingestion: install-workflows + uv run python -m examples.workflows.start --workflow document-ingestion $(if $(input),--input '$(input)',--input '{"file_path":"sample_data/hello.txt"}') From 87055a772c2254ccb76c3c90943c02c52719eb43 Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Wed, 8 Jul 2026 17:06:38 +0200 Subject: [PATCH 03/10] fix: remove stale comment from pyproject.toml.jinja Co-Authored-By: Claude Sonnet 4.6 --- template/pyproject.toml.jinja | 2 -- 1 file changed, 2 deletions(-) diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 46d0d61..abbbff7 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -11,8 +11,6 @@ dependencies = [ ] [project.optional-dependencies] -# Kept for users who want to build workflow examples on top of this starter app. -# The Makefile no longer installs this automatically; run `uv sync --extra workflows` manually. workflows = [ "mistralai-workflows[mistralai]>=3.0.0,<4", "pydantic", From 409b6db651488f8d60afbfac66e6c5e814df77e8 Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Wed, 8 Jul 2026 17:22:06 +0200 Subject: [PATCH 04/10] feat: add delete tool to MCP server Co-Authored-By: Claude Sonnet 4.6 --- template/src/entrypoints/mcp_server.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/template/src/entrypoints/mcp_server.py b/template/src/entrypoints/mcp_server.py index 030442f..23cb264 100644 --- a/template/src/entrypoints/mcp_server.py +++ b/template/src/entrypoints/mcp_server.py @@ -10,6 +10,7 @@ from fastmcp import FastMCP from mistralai.client import Mistral from mistralai.search.toolkit.embedders import MistralEmbedder +from mistralai.search.toolkit.document import compute_id from mistralai.search.toolkit.ingestion import File from mistralai.search.toolkit.ingestion.extractors import ( MistralOCRExtractor, @@ -24,6 +25,7 @@ from mistralai.search.toolkit.plugins.vespa import VespaClientConfig from mistralai.search.toolkit.retrieval import QueryEngine, VectorRetriever from mistralai.search.toolkit.search import GrepMode, NavigableIndex, NavigationDirection +from mistralai.search.toolkit.search.errors import DocumentNotFoundError from vespa_app import app, vespa_endpoint load_dotenv(override=True) @@ -222,6 +224,26 @@ async def ingest(uri: str) -> str: return await _ingest_local(Path(uri)) +@mcp.tool() +async def delete(source_id: str) -> str: + """Delete a document and all its chunks from the search index. + + Use the source_id returned by search() or ingest() to identify the + document to remove. All chunks belonging to that document are deleted. + + Args: + source_id: Source identifier of the document to delete. + + Returns: + A confirmation message, or an error message if the document was not found. + """ + try: + await _vector_store.delete_document(compute_id(source_id)) + return f"Deleted document '{source_id}' from '{_collection_name}'." + except DocumentNotFoundError: + return f"Error: document '{source_id}' not found in '{_collection_name}'." + + # --------------------------------------------------------------------------- # Agentic navigation tools (RFC: Agentic Search Loop) # --------------------------------------------------------------------------- From 663dbc3be43dd3575d4d122f05d141bfa0885e6c Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Thu, 9 Jul 2026 09:26:49 +0200 Subject: [PATCH 05/10] fix: restore original .PHONY ordering in Makefile.jinja Co-Authored-By: Claude Sonnet 4.6 --- template/Makefile.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/Makefile.jinja b/template/Makefile.jinja index f3c927b..7ffd073 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -1,4 +1,4 @@ -.PHONY: installdeps setup-vespa start-vespa stop-vespa reset-vespa migrate-vespa verify-vespa ingest search mcp bruno generate-vespa-lock install-workflows start-examples execute-ingestion +.PHONY: installdeps install-workflows setup-vespa start-vespa stop-vespa reset-vespa migrate-vespa verify-vespa ingest search mcp bruno generate-vespa-lock start-examples execute-ingestion ifneq (,$(wildcard .env)) include .env From ef69155db6f0d2ee2030e0ce918a4be5ecb22caa Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Thu, 9 Jul 2026 09:40:00 +0200 Subject: [PATCH 06/10] Add hardcoded query profile for vector store in MCP in order to enable ranking --- .idea/.gitignore | 5 ----- template/src/entrypoints/mcp_server.py | 1 + 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 .idea/.gitignore diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/template/src/entrypoints/mcp_server.py b/template/src/entrypoints/mcp_server.py index 23cb264..0714c0b 100644 --- a/template/src/entrypoints/mcp_server.py +++ b/template/src/entrypoints/mcp_server.py @@ -50,6 +50,7 @@ _vector_store = app.get_search_index( VespaClientConfig(endpoint=vespa_endpoint()), collection_name=_collection_name, + query_profile="hybrid-search", ) if not isinstance(_vector_store, NavigableIndex): raise RuntimeError( From 615f5d4f7d4ee63c7c3a9757906a33e027e0f98b Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Thu, 9 Jul 2026 15:49:40 +0200 Subject: [PATCH 07/10] Downgrade fastmcp version to allow resolution matching version constraints at this date --- template/pyproject.toml.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index abbbff7..5c23fd4 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -4,7 +4,7 @@ version = "0.1.0" description = "A Mistral Search Toolkit project" requires-python = ">=3.12,<3.15" dependencies = [ - "fastmcp>=3.4.3", + "fastmcp>=3.3.1", "httpx>=0.27", "mistralai-search-toolkit[text-splitter-langchain,vespa]==0.0.10", "python-dotenv>=0.9.9", From 8f3324989194ac9e8e876756ec7f08a9cb5601bf Mon Sep 17 00:00:00 2001 From: Martin Devillers Date: Thu, 9 Jul 2026 18:13:58 +0200 Subject: [PATCH 08/10] docs: improve Quick start section in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +++++++- demo.gif | Bin 0 -> 5432662 bytes demo.tape | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 demo.gif create mode 100644 demo.tape diff --git a/README.md b/README.md index 3ec818e..43569da 100644 --- a/README.md +++ b/README.md @@ -64,19 +64,32 @@ template/ └── migrations/ # mistral-vespa migrate (hybrid query profile) ``` +Port selection is intentionally not part of the initial Copier questions. Generated projects default to `18080` / `19072`; if needed later, users can edit `.env` (`VESPA_QUERY_PORT`, `VESPA_CONFIG_PORT`) without re-generating the project. + ## Quick start (after `copier copy`) +![Demo](demo.gif) + These commands run in the **generated project**. The repo root `Makefile` is only for template CI. ```bash cd my-search-project make setup-vespa +vibe --trust # Or your agent of choice +``` + +Alternatively, run the ingest and search entrypoints directly: + +```bash make ingest path=sample_data/hello.txt make search query="hello world" -make bruno # optional: API files under vespa/bruno/vespa/ ``` -Port selection is intentionally not part of the initial Copier questions. Generated projects default to `18080` / `19072`; if needed later, users can edit `.env` (`VESPA_QUERY_PORT`, `VESPA_CONFIG_PORT`) without re-generating the project. +Generate [Bruno](https://www.usebruno.com/) API collection files to explore the Vespa query and document APIs interactively: + +```bash +make bruno +``` ## Variables diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..12c40d622f169f4d583ee3738eaaa4f57370abef GIT binary patch literal 5432662 zcmdR$XH=8xw(rw`&|ByMLkFc72_PCE^kS&e7riP5K|x$Dm5Rj^&cLW4M zrG%ms5s)I-@5|avJ!hT0&pv0|aqs-(C&%EOna}f^|M{Pfj-EDJ*{K?41v&UG3j%?_ zU@!y%fxe1?!C;h>lvF?nF;P=f)12a^nT)2TrK6{(V_;xl1mgJ=6C)cF5ZrvsOccz_ z%q+D0tUz!uuu8GAj>RAl2sXGf8yg!tlNkF^aIv!^**V!c5GW3IAr1~Uj-%k>;9}$; zlQ}s#x!5_l_)uI1qTDpb+?e@6w!FN&e0+Q;W=a$n z13y2%prD`-ldBLA4syc6!XiM3i-<@{iv$LWvQdlP@e<=P6azwB>iF^FCr+G@P~esH z*OD9=krvgEmX?;Gk(A-OB9oaVC-|G3n6aFkoVu60J~QuPDH(sHljhg`v@C zWgt{lmFq970-286d*U)Ya8B#FI4?y)-m5uvo0taW*Y2Ep2UW9UUEAU0r=z zn7+Qg5tpH{vGG|8iL*}#IK^^PwPaIMQ!~AbW@cvQT11QE9u|gH78Vwk=ws*3owKpA zwzajjv$L~jp>hPmP1ViK%|oxp)1uJJ%gb9w{=8Gvc_0R_UA%Y^55#3ZG1Oxs zG&D3kPBJPgD*D>PSoeeY%N+@(bP4_oi9+m&iHRvGsj04KQ&UqjGBR%6x|MVFdrn+; z9ws60=7+r4$vhxR{BB3AmOdb+y!(L|<4y9U2-M z=H?t89v&^V9UB`PA0MBzm6!rzG5$rI`l~V9nVFf{*;z7~ycCUEHsM}AKlb{nU3h*4Fmd$oJm^cYNt~4*K`@_72(<4-O8#giwD;;Q)OH z@qec^)-pHKRL5z_Ny$LLKt1q(1PTFk3?u>k8+24AAozEX0UW7Yo!=e`XFYD$S6$E< z$t0+st6Njp9m^^2J=0fH)SHOXj6v$v7Wb!$nN`~L*Om-s%D4>V>OCwS&O!UH&-6bm zdwK^GO2eyPS3Xvxn|j=SpswOs8Lmh_Prtr$@~+iG@7aO+yHgJwJ7aha8t%P#==rSD zez4*Gt4H{yfjompRkJNYyX&)qj~>iFAwX&Q3>&K#I-^)mI1Dw`EcGS{8r(K)s(n3} zCGRsg)bw!WX})GGpHXw&+Ou-AyAH$6^>3%DT?TI(wKTkY)#(3rZn)*q);uYc_Ly;N zY(#e>?rqXMUuu`KM2weE$xj6eAO0?4D#IT%?>FLU(eN9Ll6Ewh)G}^jrw%bT40s zKwep0h(tw*Ek+4tcrHeZm6k8YNHnZ2#>(`FEyXE3_gsoczbRizIQ3z5DG@^{zMO<* z_gYTY6{%QGF+91poQl&Hf1PG->Ge9@%Dv)shTWC5*O`tH;%~BCGrZp1@+_@*lYPEn z?M)88M|>qW;JMdIUeKG0mD|66SX;S6pgg{sAI9#zS`Z~txmp-^a(%TZN&EO(F^@N> zTXog7a;-G`xqKuvEkeJnBD%| z+^r(T^!q$`Lg^!sB$R~jsO7g)xOcys@5H+VAS3F`jk{yUZ|}!TmXB%BvP3ay zzy~uh8rWC2U%q>^-NC6ro3J<5OUrA8C)W1YUh{g8cY}M2Bo~1X6U-JXg!6grRfFkR zf~wCyp{u98OM-dK6jI6Hh>-%>p0!Z|CvgSpFpgZzdkuE~PgOlKiHAfOhc58nUYDqi z?w~uSE(Jd!II(k|v-f;Jw0z+9HnZt6u~$Suy0C`^6;xfNp-FXbeatR0XcH`IhsRK{ z=q5)96p6)cQSeoNktUV1&+gA4Bl6Erv&L;>xv%DbzfQyFLXV1Xkfznzk8 zFNu;bD9jMXcM{1@gdH>ga*`x1xcM;@bq^QLeN}>GM?kF;b7e9+bD(MAPVc58?R7$H zzre|5*)H15XJ6D`-S6Z*cwWo##1XEBx~I!W-&qZQ!5)TJe?!lFy7m3^2~G&z$v6|O zErqGyiOr1G^H)Et9z1H><Tl zr02Iwyfqt2B+7A7Yj`mn#H-u>88dqG<;xie)T*69$}@zIZh@)*ml)6}$SZk$dcLDC8F{^8W&fH zDAVmeZu36IYN(L?QD3)I@cURtx+2bA-5$B@_i>(vMch+;Jtv#r$K&IQkSn^qDlgwB z1PvAO9rX2{{`5YPKv#^S((BXU-bsowEEeGC@6%SsaQR?qE z4&F)4rz;WD(Hk($-bpJrEIEFzf55VNC%rnZM8ZRF(B|b%M&nS4RAB$0!>6505?!fG zq~4G-_ik37VX0hZ|B$=N?yb?dQiU?TVQ-t=?CGJ>laKm`eS>#%$aH1sUcHe^*}J)` zhGi;K{UcYJck{O6%1*85Jq>=jd;4&x?DRqZ_op{L?cM>=mt&~(M?<*x^5I718XN%nNaPgJqW%`qqHv5%U z!aA`}p{~b}RZ*%`f+>f`;!p91Ki7 z{d{g_gqB{UJTfLsLdL_=dLvPVkG#(!+iSt zo;n6E$Fo1wl^fmnJ~#OCdGm+*>iGNTJq%vG{QmMoL*wv$-@w6FbDusuBGFgjBMoL2 zxeprqjH)hW4$iz$IcOS