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 hitkdbl-api.kdbl.svc.cluster.local/mcpdirectly, 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:
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.
πΈοΈ entity_related β expand one entity's relationships¶
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.
Recommended usage pattern (locate β read β answer)¶
- Locate with
search_content. Lead with the most distinctive terms, and quote the part you know verbatim ("Statement of Cash Flows"). Readmatch_tier: aphrasehit is the strongest evidence; apartialtop 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 withpath/content_type/modified_*; exclude a distractor with-word; or page viahas_more. For "which documents mentionβ¦" questions, setgroup_by_file. - Read from the hit's
text(it already includes neighbouring chunks). Iftextistruncated, or a hit looks relevant but the answer continues beyond the window, callget_file_windowaround the hit'sseq. - 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).
Opening the original file (download links)¶
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:
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 byGET /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 uselist_files+get_file_windowto 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_textcaps at 5000 chunks;get_file_windowcaps 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.