{
  "schema_version": 2,
  "id": "develop/ai/redisvl/concepts/mcp",
  "title": "RedisVL MCP",
  "url": "https://redis.io/docs/latest/develop/ai/redisvl/concepts/mcp/",
  "summary": "",
  "aliases": [
    "/integrate/redisvl/concepts/mcp"
  ],
  "tags": [],
  "last_updated": "2026-10-12T09:34:21+02:00",
  "page_type": "content",
  "content_hash": "ca71a7f700640bb645a39c21bda5a2ad54fa3ab7d58c6305c8228ba002b7930d",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "RedisVL includes an MCP server that exposes a Redis-backed retrieval surface through a small, deterministic tool contract. It is designed for AI applications that want to search or maintain data in one or more existing Redis indexes without each client reimplementing Redis query logic."
    },
    {
      "id": "what-redisvl-mcp-does",
      "title": "What RedisVL MCP Does",
      "role": "content",
      "text": "The RedisVL MCP server sits between an MCP client and Redis:\n\n1. It connects to one or more existing Redis Search indexes.\n2. It inspects each index at startup and reconstructs its schema.\n3. It initializes vector capabilities only when the configured search or upsert behavior needs them.\n4. It exposes stable MCP tools for discovery, search, and optionally upsert.\n\nThis keeps each Redis index as the source of truth for its search behavior while giving MCP clients a predictable interface."
    },
    {
      "id": "how-redisvl-mcp-runs",
      "title": "How RedisVL MCP Runs",
      "role": "content",
      "text": "RedisVL MCP works with a focused model:\n\n- One server process binds to one *or several* existing Redis indexes, each addressed by a logical id.\n- The server supports stdio (default), Streamable HTTP, and SSE transports.\n- Search behavior is owned by per-index configuration, not by MCP callers.\n- Vector search and server-side embedding are optional capabilities configured explicitly per index.\n- Upsert is optional and can be disabled globally with read-only mode or per index with a `read_only` flag.\n\nA single-index server remains the simplest deployment: when exactly one index is configured, callers can omit the index selector entirely and every tool call targets that index. Multi-index support is fully formal — it adds discovery and explicit routing without changing the single-index contract."
    },
    {
      "id": "config-owned-search-behavior",
      "title": "Config-Owned Search Behavior",
      "role": "content",
      "text": "MCP callers can control:\n\n- `query`\n- `limit`\n- `offset`\n- `filter`\n- `return_fields`\n\nThese request-time controls are still bounded by runtime config. In particular,\ndeep paging is limited by a configured maximum result window, enforced as\n`offset + limit`.\n\nOn a multi-index server, callers also choose **which index to target** through an optional `index` argument (see [Index Selection]()). Callers do not choose:\n\n- whether retrieval is `vector`, `fulltext`, or `hybrid`\n- query tuning parameters such as hybrid fusion or vector runtime settings\n\nThat behavior lives in the per-index server config under `indexes.<id>.search`. The response includes `search_type` as informational metadata, but it is not a request parameter."
    },
    {
      "id": "single-and-multiple-index-bindings",
      "title": "Single and Multiple Index Bindings",
      "role": "content",
      "text": "The YAML config uses an `indexes` mapping. Each entry is a logical binding keyed by an id (for example `knowledge` or `tickets`) that points to an existing Redis index through `redis_name`. The mapping may contain one entry or several; each binding is inspected, validated, and given its own search config, runtime limits, and optional vectorizer independently at startup. Startup is all-or-nothing — if any binding fails to initialize, the server does not start.\n\nA single-binding config is the simplest case and behaves exactly as before: the lone binding is the implicit target of every call. With multiple bindings the server stays a single process and endpoint, but callers select a binding per call."
    },
    {
      "id": "index-selection-and-discovery",
      "title": "Index Selection and Discovery",
      "role": "content",
      "text": "On a multi-index server, every tool call must say which logical index it targets:\n\n- `search-records` and `upsert-records` accept an optional `index` argument naming the logical id.\n- When exactly one index is configured, `index` may be omitted and resolves to that sole binding (backward compatible).\n- When multiple indexes are configured, omitting `index` is an `invalid_request`; the caller must name one.\n- An unknown logical id is an `invalid_request`.\n- Both tools echo the resolved `index` in their response so clients can confirm routing.\n\nBecause a client cannot guess the configured logical ids, multi-index servers expose a `list-indexes` discovery tool. **Clients should call `list-indexes` first** to enumerate the available indexes and their filterable fields, then pass the chosen id as `index` on subsequent calls."
    },
    {
      "id": "schema-inspection-and-overrides",
      "title": "Schema Inspection and Overrides",
      "role": "content",
      "text": "RedisVL MCP is inspection-first:\n\n- the Redis index must already exist\n- the server reconstructs the schema from Redis metadata at startup\n- runtime field mappings remain explicit in config\n\nIn some environments, Redis metadata can be incomplete for vector field attributes. When that happens, `schema_overrides` can patch missing attrs for fields that were already discovered. It does not create new fields or change discovered field identity.\n\nStartup also validates that the inspected schema does not collide with\nMCP-reserved score metadata field names for the configured search mode."
    },
    {
      "id": "read-only-and-read-write-modes",
      "title": "Read-Only and Read-Write Modes",
      "role": "content",
      "text": "RedisVL MCP registers `search-records` and `list-indexes` by default (see [Custom Tool Profiles]() for turning a built-in off deliberately).\n\nWrite availability is enforced at two levels:\n\n- **Global read-only mode** disables writes across every binding. It is controlled by the CLI flag `--read-only` or the environment variable `REDISVL_MCP_READ_ONLY=true`.\n- **Per-index read-only** disables writes for a single binding via `indexes.<id>.read_only: true`, while other bindings stay writable.\n\nThese combine into each binding’s *effective* write availability: a binding is read-only if global read-only is on **or** that binding sets `read_only: true`. The `upsert-records` tool is registered only when at least one binding is writable, so a fully read-only server does not advertise it at all. When the tool is registered, a write to a read-only binding is rejected with `forbidden` before any data is changed. `list-indexes` reports each binding’s effective write availability as `upsert_available`.\n\nUse read-only mode when Redis is serving approved content to assistants and another system owns ingestion — globally when no binding should accept writes, or per index when only some indexes are writable."
    },
    {
      "id": "authentication-and-authorization",
      "title": "Authentication and Authorization",
      "role": "security",
      "text": "The HTTP transports can require a JWT bearer token issued by an existing identity provider. The server validates the token signature, issuer, and audience, and can gate read vs write by scope or role claim. A custom tool profile can also scope every query to a tenant carried in the token; see [Tenant Scoping From Token Claims](). What the server does not do is map token claims to Redis ACL users or to separate indexes, which remains a gateway concern. The `stdio` transport is local and is never authenticated.\n\nFor configuration and the gateway boundary, see [Authenticate RedisVL MCP](https://redis.io/docs/latest/../user_guide/how_to_guides/mcp_authentication)."
    },
    {
      "id": "tool-surface",
      "title": "Tool Surface",
      "role": "content",
      "text": "RedisVL MCP exposes up to three built-in tools, plus any configured [custom tool profiles]():\n\n- `list-indexes` enumerates the configured logical indexes for discovery\n- `search-records` searches a selected index using that index’s server-owned search mode\n- `upsert-records` validates and upserts records into a selected writable index, embedding them only when that capability is configured\n\nAny of the three can be turned off with `server.builtin_tools`, independently of whether custom tools are configured — useful for a server that should only ever read, or one that serves nothing but curated profiles:\n\n[code example]\n\nOnly the three names above are accepted; anything else fails at startup rather than being silently ignored.\n\nDisabling a built-in adjusts what the rest of the surface advertises, so the published contract never points at something the server withholds:\n\n- `list-indexes` reports `upsert_available: false` for every binding when `upsert-records` is disabled, since a writable binding still cannot be written to through a tool that is not published.\n- On a multi-index server with `list-indexes` disabled, every tool that requires an `index` — `search-records` and `upsert-records` alike — names the available index ids in its own description instead of deferring to a discovery tool that does not exist. That server still logs a startup warning naming the affected tools, because inlining the ids is a fallback rather than an endorsement of the shape.\n\nA server whose tool set ends up unusable — no tools at all, or discovery disabled on a multi-index server — logs a warning at startup.\n\nTools register once per process. `builtin_tools` is re-read on restart, but the registered tool set is not rebuilt, so a stop/start against an edited config keeps the previous tools and logs a warning saying so. Start a new process to change the tool surface.\n\nThese built-in tools follow a stable contract (profiles differ where noted in [Custom Tool Profiles]() — notably they accept the object filter form only):\n\n- request validation happens before query or write execution\n- the resolved logical `index` is echoed in every `search-records` and `upsert-records` response\n- filters support either raw strings or a RedisVL-backed JSON DSL\n- on a single-index server, `search-records` describes the inspected schema by advertising typed JSON DSL filter fields, object-filter `exists` support, and valid `return_fields`; on a multi-index server those hints are ambiguous, so the description instead directs clients to call `list-indexes` and pass `index`\n- error codes are mapped into a stable set of MCP-facing categories"
    },
    {
      "id": "list-indexes",
      "title": "`list-indexes`",
      "role": "content",
      "text": "`list-indexes` returns one entry per configured binding so clients can route subsequent calls. Each entry reports:\n\n- the logical `id`\n- an optional `description` (only when configured)\n- `upsert_available`, reflecting the binding’s effective write availability\n- `fields`, the filterable fields discovered from the index\n- `limits`, only the runtime limits that were explicitly configured\n\nThe discovery payload is deliberately minimal:\n\n- the underlying Redis index name (`redis_name`) is **never** exposed\n- the vector field and the configured embed-source text field are **omitted** from `fields`, since they are implementation inputs rather than fields a client filters on\n- `limits` shows only explicitly set values (such as `max_limit` or `max_upsert_records`); defaults are not echoed"
    },
    {
      "id": "custom-tool-profiles",
      "title": "Custom Tool Profiles",
      "role": "content",
      "text": "The built-in tools expose the index generically, which leaves the model doing query engineering on every call: pick the index, understand the schema, build a filter, choose return fields. A **profile** moves those decisions into config. It is `search-records` with some arguments pre-filled and frozen and the rest still exposed, published under a name and description of your choosing.\n\nProfiles are pure configuration. You add a `custom_tools` entry to the same YAML the server already loads and restart it; there is no Python to write.\n\n[code example]\n\n`lock` holds what the author decides; `params` holds what the model may still pass. Anything not listed in `params` stays exposed, so a profile that only locks a filter keeps the rest of the built-in’s contract. `index` is pinned by the top-level `index:` key rather than exposed as a param, and may be omitted only when exactly one index is configured.\n\n`params.limit.max` bounds the result count. It applies whether the model names a limit or leaves it out — an omitted limit is capped rather than falling through to the binding’s default. An explicit request above the cap is rejected. When `limit` is hidden (`expose: false`), the cap becomes the fixed result count instead. The cap must not exceed the binding’s own `runtime.max_limit`, which is checked at startup. Note that it bounds page size, not total reachable data: `offset` is a separate argument, so paging is still possible up to the binding’s `max_result_window`.\n\n`suppress_schema_hints: true` drops the auto-generated field hints from the tool description. Those hints enumerate filterable and returnable fields, which is noise once those arguments are locked — and by default they are appended only for arguments the model can still use.\n\nTwo things about filters are easy to conflate:\n\n- `lock.filter` is an ordinary expression in the JSON filter DSL. Its `and`/`or`/`not` operators describe the locked filter’s own content.\n- How the locked filter combines with a model-supplied one is fixed and not configurable: the executed query is always `locked AND caller`. A compound model-supplied expression renders parenthesized, so its `or`/`not` nests *inside* the locked AND and cannot reach the top level — the model can only narrow within the locked scope and never widen past it.\n\nFor that reason a profile accepts only the **object** form of a filter from the model. A raw filter string is rejected both by the advertised schema and by the tool itself, because strings bypass the DSL’s field validation and have no safe composition with a locked expression.\n\nStructure is only half of it: the nesting guarantee holds only while every filter *value* stays inside its own clause. Text `eq`/`ne` values are handled by the `Text` filter itself, which renders them as a quoted phrase with any `\"` or `\\` replaced by a space, so a parenthesis or a `|` the value carries is literal text rather than syntax. Text `like` values are patterns, so the library leaves them raw and this boundary escapes them instead — the delimiters that would close the clause are escaped, the pattern metacharacters are not. Tag values have their delimiters escaped and numeric values are type-checked. A caller filter that still renders as something able to break out is refused rather than combined.\n\nProfiles resolve to a built-in call and nothing more, so they inherit the concurrency cap, request timeout, read-only policy, auth scoping, and error mapping already applied to `search-records`.\n\nBecause adding near-duplicate tools makes tool selection harder rather than easier, built-ins that curated profiles supersede can be turned off with `server.builtin_tools` (see [Tool Surface]()).\n\nMisconfiguration fails at startup rather than at the first call. Among the checks: a name colliding with a built-in or using a reserved `redisvl-`/`redisvl_` prefix; a duplicate tool name; a missing or unknown `index`; a `params` key that is not a real argument; `max` on anything but `limit`, or a cap above the binding’s `max_limit`; hiding `query`; locking `return_fields` while also exposing them; and a locked filter or projection naming a field the bound index does not have. Unrecognized keys are rejected too, so a typo in `lock` fails loudly instead of silently producing a tool that reads as locked but enforces nothing."
    },
    {
      "id": "tenant-scoping-from-token-claims",
      "title": "Tenant Scoping From Token Claims",
      "role": "content",
      "text": "When several tenants share one index, separated by a field such as `org_id`, exposing `search-records` makes the tenant boundary depend on the model remembering to pass a filter. One forgotten filter is a cross-tenant read. A profile can remove that knob: `lock.inject` reads the tenant from the caller’s verified token and AND-combines it into every query the profile runs.\n\n[code example]\n\n`field` and `claim` are independent names: `field` is what the index schema calls the tenant column, and `claim` is what the identity provider calls it. A token carrying `\"https://acme.example/org\": \"acme\"` makes every query from that caller run as `@org_id:{acme} AND <everything else>`.\n\nThe model cannot set the injected value. The field is absent from the tool’s input schema, a call that names it is rejected, and it is left out of the field hints appended to the tool description. It is not hidden outright: unless `lock.return_fields` excludes it, results carry the field, and `list-indexes` describes the whole schema. What the model sees there is only ever its own tenant. If the model filters on the field, its clause ANDs with the injected one, so it can narrow within its own tenant and naming another tenant matches nothing. The rest of the profile works as before, so a static `lock.filter` on another field and a model-supplied filter both still apply.\n\n#### What Counts as a Usable Claim\n\nThe claim must be a single, non-empty string. Anything else refuses the request with a `forbidden` error, and no query runs:\n\n| Claim value                                  | Why it is refused                                                                                                                 |\n|----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|\n| Absent, or the request has no token          | There is no tenant to scope to.                                                                                                   |\n| `null` or `\"\"`                               | An empty tag value would drop the tenant clause from the query entirely.                                                          |\n| A list, such as `[\"acme\", \"victim\"]`         | It would render as a union, `@org_id:{acme|victim}`, which spans both tenants.                                                    |\n| An object, number or boolean                 | It is not a tenant identifier.                                                                                                    |\n| Padded with whitespace                       | It cannot be a real tenant identifier, and refusing is safer than guessing which tenant was meant.                                |\n| Containing a control character or a backtick | The query parser splits a tag term on these, so a value such as `acme` followed by a control character matches the tenant `acme`. |\n\nA `|` inside a single string is accepted. It is escaped, so an identifier such as the Auth0 subject `auth0|64f1c2` matches only its own documents.\n\nA list is refused because of its type, not because of what it renders as. The union it produces is indistinguishable from one a caller could legitimately ask for, so no inspection of the finished query could catch it.\n\nWith several `inject` entries, every entry ANDs into the query, and one unusable claim refuses the whole request rather than narrowing by the entries that did resolve.\n\n#### What Fails at Startup\n\nInjection is checked at startup wherever the configuration alone can show it would not hold:\n\n- authentication is not enabled, on any transport, including an unauthenticated loopback HTTP bind and any `--allow-unauthenticated` bind;\n- authentication is configured but the server runs over `stdio`, which is never authenticated (checked when the server starts through `rvl mcp` or `run_async`; an embedder that calls `startup()` directly is not, and every call is then refused at request time instead);\n- the Redis index is also reachable without the tenant scope: through `search-records`, through `upsert-records` unless every binding over it is read-only, or through another custom tool on it that does not inject exactly the same entries, since a tool scoped by another field, or by the same field from another claim, reads across the tenants this one separates. Bindings that share a `redis_name` count as one index;\n- the injected field is absent from the bound index, is not a tag field, or is declared `NOINDEX`;\n- an `inject` list is empty, names one field twice, or names a field that `lock.filter` also constrains;\n- `required` is anything but `true`, or `from` is anything but `claim`.\n\nAn injected field must be a tag. Text equality is a phrase match over tokenised text, and text is tokenised on punctuation, so the phrase `acme-corp` would also match `acme-corp-eu`.\n\nThe tool set registers once per process. If a restart reloads a configuration that differs from the registered one, the server normally logs a warning and keeps the old tools. When injection is configured on either side of the change, startup fails instead, because keeping the old tenant scoping in force is not something a log line should report.\n\n#### Threat Model\n\nThe guarantee is narrow: a client presenting a validly signed token cannot make the model widen or escape the tenant scope carried in that token. The trust boundary is the identity provider, not the MCP client, so the guarantee holds only while these hold:\n\n- The token is genuinely verified. Use a real signing key and an asymmetric algorithm. The server refuses to start an injecting profile without authentication, but it does not check which algorithm you configured.\n- The identity provider assigns the claim. If a tenant can mint its own token, or set the claim itself, nothing here stops it reading another tenant’s data.\n- The config names every route to the tenant data. The startup check compares bindings by `redis_name`, so it does not see an index alias, or a second index built over the same key prefix. Either one is another route to the same documents, and a tool on it needs the same `lock.inject`.\n- Only trusted ingestion writes the index. The server refuses `upsert-records` on a writable scoped index, because a write can retag another tenant’s document as the writer’s own. Whatever loads documents outside the server is inside the trust boundary.\n- Every document carries exactly its tenant. Stamp the tenant field on each document, indexed as a tag, with the identifier exactly as the identity provider emits it. Redis normalises the stored value, not the claim: it splits it on the field’s separator (`,` by default), so a document stamped `acme,victim` belongs to both tenants; it trims surrounding whitespace; and on JSON storage it indexes every element of an array. A document without the field matches no tenant, so it is invisible rather than shared.\n- Tenant identifiers differ by more than case. Tag fields fold case unless declared `CASESENSITIVE`, and the folding is Unicode-wide: `Acme` and `acme` are one tenant, and so are a Kelvin sign and `K`, or composed and decomposed forms of an accented letter. The server warns at startup when an injected field is not case-sensitive.\n\nWhere tenants share one index, the injected filter is the only isolation boundary. There is no Redis ACL or keyspace separation behind it, so a defect in filter combination or claim validation is a full cross-tenant read. If you need defence in depth, separate tenants at the Redis layer as well.\n\nListing the tenant claim under `auth.required_claims` is a cheap outer layer: the verifier then rejects a token that lacks the claim before any tool runs. That check confirms only that the claim is present. Its value is still validated by the profile on every call."
    },
    {
      "id": "why-use-mcp-instead-of-direct-redisvl-calls",
      "title": "Why Use MCP Instead of Direct RedisVL Calls",
      "role": "content",
      "text": "Use RedisVL MCP when you want a standard tool boundary for agent frameworks or assistants that already speak MCP.\n\nUse direct RedisVL client code when your application should own index lifecycle, search construction, data loading, or richer RedisVL features directly in Python.\n\nRedisVL MCP is a good fit when:\n\n- multiple assistants should share one approved retrieval surface\n- you want search behavior fixed by deployment config\n- you need a read-only or tightly controlled write boundary\n- you want to reuse an existing Redis index without rebuilding retrieval logic in every client\n\nFor setup steps, config, commands, and examples, see [Run RedisVL MCP](https://redis.io/docs/latest/../user_guide/how_to_guides/mcp)."
    }
  ],
  "examples": [
    {
      "id": "tool-surface-ex0",
      "language": "yaml",
      "code": "server:\n  builtin_tools:\n    upsert-records: disabled",
      "section_id": "tool-surface"
    },
    {
      "id": "custom-tool-profiles-ex0",
      "language": "yaml",
      "code": "custom_tools:\n  - name: search-support-tickets\n    based_on: search-records\n    index: support_tickets\n    description: >\n      Search historical customer support tickets by semantic similarity.\n      Use this to find prior resolutions for a customer problem.\n    lock:\n      return_fields: [subject, resolution, created_at]\n      filter: { field: status, op: eq, value: resolved }\n    params:\n      limit: { expose: true, max: 20 }\n      filter: { expose: true }",
      "section_id": "custom-tool-profiles"
    },
    {
      "id": "tenant-scoping-from-token-claims-ex0",
      "language": "yaml",
      "code": "custom_tools:\n  - name: search-customer-kb\n    index: customer_kb\n    description: Search this customer's knowledge base.\n    lock:\n      inject:\n        - field: org_id                      # the tenant field in the index schema\n          from: claim                        # the value comes from the verified token\n          claim: \"https://acme.example/org\"  # the claim name your identity provider emits\n          required: true",
      "section_id": "tenant-scoping-from-token-claims"
    }
  ]
}
