Skip to content

KDBL Context Lake (K-Lake) MCP Server β€” Skills Reference

The K-Lake knowledge platform exposes its crawled-and-extracted content to LLM agents over the Model Context Protocol (MCP). This is the capability ("skills") reference for that server: the tools it offers, what each returns, the security model, and the usage patterns that get good answers.

Every call is security-trimmed per tenant, source, and file β€” enforced in the datastore itself, not by the caller β€” and audited. The model only ever sees content the calling principal is authorised to see.


Connection

Endpoint POST /mcp on kdbl-api (in-cluster: http://kdbl-api.kdbl.svc.cluster.local/mcp)
Transport Streamable HTTP, JSON-RPC 2.0. POST-only β€” GET /mcp returns 405 (no SSE channel)
Protocol revision 2025-11-25
Auth Authorization: Bearer <token> β€” a tenant PAT (kdblpat_…, offline hash check, no IdP) or an OIDC access token. A PAT must carry the mcp, search or read scope (unscoped PATs work this release only). Tenant-scoped: the cluster-admin token is refused at authentication (403, cluster_admin_refused).
Discovery Protected-Resource Metadata at /.well-known/oauth-protected-resource
Enable KDBL_MCP_ENABLED=true on kdbl-api; scopes kdbl.search, kdbl.read
Smoke test kdbl-control --api-url … --api-token <PAT> mcp smoke (initialize β†’ tools/list β†’ tools/call)

Note: the ingress only proxies /api/, so in-cluster MCP clients must hit kdbl-api.kdbl.svc.cluster.local/mcp directly, not the public front door.

Result shape

Each tools/call returns both a back-compat content array (a JSON text block, plus any citations/resources) and a validated structuredContent object. Search hits carry resource_link citations; file reads can carry an embedded resource. Files are addressed by a stable internal URI:

kdbl://file/<url-encoded source_id>/<url-encoded key>

When signed downloads are enabled, a search hit's resource_link carries the file's signed https download link as its uri instead β€” clients such as Microsoft 365 Copilot build their citation panel from that field directly, and an internal kdbl:// identifier there is an unopenable link. Both shapes are resolvable via MCP resources/read (returns the same body as get_file_text).


Skills (tools)

Seven tools: one for finding content, three for reading it, two for browsing the catalogue, and one for a whole-estate overview.

πŸ” search_content β€” search (start here)

Search over extracted content the caller may see. Hybrid by default (semantic + keyword search, fused and reranked); set mode:"lexical" for keyword-only search.

Input: query (required β€” see Query semantics below for operators), source_id (optional β€” restrict to one source), limit (default 20, max 100), mode ("hybrid" default | "lexical"), group_by_file (default false β€” one hit per document instead of per chunk; limit then counts documents), path / content_type / modified_after / modified_before (optional filters), cursor (the previous page's next_cursor).

Output: { hits: [ … ] }, plus total_count (capped at 1000), has_more, next_cursor when there is more, and on the first page a coverage summary (below). Each hit: - source_id, key, seq β€” the citation + chunk locator - text β€” the matched chunk and its neighbouring chunks (a small window, expanded server-side, bounded ~1800 chars). Answer from this. - truncated β€” true if the window was longer than returned (read on with get_file_window) - snippet β€” a <mark>-highlighted fragment (a locator, not the full text) - page_no / char_start / char_end / ts_start_ms / ts_end_ms β€” in-document locators (documents set page/char; audio/video set ts) - url β€” a clickable, short-lived signed link to the ORIGINAL file (when downloads are enabled). Cite this so the user can open the source and verify the grounding; null when the feature is off. - rank β€” relevance score - match_tier β€” how much of the query the chunk literally holds: phrase (your terms occur contiguously, in order β€” these are always ranked first), all (every term present, scattered) or partial. A phrase hit for a multi-word name, reference or title is the strongest evidence you can get; a top hit that is only partial means the corpus phrases it differently β€” reformulate. - name_match β€” the file's name (path, or recovered original name) matched the query. A hit only the name found carries the file's first chunk as text. - with group_by_file: chunk_hits (how many of the document's chunks matched) and other_hits (up to three more of them: seq, page_no, snippet, match_tier, rank).

Query semantics: bare words are relevance-ranked any-of, weighted by term rarity β€” a chunk holding the rare decisive term out-ranks one dense in a common word, so a natural-language question still works as-is and a question phrased differently from the corpus still recalls on the words it shares. Lead with the most distinctive terms (a proper noun / rare word / specific figure). Four operators narrow the match set, in both modes:

Operator Meaning Reach for it when…
"quoted phrase" The hit must contain those words contiguously, in order. You know a name, title or term verbatim.
-word, -"phrase" Exclude. (Exclusions alone return nothing.) One word keeps dragging in the wrong documents.
A OR B At least one of the two; groups phrases or single words. A thing has two names (old / new, formal / everyday).
INC-2026-0714 A reference-like token (hyphens, slashes or dots with a digit, or 3+ parts) matches as a whole β€” automatic. Ticket, contract, invoice or version numbers.

File names count: a document whose path or recovered original name matches is found even when its content does not repeat the words (name_match: true), and a name that holds your phrase verbatim is a phrase hit β€” titles, references in filenames and folder names are searchable as-is. total_count counts chunks matching the operators exactly, so with a phrase or a reference it is an honest "how many places say this".

Paging and coverage. Pass next_cursor back unchanged as cursor, with the same query and filters, for the next page (up to 1,000 results in all). The first page's summary lists the files the top keyword matches come from, with hit counts, and a year facet: years (from a four-digit year in the file name or folder path) and mtime_years (last-modified year, for files with no year in the name). For a multi-year request, check summary.years against the years asked for and run a per-year query for any that are missing or thin before reporting a year as absent.

Date filters are file dates, not document dates. modified_after / modified_before test the file's last-modified time on the source system β€” a migrated archive gives old documents recent times. To find documents from a year, put the year in the query. If a date filter removes every match, the result says so in notice.

Modes. hybrid (default) adds a semantic (vector) arm β€” recall for paraphrase / no-shared-lexeme queries β€” fused with the keyword arm and reranked. lexical is keyword-only (no embeddings/rerank): faster, and the right choice when embeddings are disabled for the tenant/source, or to drive a multi-query agentic search (see below). Hybrid is a per-tenant / per-source toggle; if it's off (or no embedder is deployed) a mode:"hybrid" request transparently degrades to lexical.

πŸ“„ get_file_text β€” whole document

Retrieve a file's full extracted text (as ordered chunks) plus its extraction status. Input: source_id, key. Output: { chunks: [{seq, text, page_no, …}], status } (+ embedded resource). Bounded at 5000 chunks (truncated flag); use get_file_window for very large files.

πŸͺŸ get_file_window β€” bounded sliding read (the read-through tool)

Read a bounded window of a file's chunks β€” a cursor over the document. Input: source_id, key, start (0-based chunk index, default 0), length (default 40, max 200). Output: { chunks, total_chunks, next_cursor, window: {returned}, has_more, text }.

Use it to (a) read a large document in pieces, or (b) pull more context around a search_content hit β€” set start β‰ˆ the hit's seq minus a few, then slide forward with next_cursor.

🏷️ get_file_metadata β€” file facts

Metadata for one file. Input: source_id, key. Output: size, mtime, etag, storage class, owner, content type, and POSIX mode/uid/gid where captured.

πŸ“š list_sources β€” browse sources

Sources the caller can access, paginated. Input: cursor, limit (default 50, max 200). Output: { sources: [{source_id, protocol, enabled, last_indexed_at}], next_cursor? }.

πŸ—‚οΈ list_files β€” browse a source

Files within a source, paginated by key. Input: source_id (required), cursor, limit (default 50, max 200). Output: { files: [...], next_cursor? }.

🧭 data_estate_summary β€” whole-estate overview

Summarise the entire tenant data estate β€” "what data do we have?" Call this FIRST to orient before searching: it tells you what kinds of documents exist, how much, how fresh, how much is already searchable, which sources dominate, and what languages are present, so you can then target search_content intelligently (narrow by content_type / source_id / path, pick mode). Served from a pre-aggregated rollup β€” fast, no full scan. Input: source_id (optional β€” summarise just one source), top (default 15, max 50 β€” entries per breakdown). Output: totals (files, bytes, sources, last_indexed_at), coverage (extracted_ok / failed / skipped / pending / searchable_pct), and top breakdowns β€” file_types, content_types, languages, size_distribution, age_distribution, top_sources.

Tenant-admin only. The estate view isn't per-user access-trimmed, so a non-admin caller gets an authorization error. Every other tool is available to any tenant user, with results access-trimmed to what that caller may see. See Estate insights for the same data in the console and CLI.

πŸ• list_file_versions β€” the version history of one file

Archived past versions plus the live one, for a source with version tracking enabled. Input: source_id, key. Output: current (content_version, chunk_count, extracted_at) and revisions, newest first (revision_no, content_version, text_sha256, chunk_count, size_bytes, extracted_at, archived_at). Answers "how many times has this document changed?" and gives you the revision_nos to feed to diff_file_versions. Empty revisions with a non-null current means the file hasn't changed since tracking began β€” or that tracking is off for its source.

πŸ”€ diff_file_versions β€” what changed between two versions

A unified (line) diff of the extracted text. Input: source_id, key, and from / to β€” each either a revision_no from list_file_versions or the literal "current". Defaults to from = the newest archived revision, to = "current": "what changed in the latest update." Output: unified_diff (empty when identical), stats (insertion/deletion line counts), identical, and each side's content_version. Use it to explain how a policy, contract or report changed after it was re-indexed.

πŸ§‘β€πŸ€β€πŸ§‘ search_entities β€” who and what the documents talk about

A search over the extracted entity vocabulary rather than over document text: people, organisations, roles, locations, dates, amounts, reference numbers and topics. Substring match, so raman finds Priya Raman. Input: q (name or fragment, minimum 3 characters β€” omit to list the most-mentioned), kind (optional, e.g. PERSON, ORG, LOCATION, TOPIC), limit (default 50, max 200). Output: entity_id, kind, display_name, tenant-wide mention_count. This is the entry point for "who is X / how are X and Y connected" questions β€” resolve the name to an entity_id here first, because it tells you the exact spelling the corpus uses. The corpus may hold several near-identical entities for one real person (bare surnames are deliberately not merged, being genuinely ambiguous), so check the candidates before assuming one.

Direct relationships for one entity β€” who they work for, where they are located, who they communicated or travelled with. Input: entity_id (from search_entities), predicate (optional, e.g. WORKS_FOR, LOCATED_IN, COMMUNICATED_WITH), limit (default 50, max 200). Output: per neighbour, a predicate, a direction (out = this entity is the subject, in = it is the object β€” "Alice WORKS_FOR Acme" and its reverse are very different claims) and evidence_count, the number of distinct documents asserting that edge. Sort your attention by evidence_count: one document is a claim, twenty is a pattern. Results are capped and ranked by evidence; truncated tells you there are more.

πŸ“Ž entity_documents β€” cite an entity

The documents that mention an entity, most-mentioning first, with the surface form as it actually appeared. Input: entity_id, limit (default 50, max 200). Output: source_id / key β€” feed them to get_file_text or get_file_window to read the passage β€” plus a url to the ORIGINAL file, which is what you cite. This is the citation path for anything learned from search_entities or entity_related: entity and relation extraction is statistical and does make mistakes, so never assert a relationship as fact without reading a document that asserts it.

The same graph is browsable in the console and the CLI β€” see Knowledge graph.


  1. Locate with search_content. Lead with the most distinctive terms, and quote the part you know verbatim ("Statement of Cash Flows"). Read match_tier: a phrase hit is the strongest evidence; a partial top hit means the corpus phrases it differently. Don't stop after one query β€” if the top hits don't contain the answer, reformulate with the document's own wording (exact line-item names, section/statement titles, proper nouns, a specific figure or date) and search again; narrow with path / content_type / modified_*; exclude a distractor with -word; or page via has_more. For "which documents mention…" questions, set group_by_file.
  2. Read from the hit's text (it already includes neighbouring chunks). If text is truncated, or a hit looks relevant but the answer continues beyond the window, call get_file_window around the hit's seq.
  3. Answer, grounded, citing the kdbl://file/… source. If nothing answers the question after reading, say so β€” don't fall back to prior knowledge.

This loop is what a chat client's system prompt should enforce; see the Air-gapped AI demo for a ready-made prompt.

Agentic search beats single-shot β€” especially in lexical mode

The reformulate-and-retry loop is not a fallback; it is the way to get high recall. On a public financial-filings benchmark, single-shot lexical retrieval found the right document in the top-10 only ~40–46% of the time, vs ~60–75% for hybrid. But an agent that issues a few lexical queries β€” reformulating with each statement's printed wording β€” found the right document 75–95% of the time (scaling with model quality), matching or beating single-shot hybrid with no embeddings at all. The takeaway for client/system-prompt authors: instruct the model to treat the first result set as a clue, not an answer, and to re-query in the corpus's vocabulary before giving up. lexical mode is a first-class agentic retrieval surface β€” cheaper than hybrid and fully air-gappable (no embedder, no GPU).


Each search_content hit can include a url β€” a clickable, short-lived HS256-signed link to the original source file (the PDF/etc.), so a user can open it and verify the grounding. K-Lake doesn't store originals; the link re-fetches on demand:

GET /api/files/download?t=<signed token>   (add &dl=1 to force download vs preview)

The token (β‰ˆ15 min) carries the principal it was minted for; the endpoint re-checks authorization before streaming (404 if the principal can no longer see the file), and the byte fetch is delegated to the component that holds the source connectors and credentials (the API never does). Every download is audited (tool = files/download). Enabled when KDBL_DOWNLOAD_SIGNING_SECRET, KDBL_API_PUBLIC_URL, and KDBL_INTERNAL_FETCH_TOKEN are set; otherwise url is null and the route 404s.


Security & audit

  • Per-file trimming β€” every query runs in a row-level-security-scoped transaction that cannot bypass the policies, so tenant + source-ACL + per-file-grant visibility is enforced on top of the explicit tenant filter. There is no way for a tool to return content the principal can't see.
  • Audit β€” every call writes an audit row (principal, tool, arguments, sources, keys returned, row count, status, client IP, timestamp). Review with kdbl-control … mcp audit --tool search_content.
  • Offline auth β€” PATs verify offline against a stored hash with no IdP/JWKS fetch, so the server is fully usable air-gapped.

Retrieval characteristics & limits

  • Keyword search: ranking weights terms by rarity, so a rare decisive term out-ranks a common one β€” no AND/coverage tuning needed.
  • Hybrid (default) adds semantics. The semantic arm is an approximate nearest-neighbour search over chunk embeddings, fused with the keyword arm, diversified, and reranked. This catches paraphrase / no-shared-lexeme queries that pure keyword search misses. Hybrid is a per-tenant / per-source toggle and requires a deployed embedder; without one, or when disabled, retrieval is lexical-only and mode:"hybrid" degrades transparently. Cost/latency of each mode is reported by GET /capacity.
  • Keyword mode matches where the words appear. In mode:"lexical", a question phrased in words that never occur in the answer text won't surface it on the first try β€” but reformulating with the corpus's own vocabulary across a few queries recovers it (see "Agentic search" above). Or use list_files + get_file_window to read a known document directly.
  • Bounded by design. The keyword candidate scan returns a true global top-N (cap ≫ limit) before trimming and filters; get_file_text caps at 5000 chunks; get_file_window caps at 200 per call β€” all to keep latency and payloads bounded at scale.

Documented clients

Cloud and local MCP clients are covered in Connecting AI clients; a fully air-gapped self-hosted-LLM + chat-interface setup driving these tools is in the Air-gapped AI demo.