Added
-
A networked server now records which key called which tool, and
contextlake kb keys usagereads it back.kb keys list'sLAST USEDcolumn,
frozen at-since it shipped, is filled from the same file.Usage: 140 calls (140 timed) /home/you/.contextlake/kb/mcp-usage.jsonl KEY CALLS ERR THR DENY P50 P95 k_4f2a91 120 0 0 0 75ms 142ms k_9c01de 20 20 500 0 423ms 843ms Refused requests (never reached a tool) throttled 500 unknown 30 identity_unset 12 total 545A row has six fields and there is nowhere to put a seventh: the minute, the key id,
the tool name, the outcome, the tool time in whole milliseconds, and how many events the
row stands for. No query text, no symbol, no repository, no file path, no client address,
and nothing about the credential a refused caller presented. The recorder takes keyword
arguments only, with no free-text parameter and no**kwargs, so that is structural
rather than a sanitiser somebody has to remember to run.Counts, not lines, for traffic the server never admitted. The eight refusal outcomes
and the identity fault are counted into one row per key, tool and minute; 500 refused
requests are one line reading500. An unauthenticated flood would otherwise evict every
real row inside a minute. Calls from an issued key keep one row each, because a
percentile needs the individual values.A refused call is recorded too. The row is written in the tool wrapper's outer
finally, so a call refused by the tool grant, a call the rate limiter never admitted and
a call that raised are all in the file. Percentiles are nearest-rank; a refusal above the
concurrency slot carries no duration and prints-rather than0ms.Rows buffer in memory and are written every ten seconds and on shutdown, so a tool call
does no disk I/O. The file grows to 22,000 rows and is then trimmed back to the newest
20,000, so the rewrite happens once per 2,000 rows instead of once per append. A line the
reader cannot score, from a truncated write or a newer contextlake, is skipped and
counted, andkb keys usagesays how many rather than quietly reporting a short total.Three things it deliberately does not measure, each stated on the surface that prints it:
oneaskcounts once, asask, since it reaches its eight siblings below the wrapper;
tools/listand the handshake cross no wrapper, soCALLScounts tool calls and never
HTTP requests; andkb://statsresource reads are not recorded.Off with
--no-usageor[serve] usage = false.[serve] usage_max_linesand
usage_flush_secondstune it, read only from a config you named, the same gate
[serve] keys_fileand the quota defaults go through.stdio is unchanged, byte for byte. It builds no recorder, reads no ContextVar and does not
load the usage module at all. -
--rate,--burstand--cost-budgeton a key are now enforced over the
network. They were recorded and read by nothing. Measured on a live
kb serve --transport http --keys-onlyserver: a key created--rate 3/min --burst 4
answered four calls and thenHTTP/1.1 429 Too Many Requests content-type: application/json retry-after: 20 {"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"rate limit exceeded for this key: 3/min. retry in 20s"}}Two buckets per key, filled lazily from two floats each.
--rateand--burstbound
requests;--cost-budgetbounds tool TIME, as a duration per period (30s/min), and
each call is charged how long its body ran. A duration rather than a count because a
count mispricesaskby 8x: it is one request and eight tool bodies, sinceask
reaches its siblings below the wrapper that could have counted them.The refusal is at the gate, before the request reaches any tool. So it costs one
header parse rather than a worker thread, and it coverstools/listand the
kb://statsresource, which cross no tool wrapper at all. A caller with no valid key
gets401and is never counted against a quota: identity resolves first, which is
what keeps the bucket map keyed by ids this server minted rather than by anything a
caller can forge.Values are validated now.
kb keys create --rate 60is refused at the flag, naming
the string, so a typo cannot be minted onto a key that then reads as limited. The same
parser runs over every stored value when a key file is loaded for serving: a bad value
exits 1 before the socket binds, and a bad value introduced by a live edit is rejected
with one warning while the previous keyring keeps serving.noneon any axis means no limit there, and beats a server default.--burstneeds
--rate: on its own it is the capacity of a bucket that does not exist. The minimum
burst is 4, because an MCP client spends three requests on the handshake before its
first tool call.Not persisted and not shared between processes: a restart refills every quota, and two
server processes give each key twice its quota. On thessetransport the 429 message
is lost and the session closes, which is a defect in that client, not in this server.
docs/mcp-transports.mdcarries all three.stdio is unchanged, byte for byte. It builds no limiter, opens no timer and does not
load the rate-limit module at all. -
[serve] default_rate,default_burstanddefault_cost_budgetinkb.toml, for
a quota that applies to every key that names none of its own. All three are unset out
of the box, so an upgrade starts limiting nobody. Read only from
~/.contextlake/kb.tomlor a file passed to--config: a.contextlake.kb.tomlfound
by walking up from the current directory is ignored with one line saying so, because a
rate limit a repository checkout can rewrite is not a limit.A shared token is bounded by
default_rateand has no per-credential opt-out, since it
has no key record to writenoneon. -
Unknown keys in
[serve]are warned about. The table was known but its keys were
never checked the way[kb]keys are, sodefault_rat = "60/min"was a silent way to
leave every key unlimited. It now prints one line naming the key and the known set. -
--toolsand--ownerson a key are now enforced over the network. They were
recorded and read by nothing. Measured on a livekb serve --transport http --keys-onlyserver: a key created--tools none --repos nothing-matches/*used to
get the full tool list and its calls all ran; the same key on the same server now
gets an empty tool list and a refusal that names the group which would grant the
call.--reposand--externalstill bind nothing and still print
(recorded, not enforced);--rate,--burstand--cost-budgetwent live in the
same release, below.Enforced at three surfaces, because a gate on one is a gate the caller walks around
by using another: the tool wrapper,tools/list, and thekb://statsresource, which
answers the countsgraph_statsanswers and crosses no wrapper at all.--toolstakes comma-separated groups (graph,search,docs,stats,owners,
semantic) plusall,readandnone.readis every group exceptsemantic. A
group this server does not know is refused atcreate, so a typo cannot be minted
onto a key that then reads as scoped. In a hand-edited key file the same value is
denied rather than refused: it narrows the key and never widens it.askis refused unless every tool it routes to is granted. It calls eight siblings
directly, below the wrapper that checks a grant, so a key grantedaskand denied
blast_radiuswould otherwise reachblast_radiusthrough the impact route.--owners realallowswho_knows;pseudonymousandhiddenrefuse it, and refuse
askwith it. There is no anonymiser on the network path, so a key that asked for
pseudonyms gets no names rather than real ones.--reposis deliberately not enforced. It cannot be decided from a call alone: a
node id does not carry the repository it came from, andrepo_dependencies,
repo_flowandrepo_event_flowtake a requiredrepoand return rows naming other
repositories. Correct scoping needs a filter inside the store.stdio is unchanged, byte for byte. It reads no key, no policy and no identity, and it
loads no grant module.
Changed
-
The
(recorded, not enforced)label moved from per line to per axis. One label
after all three scope axes claimed the same thing about all three, so enforcing
toolsalone would have made the line sayreposandownerswere live too.
kb keys shownow marks each axis on its own, and an unset axis carries no marker at
all, because it records no scope for a marker to qualify. -
kb keys showandlistprint the EFFECTIVE quota and where each value came
from. The limits line used to read a bareunsetfor a key that named no rate. With
[serve] default_rateset, such a key is limited, and an operator readingunset
hands it out believing it is not. It now renders one of four states per axis:
rate=60/min (enforced),rate=unset -> 60/min from [serve] default_rate (enforced),
rate=unset (no limit), orrate=none (enforced: no limit, set on the key). The
ratecolumn inlistshows the effective value for the same reason.Each per-record
--jsondocument gainseffective_rate,effective_burst,
effective_cost_budget(strings or null) andlimits_source, an object mapping each
of the three axes tokey,configorunset. No field is removed and none changes
type. -
policy_enforcedin every--jsondocument is derived rather than a fixed
false. It answers whether every axis the document renders is enforced, so a key
scoped only on--toolsreadstrueand the same key with--reposadded reads
false. Aburstrecorded beside no rate does not count as enforced: it is the
capacity of a request bucket that does not exist, so listing it would make a key that
limits nothing read as limited. A key with no policy at all readsfalse, not a vacuoustrue: the fact an
operator needs is whether anything limits the key, and for the key a bare
kb keys create alicemints the answer is no. Each key also carries a new
enforced_axeslist. The field is not removed and does not change type.
Fixed
- The graph's empty state named a flag that does not exist on the command the reader
is running. "Widen the seed, raise--max-nodes, or clear filters" is right when the
page is rendered standalone bykb graph, and wrong inside the dashboard, where the same
template is embedded in an iframe andkb dashboardhas no such flag: following the
advice answers'--max-nodes' isn't a flag on 'dashboard'. It now names the node cap
rather than a spelling that is correct on one of the two surfaces, which is the rule
kb.languagesalready follows atparse.py:1467.