Skip to content

Introduces local RAG Ingest MCP server (rag-ingest-mcp) on top of unstructured-ingest that pairs with transform-mcp (supports Chroma, Qdrant, pgvector)#750

Draft
CodesLikeIcarus wants to merge 10 commits into
mainfrom
feat/ingest-rag-mcp
Draft

Introduces local RAG Ingest MCP server (rag-ingest-mcp) on top of unstructured-ingest that pairs with transform-mcp (supports Chroma, Qdrant, pgvector)#750
CodesLikeIcarus wants to merge 10 commits into
mainfrom
feat/ingest-rag-mcp

Conversation

@CodesLikeIcarus

@CodesLikeIcarus CodesLikeIcarus commented Jul 7, 2026

Copy link
Copy Markdown

What

A local STDIO MCP server (unstructured_ingest/mcp/, run as python -m unstructured_ingest.mcp) that turns Transform MCP output into a queryable local RAG corpus. It pairs with the remote Transform MCP: Transform parses/chunks/(optionally) embeds and returns Element JSON; this server fetches that JSON out of band (it never enters the agent's context), embeds it locally iff Transform didn't, upserts it into a local vector store, and serves matched-space similarity search.

Three tools: load_transform_output (URLs, file paths, or directories; embed_policy auto/passthrough/local), search, list_collections.

Design

  • The embedding-space invariant. A collection is pinned to one (provider, model, dimension) on first load; loads with a different model are refused (SpaceMismatch), and every query is embedded into the pinned space. Corpus/query symmetry is structural, not a discipline — mixing embedding models silently ruins retrieval, so the server makes it impossible.
  • Three backends behind one VectorStore contract (stores/), selected by URAG_STORE_BACKEND:
    • chroma (default) — embedded, zero-config; pin in collection metadata
    • qdrant — embedded local mode or a server (URAG_QDRANT_URL); pin in a reserved meta collection
    • pgvector — psycopg 3; auto-provisions the table + cosine HNSW index (≤2000 dims) on first load; pins in a rag_ingest_spaces table
  • Connector-shaped writes. Rows are conformed by the corresponding ingest stagers (ChromaUploadStager, QdrantUploadStager, SQLUploadStager), so what lands in each store matches what the ingest pipeline's own connector would produce, and element ids are deterministic (re-loading upserts, never duplicates).
  • Backend client libraries import lazily — an install only needs the extras for the backend it uses.

Testing

  • Conformance suite (test/unit/mcp/test_stores.py) runs identically against every backend and is the working definition of "supported backend": load → cosine ranking → space-guard refusal → idempotent re-load → metadata round-trip. Chroma and Qdrant run embedded with no services; pgvector runs against URAG_TEST_PG_DSN (verified vs a dockerized pgvector/pgvector:pg17).
  • Server-level suite (test/unit/mcp/test_server.py) drives the FastMCP server in-memory over both embedded backends: load/list, directory expansion, per-source space-guard errors.
  • Real end-to-end (all three backends): Transform MCP upload → chunk_by_character job → download_urlload_transform_output (local embed, text-embedding-3-small/1536) → search. Three real files (PDF/MD/HTML, 92 chunks); three topical queries each returned the expected document as the top hit with identical scores across backends.

Docs

unstructured_ingest/mcp/README.md plus examples/ with a getting-started guide per backend: setup, docker-compose where a service is needed, the agentic loop with Transform MCP, CLI inspection, and when (not) to pair the DB vendor's own MCP server with a corpus built here.

🤖 Generated with Claude Code

Review in cubic

CodesLikeIcarus and others added 6 commits July 6, 2026 11:14
A FastMCP STDIO server that lands Unstructured Transform MCP output into a local vector store and serves similarity search. Transform parses/chunks/optionally embeds on the platform; this server fetches the Element JSON out of band, embeds it locally when Transform did not, upserts into a local Chroma collection, and answers queries embedded into that same space.

Reuses unstructured-ingest's Chroma stager/uploader and embedding encoders, so the write path matches the ingest pipeline's. A collection is pinned to one embedding space (provider, model, dimension); later loads with a different model are refused, keeping corpus and query vectors comparable.

Tools: load_transform_output (embed_policy auto/passthrough/local), search, list_collections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A source may now be a directory; every *.json file under it is
ingested as its own document.
…g-3-small

The 1536-dim default fits every candidate backend's ANN-index limits
(pgvector caps indexable vectors at 2000 dims). Passthrough loads must
name the model Transform actually used via embed_model; documented.
The store is now a package: a VectorStore contract (space pin,
auto-provision, idempotent upsert, cosine scores, connector-shaped
writes) with one implementation per backend, selected by
URAG_STORE_BACKEND.

- chroma (default): unchanged behavior, moved to stores/chroma.py
- qdrant: embedded local mode or a server; space pins live in a
  reserved meta collection since Qdrant collections carry no metadata
- pgvector: psycopg 3; a table per collection with a JSONB metadata
  column and a cosine HNSW index when the dimension is indexable;
  pins live in a rag_ingest_spaces table

A conformance pytest suite (test/unit/mcp) runs identically against
every backend and defines what supported means; a server-level smoke
suite drives both embedded backends through FastMCP in-memory.
An overview of the local agentic RAG loop (Transform MCP + this
server) plus one self-contained guide each for Chroma, Qdrant, and
pgvector: setup, docker-compose where a service is needed, the agent
workflow, CLI inspection of each database, and when the DB vendor's
own MCP server does or does not pair safely with a corpus built here.
The wire-it-up section now links each backend's getting-started guide
and its ready-to-copy host config instead of a single Chroma-only
example; the standalone example_mcp_config.json is superseded by the
per-backend examples/*/mcp-config.json files.
The unit-test CI environment installs only locked base dependencies, so
fastmcp, chromadb, and qdrant_client are not importable there. Gate the
server suite and each conformance backend with pytest.importorskip —
the repo's existing pattern for optional connector dependencies.
@CodesLikeIcarus CodesLikeIcarus changed the title Add rag-ingest-mcp: local RAG MCP server (Chroma, Qdrant, pgvector) Introduces local RAG MCP server on top of unstructured-ingest that pairs with transform-mcp (supports Chroma, Qdrant, pgvector) Jul 7, 2026
@CodesLikeIcarus CodesLikeIcarus changed the title Introduces local RAG MCP server on top of unstructured-ingest that pairs with transform-mcp (supports Chroma, Qdrant, pgvector) Introduces local RAG Ingest MCP server (rag-ingest-mcp) on top of unstructured-ingest that pairs with transform-mcp (supports Chroma, Qdrant, pgvector) Jul 7, 2026
@lawrence-u10d

Copy link
Copy Markdown
Contributor

Security review

Nice, well-structured PR — the space-pinning invariant, the streaming byte cap, deterministic idempotent upserts, and the parameterized pgvector SQL are all carefully done. A few security notes, one worth addressing before merge.

1. SSRF: the out-of-band fetch has no destination guard (please address)

fetch.py fetches arbitrary http(s) URLs with no validation of the resolved destination:

with httpx.stream("GET", url, timeout=_FETCH_TIMEOUT_S, follow_redirects=True) as resp:

There's no block on private / loopback / link-local / cloud-metadata address ranges, and follow_redirects=True runs with no per-hop re-validation (so a guard on just the initial URL would still be bypassable via a redirect).

Because sources is a tool argument, in an agentic loop over untrusted documents it is effectively attacker-influenced (prompt injection). A crafted sources=["http://<internal-host>/..."] makes this local server reach whatever the host machine can reach — localhost/LAN services, unauthenticated internal endpoints — pull any JSON-array response into the corpus, and surface it back through search. Impact is bounded by the single-user local context and by the fact that the body must parse as a JSON array, but it's a real SSRF primitive in a new fetch path.

Suggestion: resolve the hostname once, reject private/loopback/link-local/metadata ranges, connect pinned to the validated IP (preserving Host + TLS SNI), and re-validate on every redirect hop.

2. Arbitrary local file / directory read via tool args

fetch_elements reads any local path (""/file://path.read_bytes()), and _expand_sources does a recursive rglob("*.json") on any supplied directory. Under the same prompt-injection model, sources=["/"] or a path to a sensitive JSON file harvests local content into a searchable corpus. Single-user context limits the blast radius, but consider constraining reads to a configured root and documenting that sources is security-sensitive input.

3. pgvector SQL — clean ✅ (one non-security footgun)

The SQL construction is solid: strict identifier regex (_check_name) on every entry point, psycopg.sql.Identifier for table/index names, sql.Literal for the dimension, and bound params for all data including vector literals and LIMIT. No injection surface.

Footgun (not security): CREATE TABLE IF NOT EXISTS {collection} + INSERT ... ON CONFLICT (id) means a collection name colliding with a pre-existing table in the target database silently adopts/writes into it. Consider schema-qualifying collections (e.g. a dedicated schema) or documenting "use a throwaway database."

4. Note: stored text_as_html in metadata

The stagers land engine-supplied text_as_html into row metadata, which search returns verbatim. Not an issue for this server (it never renders HTML), but any downstream consumer that renders search-result metadata as HTML would need to sanitize it — worth a one-line note in the README.

None of the above are blockers except #1, which I'd like to see guarded before merge given it's a brand-new outbound-fetch path. Happy to help with the SSRF-guard implementation if useful.

The pgvector VectorStore hand-rolled a narrow table of its own
(id text, record_id, text, metadata jsonb, embedding) instead of the
Unstructured-compliant schema the SQL connector and downstream readers
(e.g. Dragon Lab's pgvector index) expect. A reader pointed at a
rag-ingest-loaded table therefore found none of its expected columns
(embeddings, type, element_id, …), so the two could not share a table.

Rewrite ensure_space/write/search to:
- adopt a pre-created table — verify the vector dimension and record the
  space pin without recreating it — or, standalone, auto-provision an
  equivalent id/type/record_id/element_id/text/embeddings schema with a
  cosine HNSW index;
- fit staged rows to the table's actual columns (union across rows);
- cast the vector to the column's own vector/halfvec type, which the
  generic SQLUploader does not (it adapts the list to a Postgres array).

Chroma and Qdrant stores already reuse their connector stagers and
build connector-shaped writes, so they are unchanged. The backend
conformance suite (test/unit/mcp/test_stores.py) passes for all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants