MCP server¶
KDBL Context Lake (K-Lake) ships a Model Context Protocol (MCP) server so AI clients — Claude Desktop, IDE assistants, custom agents — can search and retrieve your crawled and extracted content directly, without a bespoke integration. It speaks MCP revision 2025-11-25 over the Streamable HTTP transport on a single endpoint (/mcp).
What it exposes: read-only search over extracted file text and retrieval of that text plus file metadata. It never serves original file bytes — K-Lake doesn't store those; only extracted text and metadata are returned.
The security posture has three parts, and all three are always on when the server is enabled:
- OAuth 2.1 Resource Server auth — every call carries a bearer token validated against an MCP-specific audience. No token, wrong audience, or wrong origin is rejected before any query runs.
- Per-file trimming — every tool opens a row-level-security-scoped transaction, so a caller sees only the files they are authorized to see. See per-file security trimming.
- Audit — every operation (and every auth denial) is recorded with who/what/when/where, both to a queryable table and to the structured log stream.
You enable and configure the server from the web console, the CLI, or the API — see below. Once it's running, tenant administrators get a dedicated AI access (MCP) page in the web console — live access flow, active connections, token revocation, and per-source exposure control. The audit log behind it is also available from the kdbl-control CLI and the REST API.
Enabling the server¶
The MCP server ships dark — until you switch it on, neither /mcp nor its metadata is served, and the endpoint is indistinguishable from one that was never built.
Switching it on is a settings change, not a deployment change. Nothing restarts, and you do not edit any configuration files.
From the console¶
Open AI access (MCP) › Setup (or press ⌘K / Ctrl+K and type set up AI access). The Set up AI access card walks the whole sequence in six steps: Turn on the endpoint, Set the address clients will use, Choose which tenant it serves, Check it works, Let people sign in and Connect an assistant. Each step shows whether it is done, so you can resume a half-finished setup rather than starting over. See MCP monitoring & access control for each step.
Because the endpoint address and remote access are shared by every tenant on the deployment, changing them needs the deployment's administrator: a cluster administrator, or the administrator of the deployment's only tenant when that tenant manages itself. Other tenant administrators see the steps read-only and can run the checks — enough to configure their AI clients.
From the CLI¶
kdbl-control mcp config # what's set, and where each value came from
kdbl-control mcp set --enabled true \
--resource-uri https://kdbl.example.com/mcp
kdbl-control mcp validate # check it against reality
Changes apply immediately on the node that served the request and within about ten seconds everywhere else.
Settings¶
| Setting | Default | Purpose |
|---|---|---|
| Enabled | off | Master switch. When off, neither /mcp nor the metadata routes are served. |
| Endpoint address | — | The public URL clients connect to, e.g. https://kdbl.example.com/mcp. Advertised in metadata as resource and is the audience every token must target (RFC 8707). It must match exactly what clients reach. |
| Scopes | kdbl.search,kdbl.read |
Scopes advertised in the metadata document. |
| Allowed origins | empty | Browser Origin allowlist. Empty rejects any request carrying an Origin header (DNS-rebinding protection). Server-to-server clients send no Origin and pass. |
| Allow API audience | off | Escape hatch for identity providers that cannot issue resource-scoped tokens. Weakens the no-token-passthrough guarantee — leave off unless you need it. |
Each of these can also be supplied as a deployment default (KDBL_MCP_* on the kdbl-api deployment). Anything set through the console or CLI takes precedence, and clearing a setting falls back to the deployment default — so a system configured entirely through Helm keeps working untouched.
If clients cannot connect¶
Run the checks — AI access (MCP) › Setup › Check it works › Run checks, or kdbl-control mcp validate. They test the things a configuration cannot check about itself, and the most common failure is the endpoint address naming an internal hostname while clients arrive on a public one. The addresses disagree, discovery fails, and the client reports only "authentication failed" with nothing pointing at the cause.
Authentication¶
The MCP endpoint is an OAuth 2.1 Resource Server. The kdbl-api reuses its existing token-resolution logic, but validates against the MCP audience rather than the API audience.
Supported credential types¶
| Credential | How it's recognized | Notes |
|---|---|---|
| OIDC bearer token | A JWT whose issuer matches a configured tenant | The primary path. The token must carry an issuer (iss) and an audience (aud) claim; a token missing either is refused with 401 (auth.token_missing_issuer / auth.token_missing_audience). Its aud must equal the resource of the endpoint it is presented at — the resource URI, or …/t/<tenant>/mcp. Tenant-scoped. |
| Personal access token (PAT) | kdblpat_… prefix |
First-party token minted from /api/tokens or the Tokens tab of AI access (MCP) in the console. Tenant-scoped, and held to its scopes (below). A token minted from 0.1.50 expires within the deployment's ceiling, 365 days by default (chart value mcp.patMaxTtlDays), or sooner if asked; see Tokens. |
| Cluster-admin token | Matches the configured cluster-admin secret | Refused with 403 at authentication, audited as cluster_admin_refused. The endpoint can face the internet, so the deployment's most powerful credential is not accepted there at all. Connect with a tenant user's sign-in or a tenant PAT. |
Token scopes¶
A personal access token is accepted on /mcp only if one of its scopes can search or read. The MCP tools only search and read, so:
| PAT scopes | On /mcp |
|---|---|
mcp, search or read (with or without others) |
Accepted |
kdbl.search / kdbl.read (what the console's AI access (MCP) › Tokens tab mints) |
Accepted |
Only admin (an automation token for the API) |
Refused: 403, audited as scope_refused |
| No scopes at all (the full-authority default for tokens minted before scopes existed) | Accepted for this release only — see below |
OAuth scopes. An access token from K-Lake's built-in sign-in carries the scopes the client was granted, and /mcp holds it to them. The scopes a client asks for are checked against the deployment's scope list (the scopes in the MCP settings, kdbl.search kdbl.read by default, which is also what the server advertises); an unsupported scope is refused with the standard OAuth invalid_scope error, and a client asking for none is granted the whole list. At /mcp, a token scoped only to something else is refused with 403 and an insufficient_scope challenge, audited as scope_refused. A token issued before 0.1.50 carries no scope claim and is admitted until it expires, within the hour, so the upgrade signs nobody out. Tokens from a tenant's own identity provider are admitted by their audience, as before.
Revocation. Revoking a personal access token, deleting its user, or revoking an edge agent takes effect on every API server within five seconds, not only on the one that handled the request. Signing out an assistant's session (AI access (MCP) › Tokens), removing a client, and the response to a replayed refresh token also cancel the access tokens that session already holds: their next call is answered 401, on every server, within five seconds.
Deprecation: unscoped tokens. For one release, a PAT with no scopes keeps working on /mcp. Every use is counted in kdbl_mcp_scope_grace_total, written to the audit log (tool (auth), status ok, error_code scope_grace; at most once per token every five minutes) and logged as a warning naming the token's visible prefix, so you can find each token still in use. Re-mint those tokens with the mcp scope (or search / read); a token minted from the console's AI access (MCP) › Tokens tab already qualifies. To end the grace now, set mcp.abuse.scopeGrace: false (KDBL_MCP_SCOPE_GRACE=off): unscoped tokens are then refused with 403 (scope_refused). The next release ends the grace by default. Unscoped tokens are unaffected on the REST API.
Audience binding¶
For OIDC tokens, the expected audience is resolved per-tenant from oidc_config.mcp_audience on the tenant record. A token whose aud does not match is rejected with 401. If a tenant has no mcp_audience set and the API-audience fallback is off, that tenant is simply unreachable over MCP (its tokens 401) — the server never falls back to validating an MCP request against the API audience unless you explicitly opt in.
This is the RFC 8707 model: mint tokens whose audience is the MCP resource URI, so an MCP token can't be replayed against the main API and vice versa.
One address, many tenants¶
Every tenant can be served over MCP from the same address. The first tenant
connected answers on the bare endpoint (https://<host>/mcp, issuer
https://<host>) — the shape every earlier deployment was connected with, kept
so nobody reconnects. Every other tenant gets its own endpoint under the same
origin:
| Endpoint | Issuer | Discovery | |
|---|---|---|---|
| First tenant | https://<host>/mcp |
https://<host> |
/.well-known/oauth-protected-resource/mcp, /.well-known/oauth-authorization-server |
Tenant acme |
https://<host>/t/acme/mcp |
https://<host>/t/acme |
/.well-known/oauth-protected-resource/t/acme/mcp, /.well-known/oauth-authorization-server/t/acme |
A distinct issuer per tenant is what lets K-Lake resolve a token to its tenant. A token minted for one tenant is refused at any other tenant's endpoint, a code or refresh token can only be redeemed at the token endpoint that minted it, a client registered under a tenant's endpoint belongs to that tenant, and deleting a tenant ends its endpoint and every token minted for it in the same moment.
Manage it in the console: AI access (MCP) › Setup › 3. Choose which tenant it
serves lists every tenant with Connect, Move bare endpoint here and
Take off MCP; nothing is typed, because the issuer and audience are derived
from the endpoint address in step 2 and merged into the tenant's existing
configuration. The API equivalent is PUT /api/mcp/tenants/:slug with
{scoped, take_over}. A tenant can also bring its own identity provider
and its own signing keys. The whole sequence, with screenshots, is on
Multi-tenant deployments.
Protected Resource Metadata (discovery)¶
An MCP client that hits /mcp without a token gets a 401 carrying a WWW-Authenticate: Bearer challenge with a resource_metadata= pointer. Following that pointer, the client fetches the RFC 9728 Protected Resource Metadata document — served unauthenticated at two routes:
/.well-known/oauth-protected-resource/.well-known/oauth-protected-resource/mcp
Both return the same JSON:
{
"resource": "https://kdbl.example.com/mcp",
"authorization_servers": ["https://login.example.com/tenant-a/v2.0"],
"scopes_supported": ["kdbl.search", "kdbl.read"],
"bearer_methods_supported": ["header"]
}
For a tenant on its own endpoint, the document lives at /.well-known/oauth-protected-resource/t/<tenant>/mcp, its resource is https://…/t/<tenant>/mcp, and its authorization_servers names that tenant's issuer. On the bare endpoint, authorization_servers is the set of distinct OIDC issuers configured across all tenants — K-Lake is multi-tenant and multi-IdP, so a client picks the authorization server that issued it and obtains a token whose aud is the single canonical resource.
Identity providers¶
OIDC validation is shared with the main API, so any standards-compliant provider works: Microsoft Entra ID (Azure AD), Google, Okta, and Keycloak are all supported. Per-tenant settings (issuer, audience, groups claim) live on the tenant's oidc_config; the MCP path adds one field, mcp_audience. A tenant can federate to a provider of its own instead of the deployment's — see Multi-tenant deployments. Group-to-identity correlation for per-file trimming is covered in directory sync.
For the full setup — per-IdP walkthroughs, the permissions each one needs, and the tenant settings that go with them — see OAuth and identity providers.
Origin guard¶
When a request carries an Origin header, it must appear in the allowed-origins list or the request is rejected with 403 (origin not allowed). This protects against DNS-rebinding from browser contexts. Non-browser clients send no Origin and are unaffected.
Connecting a client¶
Point your MCP client at the Streamable HTTP endpoint — the bare one for the first tenant, or the tenant's own:
AI access (MCP) › Setup in the console shows each connected tenant's URL; a tenant's own admin sees theirs under Settings › AI assistants › Endpoint › MCP endpoint.
The endpoint accepts a single POST per JSON-RPC request and replies application/json. There is no server-initiated SSE stream — a GET /mcp returns 405. Configure the client for OAuth: it discovers the authorization server and resource via the metadata document and obtains a token with the MCP aud.
Fetch the metadata yourself, unauthenticated, to confirm the server is up and advertising the right issuers and resource:
End-to-end smoke test (fetches metadata, then runs initialize + tools/list with your token). Use a tenant user's token — a PAT scoped mcp, search or read — since the cluster-admin token is refused on /mcp:
The report includes the metadata document, the initialize response (protocol version + server info), and the advertised tool list.
Exposing MCP to the internet¶
With remote access on (see Connect your AI), /mcp and the sign-in endpoints are reachable by anyone who knows the hostname. Three layers stand in front of them, from the outside in:
| Layer | What it does | Configure |
|---|---|---|
| Cloudflare (your account) | TLS, DDoS absorption, and optionally WAF, rate limiting and Access | Cloudflare dashboard — see the recommendations below |
| The remote-access gateway | Serves only the MCP, sign-in, discovery and download paths (everything else is 404); per-address request and connection limits; a 1 MB body cap; timeouts against slow clients; security headers; an access log that never records query strings (a signed download link carries a credential). Only the tunnel may reach it. |
mcp.gateway.rateLimit.* |
| K-Lake | Personal-access-token scopes (above); a per-user request rate; a budget of failed sign-ins per address and per token; the Origin allowlist; audience-bound tokens; a time limit on every MCP query; anti-framing and no-store headers |
mcp.abuse.* |
The per-user rate and the failed-sign-in budgets apply on every deployment, whether or not it hosts many customers.
Default limits¶
The defaults are sized for an organisation of 1,000–10,000 users running both Microsoft 365 Copilot and Claude Desktop. Copilot reaches K-Lake from a few shared Microsoft cloud addresses and Claude Desktop from a few corporate NAT addresses, so anything limited per address is in effect limited per organisation and is set generously. The per-user rate is the primary control.
| Limit | Default | Where | Configure |
|---|---|---|---|
| Requests per user (tenant + user) | 10/s, burst 40 | K-Lake | mcp.abuse.ratePerSecond, mcp.abuse.rateBurst |
Requests per address on /mcp |
500/s, burst 1000 | Gateway | mcp.gateway.rateLimit.mcpPerSecond, mcpBurst |
| Requests per address on the sign-in, discovery and download paths | 30/s, burst 100 | Gateway | mcp.gateway.rateLimit.oauthPerSecond, oauthBurst |
| Concurrent connections per address | 1000 | Gateway | mcp.gateway.rateLimit.connectionsPerIp |
| Failed sign-ins per address | 300/min, burst 300 | K-Lake | mcp.abuse.authFailPerMinute, mcp.abuse.authFailBurst |
| Failed sign-ins per token | 10/min, burst 10 | K-Lake | mcp.abuse.tokenFailPerMinute, mcp.abuse.tokenFailBurst |
A caller over a limit is answered 429 (K-Lake's own limits add a Retry-After header). 0 switches any of the mcp.abuse limits off.
Failed sign-ins are throttled per token as well as per address. A client replaying an expired, revoked or wrongly-scoped token spends its own budget and is then answered 429 on that token, without its retries being charged to the address — so one broken client cannot lock out everyone else behind the same corporate NAT or cloud egress address. Someone guessing tokens still pays the address budget. The token itself is never stored; the budget is keyed on a one-way digest of it.
mcp:
abuse:
ratePerSecond: 10 # per caller (tenant + user); 0 = off
rateBurst: 40
authFailPerMinute: 300 # failed sign-ins per client address; 0 = off
authFailBurst: 300
tokenFailPerMinute: 10 # failed sign-ins per presented token; 0 = off
tokenFailBurst: 10
scopeGrace: true # unscoped PATs still accepted this release
gateway:
rateLimit:
mcpPerSecond: 500
mcpBurst: 1000
oauthPerSecond: 30
oauthBurst: 100
connectionsPerIp: 1000
scopeGrace is the one-release grace for personal access tokens with no scopes — see Token scopes. Re-mint such tokens with the mcp scope.
Recommended Cloudflare settings¶
For the tunnel's public hostname:
- Leave Cloudflare Access off the MCP hostname unless every client you use can complete an Access login. Claude, Copilot, ChatGPT and Gemini call
/mcpfrom their own servers and cannot; an Access policy in front of them blocks them. K-Lake's own OAuth is the authentication layer. If you want Access anyway, use a Service Auth policy for a client that can send service tokens, or Cloudflare's MCP server portals. - Turn on the WAF managed rules (Cloudflare Managed Ruleset), and turn Bot Fight Mode off for this hostname — it challenges server-to-server traffic.
- Add a rate-limiting rule as a backstop, set above the gateway's own limits: for example 36,000 requests per minute per IP on
/mcpand 2,400 per minute per IP on/token,/registerand/authorize. A tighter rule refuses a whole organisation, because Copilot and a corporate NAT send every user's requests from the same few addresses. - Set the minimum TLS version to 1.2 and enable Always Use HTTPS.
- Optionally restrict by country, or to your AI providers' published egress ranges, if your policy allows it.
Everything the gateway and K-Lake enforce holds without any of these; they move the refusal of abusive traffic out to Cloudflare's edge. The metrics and ready-made alerts that watch these limits are listed in Telemetry.
Tools¶
Up to fourteen read-only JSON-RPC tools are exposed via tools/list / tools/call. Every one runs inside an access-scoped transaction, so results are already trimmed to the caller's authorization. The tools are tenant-scoped; the cluster-admin token is refused at the endpoint.
Which tools a user is shown¶
The tool list is built for each user. Every signed-in user is shown the search, reading, listing, version and entity tools. Two kinds of tool are shown only to the users who can use them:
warehouse_schemaandwarehouse_query, only to a user who can question at least one warehouse (see Who is shown the warehouse tools);data_estate_summary, only to tenant administrators.
Everyone else's assistant never sees them, which saves context in every session and stops a model trying a tool that would only refuse. A call to a tool the user isn't shown gets the same "unknown tool" error as a name that doesn't exist, and is audited as denied. Clients that sign each user in (Claude, the Microsoft 365 Copilot connector, ChatGPT, a personal access token) read the list as that user. Copilot Studio reads it once, while the agent is being built, as the maker, so build a warehouse agent with an account that has warehouse access. Because the list depends on who is asking, list results are marked private and must never be served from a shared cache.
| Tool | Arguments | Returns |
|---|---|---|
search_content |
query (required — bare words, "quoted phrases", -exclusions, A OR B; references like INC-2026-0714 match as a whole; once identifier-parts search is enabled, a word also matches part of an underscore-joined identifier such as IFC_kozijn_90x114), source_id (optional), limit (1–100, default 20), mode (hybrid default / lexical), group_by_file (default false), path / content_type / modified_after / modified_before (optional filters), published_after / published_before (the document date), fields (the documents' metadata fields, for example {"ticker": ["CORD"]}), level (optional classification levels), interpret (read a plain-English request into filters; see Asking in plain English), cursor (the previous page's next_cursor) |
Ranked hits over extracted text — and file names — the caller can see, each with the matched passage and its neighbours (text), a highlighted snippet, match_tier (phrase / all / partial; phrase hits first), name_match, a citation (source, key, page/char/timestamp locator), a resource link to fetch full text and a signed url to open the original. group_by_file: true returns one hit per document with its other matching chunks folded under it. In hybrid mode, phrase matches and files whose name holds every query term are kept through reranking, so an exact title or file-name match is not lost. Plus total_count (capped at 1000), has_more and, when there is more, next_cursor — see Paging and coverage. |
get_file_text |
source_id, key (both required) |
Extraction status, chunk count, and the ordered text chunks for one file, plus the reconstructed full text as an embedded resource (capped at 5000 chunks). |
get_file_window |
source_id, key (both required), start (chunk cursor, default 0), length (1–200, default 40) |
A bounded, sliding window of a file's text by chunk position. Returns only the window (an indexed range scan, never the whole file), total_chunks for planning full coverage, a char_range, and a next_cursor to slide forward. Use it to read a large document in pieces, or to pull context around a search_content hit's seq. |
get_file_metadata |
source_id, key (both required) |
Size, mtime, etag, storage class, owner, indexed-at, content type, and POSIX mode/uid/gid where captured. |
list_sources |
cursor (optional), limit (1–200, default 50) |
Paginated list of sources the caller can access (id, protocol, enabled, last-indexed-at), with a nextCursor. |
list_files |
source_id (required), cursor (optional), limit (1–200, default 50), published_after / published_before, fields (optional, as search_content's) |
Paginated list of files within one source (key, size, mtime, document_date, fields), with a nextCursor. With a document-date window, only the files in it, oldest first; with fields, only the matching files. |
list_file_versions |
source_id, key (both required) |
The file's current version (content_version, chunk_count, extracted_at) and its archived revisions, newest first. Empty revisions means the file has not changed since tracking began, or tracking is off for its source. |
diff_file_versions |
source_id, key (both required), from / to (each a revision_no or "current") |
A unified line diff of the extracted text between two versions, with insertion/deletion counts, an identical flag, and each side's content_version. Defaults to "what changed in the latest update". |
data_estate_summary |
source_id (optional), top (1–50, default 15) |
Tenant-wide totals (files, bytes, sources), extraction/searchability coverage, and top breakdowns by file type, content type, language, size band, age band and source. Tenant-admin only — the estate view is not per-user trimmed, so it is shown only to tenant administrators. |
search_entities |
q (optional, min 3 chars; an aliased ticker may be shorter), kind (optional), limit (1–200, default 50) |
Entities matching a substring, with entity_id, kind, display_name, tenant-wide mention_count and aliased tickers. An aliased ticker resolves to its company, listed first. Omit q to list the most-mentioned. Only entities appearing in documents the caller can read are visible. |
entity_related |
entity_id (required), predicate (optional), limit (1–200, default 50) |
One entity's direct relationships: the neighbour, the predicate, a direction (out = this entity is the subject, in = it is the object) and evidence_count, the number of distinct documents asserting the edge. |
entity_documents |
entity_id and/or ticker (one required), limit (1–200, default 50) |
The documents mentioning an entity, most-mentioning first, with the surface form as it appeared, source_id/key, and a url to the original file; then the files filed under one of its aliased tickers (via: "ticker"). |
warehouse_schema |
warehouse (optional), model (optional; needed only when the warehouse offers the caller several models) |
The data warehouses the caller may question, and one warehouse's semantic model — entities, typed attributes, declared relationships, worked questions — never its tables. A warehouse that offers the caller several models lists them as models (slug, name, description). Shown only to users who can question at least one warehouse (see above). See Data warehouses. |
warehouse_query |
warehouse (optional), model (optional, as for warehouse_schema), one of question / query / sql, limit |
One bounded query run against the warehouse as the caller: typed columns and rows, truncated, the exact statement and parameters that ran, and the identity the warehouse evaluated it as. sql only where the warehouse allows it. See Data warehouses. |
Files are also addressable as MCP resources. A search_content hit's resource_link carries the file's signed download link as its uri when signed downloads are enabled (so a client that renders citations from the link block — Microsoft 365 Copilot's Sources panel does — gets an openable https link), and the internal kdbl://file/<source_id>/<key> identifier (percent-encoded components) only when they are off. A resources/read of either shape returns the same body as get_file_text.
What the assistant receives is data¶
Everything a tool returns came from your files or databases, and a file can
contain text written to look like instructions to an AI. So every tool result
is labelled as data (_meta["kdbl.io/untrusted"] = true), and the server's
instructions and the reading tools' descriptions tell the assistant that
document text is data and that only the user's instructions are to be
followed. Control characters, invisible characters and Unicode text-direction
override characters are removed on the way out; the text is otherwise
returned exactly as extracted, and what is stored is untouched. Handles the
assistant passes back, such as a file's key, links and paging cursors, are
returned as stored, because they must round-trip. K-Lake itself never acts on
retrieved content: every tool is read-only, and results are trimmed to the
caller's permissions and capped.
Plain-English requests
search_content can read a request such as "all real estate notes from 2025" into its date, find-all, file-type and metadata filters before searching. See Asking in plain English.
Paging and coverage (search_content)¶
- Paging. When
has_moreis true the result carriesnext_cursor. Pass it back unchanged ascursor, with the same query, filters, mode and grouping, to get the next page; a cursor replayed against a different search is refused.offsetis not supported (a non-zerooffsetis an error). Paging reaches at most 1,000 results in total. - Coverage summary. The first page also carries
summary: the files the top 1,000 keyword matches come from (plus files whose name matches), each with its hit count — the 25 with most hits — and a year facet.yearscounts matching files by a four-digit year found in the file name or folder path (3M_2016_10K.pdfis 2016);mtime_yearscovers files with no year in the name and is only the file's last-modified year on the source system. An assistant asked for "the last ten years of X" can check the facet and run a per-year query for any year that is missing, rather than concluding it is absent. - Finding every document. With
group_by_file: true, paging reaches every matching document up to the 1,000-result limit, and the result's file count is exact. Filters (path, content type, dates, fields, classification level) are applied before results are ranked and cut, so a narrow filter finds every matching file the caller may read. - Date filters.
published_after/published_beforetest each document's document date: the date a source's metadata rules read from the file's folder or first page, else its last-modified date.modified_after/modified_beforetest each file's last-modified time on the source system, not the document's own date — a migrated or copied archive gives old documents recent times. When a date filter removes every match, the result says so in anoticerather than returning a silent empty list. - A filter too broad to resolve exactly. On a very large library, a filter that matches a very large share of it may be too broad to resolve exactly within the request's bounds. An empty first page then carries a
noticesaying so, rather than returning a silent empty list. Narrow the filter, or add query words. - Repeated boilerplate ranks last. Text a source repeats at the end of many documents (a regulatory disclosure, a disclaimer, a signature) is ranked below genuine content, so a query that shares its words returns the documents that actually discuss the topic. To search for the boilerplate, quote four or more of its words. See Sources.
retrieval. Every reply says how the search ran:requested(the mode asked for),sources_searched,hybrid_countandlexical_count, always.hybrid_sourcesandlexical_sourceslist the source IDs of each arm only while a list holds 50 IDs or fewer, so a reply stays small however many sources a tenant has. When hybrid search was asked for but some sources were searched by keyword only,lexical_becausesays why (no embedder available, hybrid switched off, or no vectors yet). Read the counts rather than the lists (from 0.1.46).
Sources are exposed to MCP by default. Every source the caller is entitled to see is searchable over MCP unless a tenant admin explicitly hides it (per-source exposure control is Sources AI assistants can search on the Endpoint card under Settings › AI assistants; see MCP monitoring & access control). A
search_contentcall with nosource_idspans all exposed sources the caller can access — you don't need to target a specific volume.
Audit¶
Every tool call, resource read, and auth denial is recorded twice: a row in the mcp_audit table, and a structured tracing event on target mcp.audit. Both are best-effort with respect to the request — an audit-write failure is logged but never fails the MCP call. Tool arguments are sanitized before they're stored (long strings truncated; the bearer token is a header and is never in arguments).
The mcp_audit table¶
| Field | Meaning |
|---|---|
id |
Record id. |
tenant_id |
Owning tenant. NULL for pre-identity denials. |
user_id |
Resolved user. NULL for anonymous denials. |
principal |
Email / oidc:iss:sub / first-party identifier. The who. |
principal_kind |
oidc | pat | cluster_admin | anonymous. |
tool |
search_content, resources/read, (auth) for denials, etc. The what. |
arguments |
Sanitized argument summary (no secrets). |
sources |
Source ids touched/returned. The where (in the data). |
keys_returned |
Count of file keys returned. |
keys_sample |
Bounded sample (≤20) of returned keys for spot audits. |
status |
ok | denied | error | not_found. |
error_code |
e.g. invalid_token, not_found, bad_request, cluster_admin_refused, scope_refused, scope_grace. |
row_count |
Hits / chunks / rows returned. |
client_ip |
Caller IP (proxy-forwarded when behind nginx). The where (on the network). |
origin |
Origin header. |
user_agent |
Client user agent. |
session_id |
MCP-Session-Id when present. |
request_id |
JSON-RPC id, for trace correlation. |
protocol_ver |
MCP-Protocol-Version. |
created_at |
Timestamp. The when. |
The table is access-scoped: a tenant sees only its own rows; a cluster admin sees all. It is append-only for the application: K-Lake can add and read audit rows but cannot change or remove them, as with the admin audit log.
Reviewing the audit log¶
For live monitoring, the AI access (MCP) page in the web console renders this same activity as a real-time access-flow graph. For long-term review, filtering, and export, use the CLI or API below.
CLI — newest first, with optional filters:
kdbl-control --api-url "$KDBL_URL" --api-token "$KDBL_TOKEN" mcp audit
kdbl-control --api-url "$KDBL_URL" --api-token "$KDBL_TOKEN" mcp audit \
--tool search_content --principal alice@example.com --limit 100
API — the CLI is a thin wrapper over this endpoint:
curl -H "Authorization: Bearer $KDBL_TOKEN" \
"$KDBL_URL/api/mcp/audit?tool=search_content&limit=100"
| Method | Path | Description |
|---|---|---|
GET |
/api/mcp/audit |
List MCP audit entries, newest first. Query params: tool, principal, limit (1–500, default 50). Scoped to the caller's tenant. |
Streaming to your SIEM¶
The parallel mcp.audit tracing event carries the same fields as JSON on stdout, so you don't need DB access to feed a security service. Filter your log pipeline on target: "mcp.audit" to capture every MCP operation and denial. See Telemetry for the logging setup.