Repository navigation
Releases: Lazy-Coder-03/leftbrain
Release list
v0.5.1
Fixed
- Signing in and connecting an account no longer fail with an internal error. The hosted
server holds one connection to its key database, and a managed Postgres closes a connection
that has sat idle. Writes reopened it; reads did not. Connecting an account reads before it
writes - it looks up the application, then lists the account's keys - so the first person to
arrive after a quiet spell met an internal error, and so did everyone after them, until the
server was restarted. Authorised tool calls failed the same way and for the same reason.
Every read now reopens a dropped connection the way writes always did, and the connection
asks for TCP keepalives so an idle drop is noticed before somebody runs into it. A
self-hosted server on SQLite was never affected.
v0.5.0
Upgrading to 0.5.0
- If a client or key used the
holidaystool, it is gone:tools/listno longer offers it and
a call is refused as an unknown tool. For "is this date a working day" questions, use
datetimebusiness_days/add/diffwithregionandsubdiv- the public-holiday
calendar behind those is unchanged. There is no replacement for festival lookups; a date the
server cannot source is a date it should not answer. - Keys scoped to
holidayskeep working for every other tool they name; edit the scope on the
dashboard when convenient. - Reminder from 0.4.2:
/external/mcpis retired; every tool is on/mcp.
Removed
- The
holidaystool. It answered "which festival is when" from tables it could not vouch
for. A dry run across 2026 for twenty Indian states and twenty-four countries, and a
three-source comparison (the West Bengal gazette, an astronomical tithi computation, and the
upstream tables) showed the upstream Durga Puja days off by one for 2026 and half the
Bengali festivals absent altogether - and a date with no source behind it is exactly what
this server exists to replace. Rather than ship it as a source of confident wrong answers,
it is retired:holidaysis no longer published,/docs/tools/holidaysis gone, the
catalogue is 17 tools, anddatetime's festival anchors ({"festival": ...}as a date) are
refused with the reason. A key whose stored scope namesholidayskeeps loading and simply
no longer lists it. What stays:datetime'sadd,diffandbusiness_daysstill
honourregion/subdiv, because counting working days needs only the public-holiday
tables, which are the part of the upstream dataset that is what it says it is. (#80, #86, #91)
v0.4.2
Added
- The scope grid says which tools reach the internet.
weather,fx_rate,geoand
url_checkcarry anetworkpill with a title that says the call's input goes to a
third-party service, sit under a "Reaches the internet" heading, and a No network button
beside All / None / Expand all unticks the four and leaves everything else as it was. The
same partial serves the create-key form, the key's scope editor and both consent pages, so
a key that arrives with all four ticked - every key with no scope, including one minted by
approving a consent screen - is never decided on a page that does not mention the internet.
The fact lives with the data:scopes.NETWORK_TOOLSis the external half of the catalogue,
and a test holds it equal to the tool reference'snetworkflag. (#103)
Changed
- Docs. The Claude Code client page says what "Disabled for this project" means and how
to clear it; the quickstart's paste-in prompt tells the agent the same. (#104) - One endpoint.
/external/mcpis retired; the network tools are on/mcp. Adding
leftbrain was twomcp addcommands and, since OAuth, two sign-ins, for what every other
layer - one key, one quota, one dashboard, one scope grid, one/docs/tools- already treated
as one product.weather,fx_rate,geoandurl_checkare now published by the same
server as the fourteen core tools, and the stdioleftbraincommand carries them too.
LEFTBRAIN_SERVE_EXTERNAL=0(or--no-network) leaves the four out of a process; per-key
control over the same four is the scope grid, which marks them (#103). A request to
/external/mcpanswers410withmoved_to: "/mcp"and a message saying what happened,
with no key required, so a client still configured with it is told rather than shown a bare
404. The root document lists one endpoint and anetwork_toolsflag.leftbrain-external
remains for a process that should carry only the four. This flips the default from
opt-in to opt-out for network access; that was decided deliberately (#100). - Every parameter of
fx_rateandurl_checknow carries its description on the wire.
The pass that copies the reference's parameter docs onto the published schema only handled
tools with modes; the two mode-less tools were never on the described server before. (#100)
Fixed
- OAuth on
/external/mcp. The 401 challenge named/mcp's protected-resource document
whichever endpoint was asked for, and there was no document for the other mounts at all. A
client that checks the document'sresourceagainst the URL it is connecting to - Claude
Code does, as RFC 9728 requires - refused/external/mcpwith "does not match expected"
before any tool call. Every mounted endpoint now has its own document at
/.well-known/oauth-protected-resource<endpoint>, and the challenge and the 401 body's
how_to_authorizepoint at the one for the endpoint that was requested. A protected path
that is not an MCP endpoint (/keys/me) is pointed at the core document. Static-key auth
never read the challenge and is unchanged. (#101) - The 401 no longer sends an agent to a closed door. Its message said "get one at
POST /keys/signup" whenever a key store existed; that route answers 404 unless
LEFTBRAIN_OPEN_SIGNUPis on, which it is not on the hosted server. The clause now names
signup only when it is open and says "sign in at /login to create one" otherwise. (#104) - A headless agent is told to register before the device grant. The 401's
if_you_have_no_browsernamed/oauth/device_authorizationalone, and calling it without a
registered client answered a bareinvalid_client. The 401 now namesPOST /registerfirst,
and the device endpoint'sinvalid_clientcarries anerror_descriptionthat says the
same and points at the agent guide. (#104) - A key minted through the device grant reads
· device, not the approver's OS. The
device flow exists so the approval can happen on a different machine from the agent, so the
approving browser's operating system says nothing about where the agent runs - the same
reasoning that already names a cloud client· web. (#104) - The site links to its repository again, and "feedback is off" says where else to go.
feedback.py, the README and the report page all said the repository was private and that
the feedback endpoint was therefore the only place to report a wrong answer. It is public, and
on the hosted server the endpoint was not configured, so there was no route at all. The
footer now links Source and Issues; the report page links the tracker whether or not filing
is on;POST /feedbackwhen off answersunsupportedwith the tracker inmessageand a
trackerfield; and the reason the endpoint exists is stated correctly - an agent mid-call
holds a key, not a GitHub login. One source of truth:leftbrain.__repo__(and
Repository/Issuesinpyproject.toml), overridden byLEFTBRAIN_FEEDBACK_REPOwhen a
self-hoster files reports into their own fork. The Northflank guide names the two feedback
variables. (#102)
v0.4.1
Added
- Number-theory predicates in
math—is_even,is_odd,is_negative,is_positive,
is_integer,is_square,is_perfect,is_prime,is_coprime. They live in the
expression namespace so they compose, and they return booleans that coerce to 1 and 0, so
is_prime(11) + is_prime(12) + is_prime(13)is 2 — which is the only batchingmathhas,
and is now documented rather than left to be rediscovered. A predicate applied to a free
symbol names the symbol instead of answeringNone. (#57, #63) - Bounded ranges in
numbers.sequence—kind=primes start=50 end=80gives
53, 59, 61, 67, 71, 73, 79. Same forsquaresandfibonacci.endis what asks for a
range, so astarton its own is still ignored and still says so. (#57) - The physical constants in
math—gravitational_constant…fine_structure_constant,
each under a long name everywhere and its conventional short name (G,c,h,R) only
in the modes whose answer is a number.candGare the most common single-letter
variables in algebra, sosolve(["a + b = c"])still treatscas an unknown, and avars
entry always wins. Values come from pint's CODATA registry, and every use is named in
assumptionswith its value and unit. (#55) holidaysmodesfestival,upcomingandcompare.checkandnextare date-first;
these are name-first and window-first — "when is Durga Puja", "what is coming up in October",
"which dates does West Bengal observe that Assam does not". A multi-day festival comes back
as one thing with its days named, not as unrelated rows sharing a prefix, and common names
resolve to whatever the dataset calls the same festival (Durga Puja is filed asDussehra)
with the substitution stated. A name it does not carry is refused with the near misses,
because "not in this dataset" and "no such festival" are different claims. (#87, #88, #94)holidays.listcan return iCalendar or CSV —format="ics"puts the calendar in a
calendar, which is the thing people actually do with one. (#93)- Holiday names in other languages —
language="hi","bn","ta"and eight more for
India, discoverable through modecategories. (#76) - A festival can anchor date arithmetic —
{"festival": "Saptami", "year": 2026, "region": "IN", "subdiv": "WB"}works whereverdatetimetakes a date, so "three days before Saptami"
is one call rather than a lookup, some arithmetic and hoping both were right. (#92) - Somewhere to report a wrong answer.
POST /feedbackfor agents holding a key and a form
at/reportfor signed-in people, both filing an issue on the tracker nobody outside can
see. Off unlessLEFTBRAIN_FEEDBACK_TOKENandLEFTBRAIN_FEEDBACK_REPOare set. Anything
key-shaped is blanked before filing, and the reporter is recorded — a key prefix or a GitHub
login — never their email. (#53) - Every published parameter now says what it is for, and
modelists what each tool can
do. 0.4.0 gave them types; this gives them meanings, from the same text the documentation
site already carries. (#64)
Fixed
17 % 5returned 0.85.%was read as a percentage wherever it appeared, so it became
(17/100) 5— wrong by a factor of 20, with% read as /100as the only tell. Both are
legitimate readings of%, so it now asks which was meant and takes
percent="modulus"|"percent"as the answer, the wayanglealready works.modis the
spelling with one meaning and never asks. This also uncovered a precedence bug inmod
itself:2^10 mod 7matched10as its left operand and answered 2^(10 mod 7) = 8
instead of 1024 mod 7 = 2. (#97)- A SIP with no opening balance was refused. "growth and SIPs" is advertised at tool level,
butprincipalwas required — so "5,000 a month for ten years", which is what a SIP
normally is, came back as'principal' is required. The arithmetic was right all along.
(#98) - A year the holiday source does not reach answered with an empty list. India's tables run
1948–2100; asking for 2200 returned nothing, which reads exactly like "no holidays that
year". It is refused now, naming the window. Every date also carries its provenance — source,
calendar, covered years, language and categories. (#90) holidays.checkcould not say that a date was a festival.is_holidayandis_weekend
had to carry every question. It now answers withis_observed,day_offand
observances[], each classified in one vocabulary that means the same thing in every
country.is_holidaykeeps its old meaning. (#89, #95)- A rejected
mathfunction named only what failed.primepinow comes back with the near
misses and the accepted set,factorintreturns real factors instead of a stringified dict,
and the description no longer claims "ANY arithmetic" against a partial allowlist. (#63)
Changed
toolrefmoved out ofleftbrain.webtoleftbrain.toolref. It only ever needed
contractandcore, andleftbrain.web.__init__imports starlette — aserverextra an
MCP-only install does not have. This is internal; nothing about the published tools changed.
v0.4.0
Two batches of reported bugs, most of them the same shape: a parameter accepted and then
ignored, or a filter applied and never mentioned. Several answers that looked exact were
wrong, and this release makes each of them either right or refused.
If you read fields rather than prose, four response shapes moved:
geo_offline—coordinatesis nowzone_reference, because that is what it always held.
tz_for_coordsgained acoordinatesechoing the point asked about, and a
nearest_reference.holidays.countries— 250 entries of{code, name, aliases}instead of ~500 bare strings
mixing ISO-2, ISO-3 and the dataset's own abbreviations.holidays.locale— renameddate_locale, which is what it does.localestill works.numbers.allocate—exact_unroundedis a fraction (1000/7) where the decimal repeats.
And three behaviours changed on purpose:
- The daily quota now counts tool calls, not HTTP requests. A handshake and a refused call
cost nothing; atools/callthat did work costs exactly one. collections.sort_byhonoursorderwhen the key is given askeys. Anything relying on
that silently sorting ascending will now sort descending, correctly.mathrefuses trigonometry without anangleeven where the expression folds to a
constant, anddatetime.recurrencerefuses "every 2nd tuesday" rather than picking one of
its two readings.
Added
- OAuth 2.1 for
/mcp, so ChatGPT plugins and Claude's web connectors can connect at all —
neither will send a static key, and ChatGPT's dialog has no field for one. Discovery
(RFC 8414, RFC 9728), Client ID Metadata Documents, dynamic client registration (RFC 7591),
PKCES256, and refresh-token rotation. NeedsLEFTBRAIN_SECRETandLEFTBRAIN_BASE_URL;
without both, nothing is mounted.lblz_keys are unaffected — same responses, same quota
headers, sametools/listfiltering. - A consent screen at
/oauth/consent, which is not decoration: leftbrain uses a static
GitHub client id against a third party that sets a consent cookie, and now allows dynamic
registration — every precondition of the confused-deputy attack the MCP guidance describes.
Consent is recorded per client and checked first, and no signed state is set until a human has
approved. - The device grant (RFC 8628) at
/oauth/device_authorizationand/device, for an agent on
a machine with no browser: it shows its user a short code instead of asking them to paste a key
into the conversation. No shipping MCP client drives this yet, so it is for agents calling the
endpoints themselves; it will not make Claude Code connect. - A connector's key is an ordinary key. Approving creates one named after the app and where it
runs —Claude Code · Windows,ChatGPT · web— visible on the dashboard, revealable,
re-scopable and revocable, counting against the same cap. Reconnecting the same app reuses its
key rather than consuming a second slot. - Per-tool call counts in the scope editor, so narrowing a key is a decision rather than a
guess: a tool sitting at zero calls is one to untick. POST /keys/me/scope, letting a caller propose narrowing its own key. It returns202and
a URL for the owner to approve; nothing changes until they do. Widening is refused outright./docs/agents/auth, an authentication guide written for a model rather than a person, and
linked from the 401 body and from RFC 9728'sresource_documentation.
Added
holidaysmodecategories, mirroringsubdivisions: the category values a country
actually accepts. There is no single enum to publish —optionalis valid for India and
rejected outright for the United States — so the legal set has to be asked for per country
rather than guessed at. (#75)
Fixed
- Degrees were discarded the moment a trig argument contained
pi.sin(pi)with
angle="deg"returned0— the radian answer, as an exact integer, with nothing in
assumptionsto say the parameter had been dropped. SymPy foldssin(pi)to0while the
expression is still being parsed, and the degree conversion ran afterwards, on a tree with no
sinleft in it to convert; a plain numeric argument survives parsing, which is whysin(30)
was right the whole time. Degrees are now applied as the expression is built, so the fold
happens on the already-converted argument:sin(pi)issin(π°) ≈ 0.0548.cos(pi),
sin(pi/2),tan(pi/4),cos(2·pi)andasin(1)were all wrong the same way. (#66) - The mandatory
anglerefusal was skipped for exactly those expressions.mathrefuses
trigonometry without ananglerather than guessing between two readings that differ by a
factor of 57 — but the check looked for trig in the parsed tree, sosin(pi)with noangle
at all answered0instead of asking. Trigonometry is now detected in the source, which the
parse cannot erase. (#69) text.countignored asubstringit was given and answered a different question. "how
many r in blueberry" reaches the tool ascount(text="blueberry", substring="r");substring
is in that mode's own accepted-parameter list, was supplied, and was dropped without a word —
the reply was a dozen unrelated summary counts, and whichever one a model then picked was the
wrong answer. A needle with nowhatnow counts that needle and says so; a needle given
alongside awhatthat cannot read it is reported rather than dropped. Thewhatrefusal also
omittedsubstring— the spelling that actually worked — from the values it listed. (#65)numbers.sequenceignoredstartin silence.end,stepandratiowere already
reported when the kind did not read them;startwas left off that list, so asking for the
primes from 50 returned the primes from 2 with nothing said. (#56)- The daily quota billed HTTP requests, not tool calls — twice per call, and for calls it
refused. Metering ran in the auth middleware, before the tool and once per request, so a
hosted connector that re-initializes before eachtools/callspent two units on every one, a
connect spent four before any work, and a call refused for bad input had already been charged.
Quota and rate limiting are now separate concerns: the per-minute limit still sees every
request, because it is abuse protection, while a unit of the daily quota is spent once, after
the tool has run, and only when it did work.X-RateLimit-Remaining-Todayand
meta.quota.remaining_todayare the same number by construction, and both now count the call
they ride on: an MCPPOSTholds its response start until there is a body to send with it,
because a streamed reply would otherwise write its headers before the tool had run. (#62) simplifyremoved a point from the domain without saying so.(x^2-1)/(x-1)came back as
x + 1with an emptyassumptionslist — but the input is undefined atx = 1andx + 1is
2 there, so the two are not the same function. SymPy is behaving as documented; the silence was
ours. Any answer that cancelled a factor now carriesrestrictions(["x != 1"]) and says in
prose which points it gained. It covers every mode that can cancel, not justsimplify—
evalandfactordo it too — and the denominators are read off an unevaluated parse,
becausex*(x-2)/(x-2)is alreadyxby the time an evaluated tree exists.expand, which
keeps the denominator, says nothing. (#68)math.convert_formrefusedvalue, which the tool's own signature advertises. The mode
re-renders a quantity, sovalueis the word a caller reaches for, and the flat schema lists it
becausestatstakes one; it is an alias forexprhere, and giving both different values is
refused rather than silently picking one. (#67)- Every string argument was uncallable on five parameters, and latent on 27 more. A
parameter annotatedAnyrenders asanyOf: [{}, {"type": "null"}]— an empty schema with no
type in it. An MCP client serialises its arguments against that schema, so with nothing to
serialise against it emitted{"text": abc}unquoted: the call failed inside the client,
never reached the server, and none of the contract's machinery applied — no envelope, no
needs, and a retry produced the same malformed call. It madeencode's headline purpose
unreachable for any text containing a letter, and digit-only values happening to work is what
made it look intermittent. All 32 now carry concrete unions; a test sweeps for the shape so it
cannot come back, and another pins that"0.1"still arrives as the exact decimal string
rather than a float. (#71) collections.sort_byignoredorderwhenever the key was given askeys— which is the
only way to sort on more than one field. "The top 3 departments by spend" took the first three
rows of a silently ascending sort and returned the three lowest, in a well-formed response
with nothing to contradict it. Anyone spot-checking with the singularkeywould have
concludedorderwas fine. Mixed directions are now expressible too, as{field, order}or a
-fieldprefix, and an order that cannot be applied is refused rather than dropped. (#84)holidaysapplied acategories: ["public"]filter and never mentioned it.check
returnedis_holiday: falsefor 2026-10-18 in West Bengal — the middle of Durga Puja — so an
agent would tell its user that October 18 is not a holiday there. Omittingsubdivalready
produced an assumption; this was the one narrowing that stayed hidden. The default is now
named on every call that used it, andcheckreports a date the dataset knows under another
category as a near miss rather than a barefalse. (#72, and the visible half of #80)geo_offlinereported the zone's reference city ascoordinates, whatever place was
asked about: `...
v0.3.1
Added
- Docs: connecting Claude on the web, and a ChatGPT section. Which clients can carry a bearer
key and which cannot, and why: a config file can set a header, a browser connector dialog
cannot. ChatGPT gets its own section covering the Plugins rename, the developer-mode
prerequisite and where the toggle lives per plan, and why no configuration works directly —
OpenAI's documentation states ChatGPT cannot present custom API keys, and expects OAuth 2.1
against the MCP authorization spec. Both blocked clients get something to do rather than a
shrug: the desktop app for Claude, a header-injecting proxy for ChatGPT, with a Cloudflare
Worker to copy and the trade stated — the proxy's URL becomes the credential.
Changed
- The per-key tool scope is a tree. It was a three-column grid of blocks whose columns ran
to different heights, so a tool's modes wrapped into a shape unrelated to the tool above it.
Each tool is now one full-width row with a disclosure triangle, apicked/totalcount, and
its modes as collapsible children under a guide line. A tool with some but not all of its
modes shows an indeterminate checkbox; unticking the last mode turns the tool off and
ticking one turns it back on. The header offers All, None and Expand all. Counts
are rendered by the server, so the scope is readable with JavaScript off.
Fixed
- The loading skeleton now covers every page.
site.jslooked for.doc, then<main>,
thendocument.body, so only the docs pages showed it correctly: the dashboard got the
markup with no CSS to position or dim it, and the landing, login and error pages prepended
skeleton lines above the nav. Every template now renders inside one#pagewrapper, which
the script targets and the stylesheet positions and dims. The docs pages still dim only the
article, so the sidebar stays readable. - The site footer linked to the repository, which is private. It links to the tool reference
instead.
v0.3.0
Added
-
/docs/toolsanswers a machine as well as a person./docs/toolsand
/docs/tools/<name>now serve JSON to a client and HTML to a browser — the same content
negotiation/already used, so there is one URL per thing rather than two. The index lists
all eighteen tools (the four network ones included) with their modes and the contract's error
codes; a tool gives every mode's purpose, description, parameters with type, default and
required flag, and the example arguments. No key: discovery has to work before you have one.
tools/listover/mcpremains the authenticated, scope-aware route./advertises the new
endpoint astools. -
Every response says what it cost (#28 §6): a
metablock withtool,mode,
latency_ms(wall time in the server),compute_ms(time in the engine),version,
truncated— lifted out of the result so a caller reading a list knows it is not the whole
list — plusrequest_idandquotawhen the server has them.X-Request-Idand
X-Leftbrain-Latency-Mscarry the first two as headers; a caller-suppliedX-Request-Idis
kept so one id spans both sides of a trace.metanever affectsok, andcompute_msis
the regression alarm for the compute ceiling: a response whosecompute_msexceeds its own
deadline is a timeout that did not fire. -
A 15-second ceiling enforced by killing the work (#28 §1 step 3).
math,text,
validate,collectionsandnumberscalls now run in a worker process that can be
terminated, because a thread cannot be:int.__pow__,sreanddifflibare C loops that
never reach a bytecode boundary, so no async exception, signal or thread kill is ever
delivered — and the runaway holds the GIL, so the event loop and/healthzstarve with it.
That is how onemath.eval 9^9^9^9took the hosted instance offline for 35 minutes. A call
that reaches the deadline returnstimeoutwithstopped: "worker_terminated", the limit
and the real elapsed time; a caller-suppliedtimeoutis clamped to the ceiling; a burst
that cannot get a worker returns the retryablebusyrather than queueing behind a
15-second wait. Workers carryRLIMIT_CPUandRLIMIT_ASas a kernel backstop, use
forkserveron POSIX and are recycled every 200 calls to bound SymPy's caches. Configured
withLEFTBRAIN_COMPUTE_TIMEOUTand friends (README). Needspebble, added to theserver
extra; without it the server logs that isolation is off and runs in-process, and the library
always runs in-process. -
retryableon every failure, and four codes that say what was hit (#28 §1, §4): the
envelope's failure half is now{ok: false, error, message, details?, retryable, hint?}.
retryableisfalsefor everything exceptbusyandinternal— a client that reads only
ok: falseretries, and an identical retry of a call that hit a limit multiplies the load that
caused it. New codes alongsideinvalid_input/ambiguous/unsupported/timeout/
forbidden/internal:too_large(a pre-check refused it before any work started),
resource_exhausted(a memory or CPU limit rather than the clock) andbusy(saturated;
nothing was computed).contract.CODESis the one list, andTooLarge,ResourceExhausted
andBusyare raisable from a tool. Onlybusyis retryable by default:internalis the
catch-all for an exception no mode anticipated, and most of those are a deterministic
consequence of the input, so a site that knows its own failure was transient passes
retryable=Trueat the raise instead. -
Layer-0 size caps: the enormous is refused before it is computed (#28 §2e/§2f/§2g). Every
one of these reached the engine before;math.eval 9^9^9^9took the hosted instance down for
~35 minutes. Each now costs microseconds and comes back astoo_largenaming the limit and the
knob to turn:math: the expression is walked as an AST and the answer's size estimated in log10 space
before anything is evaluated — SymPy computesInteger ** Integerwhile parsing, so
9^9^9^9,2^(2^100),2^1000000,factorial(factorial(20)),gamma(10**10)and
exp(10^20)never start. The bound issys.get_int_max_str_digits()(4 300 by default),
which is the point beyond which CPython cannot render the integer at all.precisionis
capped at 5 000 digits andseriesorderat 50 terms.numbers:sequencecaps the largest term at 1 000 digits (geometricratio 2, n 10 000
ended at 2^10000 and returned 15 MB);allocatecapspartsat 10 000 (1 000 000 returned
116 MB).text:regex_replacerefuses an output over 200 000 characters;diffcaps each side at
10 000 lines, words or characters (difflib is quadratic — 100 000 lines never returned).collections:pivotrefuses more than 200 distinct pivot columns.datetime:business_dayscaps the range at 3 660 days;nowandconvert_tzcap the zone
list at 50.holidays:nextcapsnat 100.- A global 256 KB ceiling on any successful response backs all of them up, configurable with
LEFTBRAIN_MAX_RESPONSE_BYTES. A mode with a knob should refuse earlier and say which knob;
this only catches what slipped past one.
Fixed
-
Extreme magnitudes are rendered rather than lost or refused. Four behaviours for one
class of input:numbers.parse "1e400"wasinvalid_input(Decimalholds it exactly —
the parser's suffix regex simply never let it try); the same value as a JSON number
arrived asinfand came back as a successful"Infinity"with nothing said;
convert "1e-400"leakedOverflowError: int too large to convert to float; and
finance.compound principal=1e300wasinternalplus a bareInvalidOperation.
Now: scientific notation parses however large, a value that arrived as infinity says its
magnitude was already lost before the tool saw it, andconvertcomputes exactly at any
size — onlyvalue, which is a JSON number, saturates toInfinity/0, with the exact
answer in the newvalue_exactand the loss named inwarnings. An ordinary conversion
gains neither field. An amount too big to be money istoo_large, not a crash. -
The small list (#28 §3.13): sixteen findings that were each wrong or silent rather than
dangerous.random.samplewith more groups than items left some empty and said nothing;
random.float decimals=100ignored the parameter (a binary float has ~17 significant digits,
so it is now 0..15 and anything larger is refused);encode.base64 urlsafe=truedecoded
standard-alphabet text anyway;encode.urlleft a malformed%zzescape untouched with no
note;validate.urlaccepted embedded credentials and punycode/Cyrillic look-alike hosts
silently, and now warns about both;validate.iprejected the scoped IPv6 form
fe80::1%eth0(RFC 4007), which is now valid with thezonereported;
validate.sql_parsecounted a trailing comment as a third statement;
numbers.parserejected the Unicode minus−5, which is what copy-paste from a document
produces;collections.find_duplicateswithcase_insensitivedid not also trim, so
"a@x.com "and"A@x.com"were different;collections.aggregatehad nomedian
althoughmath.statsdid;datetime.recurrencerefusedevery other tuesday, the short
form people actually write;holidays.listfor a year the calendar has no data for looked
identical to a year with no holidays, and years past 2075 are now marked as estimated;
text.sortsortedéclairafterZebrawithout saying it does no locale collation; and
text.countnow reportsgraphemes— what a reader sees, so a ZWJ family emoji is one
character rather than five — and warns when text carries bidi controls or zero-width
characters, which are invisible, count towards length, and are how a filename is made to
read backwards. -
Unknown parameters are refused instead of silently dropped (#28 §2a). Every tool kept the
keys it recognised and discarded the rest, sodatetime.age dob=… ref_date=…returned the age
as of today (the parameter ison) andmath.plot_points start=… end=…plotted the default
range — an answer computed from defaults after the caller's arguments were thrown away, with
nothing in the response to tell it from a right one. Each of the fourteen core tools now
declaresMODE_PARAMS, what every one of its modes reads, and a call carrying anything else is
invalid_inputnaming the mode, listing what it accepts and suggesting the closest match.
tests/test_mode_params.pyderives the same map from the source and fails if the two drift.
Retired names (from/to) now say what replaced them instead of being ignored.overlapalso
honours a per-intervaltz, which it used to drop — two windows 12½ hours apart were compared
as wall clocks and reported as overlapping by 8 hours. -
Mutually exclusive parameters say which one won (#28 §2b):
numbers.round significant=2 decimals=5returned 120 with no word aboutdecimals, andfinance.emi months=12 years=5
gave a one-year loan to a caller who described a five-year one. Both now record the choice in
assumptions. -
Eleven confidently wrong answers (#28 §3.3–3.13). None of these failed; each returned
ok: trueand a value a caller would act on.encode.json parsereported a valid document
as invalid when it arrived through MCP already decoded (textis typedAny, so
{"a": 1}became Python's{'a': 1}repr);encode.json stringifyemitted literal
InfinityandNaN, which no strict JSON parser accepts, and now refuses;
datetime.parseturned01/02/50into 2050 with no note — there is now a stated pivot
(49 and below is 20xx) recorded inassumptions, because for a date of birth a silent
century is a hundred years wrong;datetime.recurrencewith `FREQ=MINUTELY...
v0.2.0
Two new core tools — fourteen in all — and new modes on seven of the existing ones, plus a
dashboard that lets you delete old keys and a hosted free tier of 1,000 calls per key per day.
Everything is additive: every call that worked on 0.1.0 returns the same shape on 0.2.0.
Added
New tools
financetool (13th core tool):emi(instalment, totals and amortisation schedule that
reconciles to zero),compound(future value with optional per-period contributions and the
effective annual rate),cagr,npv_irr(IRR by bisection),gst(inclusive/exclusive split
into CGST/SGST or IGST, rounding difference reported) andpercent(change vs percentage
points, stacked vs additive discounts, exact bill splits). The rate's period and whether an
amount is GST-inclusive are required, not guessed.colortool (14th core tool):convertbetween hex (3/4/6/8-digit), RGB, HSL, HSV/HSB,
naive CMYK and Lab with alpha preserved;describenames the nearest of the 148 CSS colours
by CIE76 ΔE and words the colour from fixed HSL bands;swatchreturns a real PNG (stdlib
zlib, 16–256 px, one colour or two side by side);contrastgives the WCAG 2.x ratio,
AA/AAA for normal and large text and the smallest lightness change that passes;mix
blends in sRGB or Lab;harmonyrotates hue for complementary, analogous, triadic and
split-complementary sets;nearestsnaps to a caller's palette with the runner-up;
simulateshows deuteranopia, protanopia and tritanopia (Viénot–Brettel–Mollon 1999);
grayscalegreys by rec709, rec601, lab, average or hsl with an optional ramp and strip.
Bare triples such as58, 26, 241are refused withneeds.options(rgb, hsl, hsv).
New modes and parameters
datetimenowtakes a list astz— zone names or{tz, label}objects — and answers with
the shared instant asutcplus one full per-zone entry underzones, each carrying its
labelback.convert_tzaccepts the same{tz, label}entries into_tz. One round-trip
for "what time is it in each of our offices right now"; the single-tzform is unchanged.datetimefree_slots: common free slots for two or more participants in different time
zones, from weekly windows (09:00–17:00onmon…fri) or one-off local ranges, intersected
in UTC throughzoneinfo. Each slot is shown in every participant's local time and in UTC,
per_daytotals the overlap per date, a window spanning a DST change is expanded to its real
length with a note inassumptions, and no common time isok: truewithslots: []and a
warning naming who never overlaps.convertfuel_economy:mpg_us/mpg_uk/km_per_l/l_per_100kmwith exact
constants; a barempgisambiguous(US or imperial gallon), and any conversion that crosses
L/100 km states the inverse relation inassumptions.convertcooking: cups, tbsp, tsp, ml, fl oz ↔ g, kg, oz, lb. Mass ↔ volume needs
ingredient, looked up in a built-in density table (18 staples; the grams-per-cup used are
stated); a missing or unknown ingredient returns the table asneeds.options.cupselects the
US (240 ml, default and declared), metric, UK or Australian (20 ml tablespoon) system.convertsizes:category=shoeconverts US men / US women / UK / EU / cm on a generic
adult chart, snapping to the nearest half size with a warning;category=clothingmaps XS–XXL
to chest and waist cm bands for a chart chosen byregion(usinch-based,euEN 13402-3)
andgender, both required. Every result warns that sizes are approximate and names the chart.numberssemver: compare or sort version strings as versions (1.10>1.9), with
SemVer 2.0 pre-release precedence, build metadata carried but ignored for ordering, and a
leadingv/ missing minor or patch tolerated and recorded inassumptions.textsimilarity: Levenshtein distance and a 0–1 ratio for a pair, ortext+itemsto
rank candidates and return the best match with its index — mapping typed input onto a menu,
spotting near-duplicate names. Case-folded and whitespace-normalised by default, both stated.collections: every record input (items,a/b,data) also accepts CSV text — the
delimiter is sniffed, the header row detected (first row with no numeric, date or boolean cell;
has_headeroverrides), each field typed as number (exactDecimal, vianumbers.parse),
ISO date, boolean or text, and blank rows andN/A/-/nullcells skipped and counted, all
stated inassumptions; above 5,000 rows the call is refused. New table modes over the same
input:filter(wherepredicates with thevalidate.assertvocabulary, compared in the
field's type),pivot(by×pivot_columns, one aggregate ofcolumn, row and grand
totals, empty cellsnull),running(cumulative total, restarting perby),outliers
(Tukey hinges, 1.5×IQR, fences reported, at least 4 values),summarize(count/nulls/sum/avg/
min/max/median for every numeric field, distinct counts for text, range for dates) andto_csv
(delimiter, chosencolumns). A lone numeric field is assumed forrunning/outliersand
said so; several areambiguous.decimalsrounds computed values only; row-shaped results
echo at most 500 rows with a warning, exceptto_csv.validatecidr: membership (network+value→contains, for an address or a smaller
block) and overlap (a list of networks → every pair withequal/a_contains_b/
b_contains_a/disjoint), plus a block's size, usable hosts, bounds and masks. Host bits
set are read as the network and recorded inassumptions.validateidwithkind=isbnreturns bothisbn10andisbn13for a valid number (a 979
book has no ISBN-10 form).encodehashandchecksumtakeexpectedand answermatches— compared in constant
time against the hex, Base64 (or, for checksums, decimal) form, case- and whitespace-
insensitive, with asha256sum-style<digest> <file>line accepted as-is. A mismatch is
ok: true, matches: false: an answer, not an error.hmacshares the same comparison.filesfile_hash: sha256 (default) / sha1 / md5 / blake2b / crc32 and the other hashlib
algorithms, streamed in 1 MiB chunks so a file's size is bounded only byLEFTBRAIN_FILE_ROOTS,
withbytes, the algorithm used, andexpected→matchesfor verifying a download.
Keys and dashboard
leftbrain-keys set --all --daily N [--rpm N] [--from-daily N]changes limits on every key —
or, with--from-daily, only on keys still at an old default — so a moved default can be
migrated without a database shell or touching keys set by hand. Prints how many keys changed.- Dashboard Delete for a revoked or expired key: removes the row and its usage history for
good (with a confirm). A key that still works must be revoked first; deletion is owner- and
CSRF-checked like the other key routes.
Fixed
- Dashboard: keys issued before the server could show keys again (including the old
POST /keys/signuprows, still namedself-serve signup) are marked legacy and explained
on the row instead of the bare "created before reveal was enabled". They no longer hold one of
the owner's active-key slots, so a first sign-in that inherits three of them can still create a
key./keys/meoutput is unchanged. - Demo endpoint: the 8 KB body cap now applies to chunked requests too, and deeply nested arguments return a contract
invalid_inputinstead of a bare 500. - Docker image build failed after the changelog was bundled into the wheel (
CHANGELOG.mdwas not copied into the image).
Changed
- Free tier is 1,000 calls per key per day (was 5,000);
LEFTBRAIN_DEFAULT_DAILY_QUOTAstill overrides it. Every place the site quotes the limit — landing, sign-in, quickstart, the demo's 429 — now reads the configured value instead of a hard-coded number. - Site shows loading skeletons on navigation, form submits and demo runs; the colour legend on docs pages was dropped.
- Docs key picker shows the key name only (prefix only for unnamed keys).
- Docs examples are labelled Request/Response and colour-coded: blue blocks are what you
send, green blocks are what comes back, and a neutral block marks a setup command.
v0.1.0
First public release, and a pre-release — the package ships as
Development Status :: 3 - Alpha. Tool names, parameters and the response envelope may
still change before 1.0, so pin an exact version if you build on it.
Added
- 12 core tools, offline and free of API keys:
math,datetime,scale,convert,
holidays,numbers,text,collections,validate,random,geo_offline,
encode. Each takes amode, so a client pays for twelve tool descriptions rather than
a hundred. - 4 external tools over key-less public APIs:
weather(Open-Meteo),fx_rate(ECB
reference rates via Frankfurter),geo(geocode, reverse, driving route),url_check. - Optional file tools for agents that cannot open files themselves:
pdf_text,
pdf_info,image_info,image_to_base64,base64_to_file,file_info,read_text,
list_dir, restricted to the paths inLEFTBRAIN_FILE_ROOTS. - One response contract for every tool:
{"ok": true, "result": …, "assumptions": [], "warnings": []}. Failures return
ok: falsewith anerrorcode and a message; an ambiguous input returnsneedswith
the concrete options instead of guessing (IST,03/04/2025,ton,KB).resultis
never null, and exact and decimal forms are returned together so nothing is re-rounded. - MCP servers over stdio:
leftbrain,leftbrain-external,leftbrain-files. - MCP over Streamable HTTP:
leftbrain-serveputs every tool set in one stateless
process —/mcp,/external/mcp, optionally/files/mcp, plusGET /healthzand a
JSON service description at/. Dockerfile and compose file included. - Use from Python without MCP:
import leftbrain as lbgiveslb.math_tool(...)and
friends, andlb.TOOLSmaps every tool name to its function. - Hosted service at https://leftbrain.idlesync.in:
- GitHub sign-in; keys belong to the account's verified primary email.
- API keys prefixed
lblz_, up to 3 per account, 5,000 requests/day and 60/minute, with
quota headers on every response and429plusRetry-Afterwhen a limit is reached. - Dashboard: create a named key (shown once), reveal an existing one again, revoke, and
see today's usage. - Docs with Windows PowerShell / macOS / Linux tabs — quickstart, MCP client setup, a
custom-agents guide, and a copy-paste prompt that configures any coding agent. A
signed-in reader's own key is filled into every example on the page. - A per-tool reference generated from the MCP servers themselves: parameters come from
the published schemas and every example is executed while the page is built, so the
docs cannot drift from the code. - A key-less live demo on the landing page (
numbers,convert,datetime,text).
- Per-user key store on SQLite or Postgres, resolved from
LEFTBRAIN_KEYS_URL,
DATABASE_URLorLEFTBRAIN_KEYS_DB, with aleftbrain-keysadmin CLI (create, list,
enable, disable, revoke, set limits, usage, stats). - Key expiry. Keys are created with a lifetime — 30, 90 (default) or 365 days, or never
(the dashboard and CLI warn that a key which never expires is a liability if leaked).
An expired key is refused with403 {"error": "expired", "message": "key expired on <date>; create a new one at /dashboard"},
drops out of the 3-active cap, and can no longer be shown again. The dashboard shows
when each key expires and flags one within 7 days; the docs key picker skips expired
keys and reminds you when the selected one is about to expire.GET /keys/mereports
expires_atandexpired;leftbrain-keys create --expires 90d|neverand
set <prefix> --expires …manage it from the CLI. Existing keys are migrated with no
expiry. - Deployment guide for a free Northflank sandbox in
docs/deploy-northflank.md.
Changed
- Range parameters use the name their domain already uses rather than
from/to, which
collided with the Python keyword and forced afrom_spelling on the wire:
datetime diff,business_daysandcron_nexttakestart/end;math integrate
takeslower/upper;math limittakespoint;math convert_formtakesform;
geo_offline distanceand externalgeo routetakeorigin/destination. Thefrom,
from_and baretospellings are gone.from_unit/to_unit,from_qty/to_qtyand
from_tz/to_tzare unchanged. - The MCP wrappers accept every parameter their core tool supports, so a tool is not less
capable over MCP than it is from Python.
Fixed
- The GitHub OAuth callback handles malformed or unexpected responses instead of failing
with a server error. - Docs
:::ostab blocks are parsed fence-aware, so a:::inside a code sample no longer
ends the block early. - Static assets are stamped with a content hash, so a stylesheet or script change reaches
browsers without waiting for a version bump.
Security
- Security headers on every response:
X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin,X-Frame-Options: DENYand a
Content-Security-Policy withframe-ancestors 'none'. X-Forwarded-Foris counted from the right usingLEFTBRAIN_TRUSTED_PROXY_HOPS
(default 1), andX-Real-IP/CF-Connecting-IPare ignored — a caller cannot forge the
address a rate limit is keyed on.- The demo endpoint runs behind an allow-list of tools, modes and argument names, an 8 KiB
body cap and per-argument size caps, and never returns a traceback. - Keys are authenticated by comparing a SHA-256 hash. The revealable copy is
Fernet-encrypted under a key derived fromLEFTBRAIN_SECRET; leave that unset and only
the hash is stored. - Anonymous signup is closed unless
LEFTBRAIN_OPEN_SIGNUP=1. Sessions use a signed
cookie, every mutation is CSRF-checked, and any page carrying a key is sent
Cache-Control: no-store.