Changelog
All notable changes to leftbrain are recorded here. The format follows Keep a Changelog and the project follows Semantic Versioning.
[0.5.0] - 2026-08-31
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, usedatetimebusiness_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)
[0.4.2] - 2026-08-31
Added
- The scope grid says which tools reach the internet.
weather,fx_rate,geoandurl_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-externalremains 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'show_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 atrackerfield; 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__(andRepository/Issuesinpyproject.toml), overridden byLEFTBRAIN_FEEDBACK_REPOwhen a self-hoster files reports into their own fork. The Northflank guide names the two feedback variables. (#102)
[0.4.1] - 2026-08-30
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, sois_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 avarsentry always wins. Values come from pint's CODATA registry, and every use is named inassumptionswith 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 takespercent="modulus"|"percent"as the answer, the wayanglealready works.modis the spelling with one meaning and never asks. This also uncovered a precedence bug inmoditself: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,
but
principalwas 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_weekendhad to carry every question. It now answers withis_observed,day_offandobservances[], 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 neededcontractandcore, andleftbrain.web.__init__imports starlette — aserverextra an MCP-only install does not have. This is internal; nothing about the published tools changed.
[0.4.0] - 2026-08-30
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 anearest_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; a
tools/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)withangle="deg"returned0— the radian answer, as an exact integer, with nothing inassumptionsto 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 nosinleft 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 noangleat 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");substringis 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-Todayandmeta.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 asx + 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 annotated
Anyrenders 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, noneeds, 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.checkreturnedis_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:tz_for_place("Mumbai")and("Chennai")both returned Kolkata's, 1,650 and 1,300 km out.tz_for_coordswas visibly self-contradictory — those coordinates besidedistance_to_reference_km: 0.5. The field is nowzone_reference, a coordinate lookup echoes the point it was asked about, andnearest_referenceshows which reference actually chose the zone. (#73)datetime.convert_tzreported a wall clock that never existed. Given a time inside a DST spring-forward gap it warned correctly and named the reading it used — then printed the other one insource, withis_dst: false. The instant was right throughout; only the machine-readable fields disagreed with the prose beside them, which is the wrong way round. (#85)datetime.recurrenceread "every 2nd tuesday" as monthly, silently. "Every other tuesday" means the same thing to most speakers and produced a fortnightly schedule;assumptionswas empty either way, so an agent scheduling a fortnightly standup got a monthly meeting series. It is refused now, the way03/04/2025already is, offering both readings as rules the tool accepts. (#81)datetime.free_slotsrefuseddays: "mon-fri"— the natural way to write a working week, and the case the feature exists for, with no format documented to guess from. Ranges, comma and space lists, andweekdays/weekendsgroups are accepted now. (#82)numbers.allocatespent 8 KB of context on a 7-way split, emitting ~1,100 digits of142.857142…per share whiletruncatedreported false, because nothing had been cut. The exact value is1000/7. 9,516 bytes down to 1,151. (#74)- A defaulted
modewas invisible, and its refusal spoke in the wrong vocabulary. A call naming no mode gets the schema's default;meta.modewas absent, and avalidatecall the caller believed saidmode: "email"was told it neededrules, which belongs toassert. The mode that ran is now always reported, and a refusal about that mode's own parameters says it was defaulted — but only such refusals, since aforbiddennever ran the mode at all. (#79) holidays.localenever localised anything. It is the date-parsing locale, which is whyhiwas refused and a country code demanded. It isdate_localenow;localestill works and says what it actually does. (#76)- An alias could resolve to a different country in silence.
region: "BAH"returns Bahrain — the upstream package's own abbreviation, not the IOC's, which gives BAH to the Bahamas — and the response echoedregion: "BH", which reads as normalisation rather than as a different country. Every alias now says what it resolved to, the two known collisions name the other reading and the code that reaches it, andcountriesreturns 250 named entries instead of ~500 undifferentiated strings mixing three code systems. (#83) collectionscalled the same thing by different names in different modes — records wereitemsordata, the grouping keykeyorby— so learning one mode did not transfer to the next, andgroup_bycost three round-trips to get right. Both names are accepted wherever records are taken. (#78)- A zone listing an unexpected country said nothing about why.
Asia/Tokyoincludes Australia, which is correct and upstream — Eyre Bird Observatory keeps UTC+9 — but the comment explaining it was being overwritten with an empty string, so correct data read as a bad reverse lookup. (#77) - A throttled key answered
500, not429. Both rate-limit paths passed the key record intoVerdict'smessagefield positionally, and it is not serialisable — so every caller who hit their rate limit or exhausted their daily quota got a server error with noRetry-Afterand no explanation.Verdict's optional fields are keyword-only now, so the shape cannot recur. - A key scoped to tools this build does not ship said the wrong thing. It correctly allowed
nothing and listed nothing, but refused calls with
allowed: files— naming a tool nobody on that server can call. It now says the tools are not provided and points at the dashboard, and the key carries a warning on its row.
Changed
-
The active-key cap rises from 3 to 5 (
LEFTBRAIN_MAX_KEYS_PER_EMAIL), now that connecting an app consumes a slot. Three was sized for keys pasted into config files. -
numbers.compare'spercent_change_a_to_bdivides by|a|, matchingfinance.percent: from -100 to -50 is +50%, not -50%. It is omitted, with an assumption saying why, whenais zero. -
color.contrast'sratiois rounded down, andratio_exactcarries the unrounded figure. 2.99979 was displayed as3.0beside "does not meet 3:1". -
Each mode of
convertandholidaysdeclares only the parameters it reads.decimalsonconvert.units,deltaonconvert.currency,nonholidays.checkanddateonholidays.listwere accepted and ignored; they are refused now.convert.temperatureconverts temperatures rather than whatever units it is handed. -
math.solvefilters solutions bydomain.x^40 = 2over the reals returned 26 solutions, 24 of them complex. An identity reportsidentity: trueand an inconsistent system reports no solutions, bothok. -
numbers.allocateis capped at 2,000 parts, which is what a 256 KB response can carry; 10,000 passed the pre-check and then failed on size. -
numbers.semverrefuses a version given as a number, which cannot tell1.10from1.1, and refuses a leading zero in a numeric part. -
encode.base64decodes strictly: data after the padding, padding in the middle and characters outside the alphabet are refused rather than quietly ignored. -
convert.unitsrefusesS,HandTas ambiguous - siemens or seconds, henry or hours, tesla or tonnes.
Fixed
-
mathread a decimal literal as a binary float.exacton0.1 + 0.2 - 0.3returned277555756156289/5000000000000000000000000000000: the IEEE-754 rounding error, faithfully rationalised. A decimal literal is now the rational it prints as, in every mode, so the sum is0,0.1 * 3is exactly3/10and2^0.5issqrt(2). -
mathread grouped digits as a tuple.17.5% of 8,45,000 + 12% of 1,20,000came back as five numbers with no warning. Grouped digits are read as one number, with a line inassumptions, in the same two shapesnumbers.parseaccepts: Western threes (1,234,567) or Indian twos-then-a-three (12,34,567). A run that is neither —3,14,1,2345,123,45,678— is refused with those two spellings named, rather than evaluated as a tuple or stripped of its commas. Inside a call's brackets a comma still separates arguments. -
A rational power ran to the deadline.
(1+1/1000000)^1000000is about 2.718 and also a seven-million-digit integer over another; the size estimate measured the value and let it through, and the hosted server built the rational until the 15 s kill. The estimate now measures the exact form too.evaltakes the tree unevaluated and returns the decimal the caller asked for, withwarningssaying why there is noexact;exactrefuses astoo_largein a millisecond and points ateval. -
oderejected the syntax its own docstring documents.y'' + 4y' + 4y = 0failed the token guard withdisallowed token: "'", because the prime rewrite wanted a word boundary beforeyand there is none after a coefficient. The documented form now works, as does4yfor4*y(x). -
solvereported "no real solutions" as a numeric failure.x^2 + 1 = 0withdomain=realansweredunsupported— "no closed form exists and its roots could not be found numerically" — withequations: ["False"]leaking out. Neither half was true. It is nowokwith an empty list, andassumptionssaysno real solutions; 2 complex roots exist (domain='complex' to see them). The numeric fallback also respects the domain: the four complex roots ofx^4 - 2x^2 + 3are no longer offered as real solutions. -
expandnever expanded trigonometry, and said nothing.sin(2*x)came back untouched whileexp(x + y)split. The multiple-angle and sum identities are applied now;log(x*y)is left alone, correctly, andassumptionssays why.expand,factorandsimplifyall say so when they return the input unchanged, so "already in simplest form" and "this mode does not do that" can be told apart. -
factorreturned an integer unchanged.factor 12is now2**2*3, with the primes and exponents infactorsandprime: truefor a prime. -
factoron an equation surfaced CPython's parser.x^2 - 5*x + 6 = 0answeredinvalid syntax (<string>, line 1). A bare=where an expression is expected now says to drop the= 0and points atsolve; no parse failure carries the(<string>, line 1)artefact any more, and unbalanced brackets are called that. -
mathcross-checks every integral it computes. A definite integral is checked against numeric quadrature and the numeric value is returned when the two disagree, with a warning naming the closed form that was dropped; an antiderivative is differentiated back and the result carriesverified. SymPy 1.14 answers0for the integral of1/(x^8+1)over[0, 1]. A divergent integral is refused, a range where the integrand is not real is flagged, and a step function is integrated numerically rather than erroring. -
mathlimits that do not exist say so.1/xat 0 andsin(1/x)at 0 returnedexists: truebesidezooandAccumBounds. Both returnexists: falsenow, withleftandright, oroscillates_between. -
math.matrixrefuses a right-hand side whose length does not match the matrix, reports an inconsistent system asconsistent: falserather than "singular; general solution returned", names the shape when an operation needs a square matrix, names the row when the rows are ragged, reads a nested list written as a string, and estimates the digits ofA**nbefore computing it. -
math.plot_pointsdrops a sample that is a pole showing through (tan(x)beside a half-turn), falls back to SymPy where lambdify'smathmodule has no such function (zeta), and refuses an expression that still has a second free symbol in it. -
math.convert_formhonourstoleranceon a value that is already exact, reports the argument of 0 as undefined rather than NaN, keeps the exact form of a value below 1e-308, and writes scientific notation throughDecimalso10^400is notinf. -
math.statsrefuses a z-score of one point and a covariance of one pair, returns a horizontal regression (slope 0,r_squared: null) instead of refusing it as "correlation undefined", and names the index and value of a data point that is not a number. -
numberscomputes at 1,200 significant digits. Decimal's default context is 28, sosequenceterms,to_wordsvalues and parsed numbers past 28 digits were rounded in silence and anyquantizepast them raised. A number that would round is refused astoo_large. -
numbers.parseresolves.and,as a whole.1.234,56is 1234.56,1,234.56is 1234.56 and12,34,567.89is 1234567.89; commas that group nothing (1,2345,10,000,00) are refused rather than stripped. Thin and non-breaking spaces group digits, a sign may come before a currency symbol, and a value past the float range carries the note that says so. -
financereads a rate written with its sign.rate="12%"was 0.12%.rate,tip,percentanddiscountstake a percentage, and how any amount was read (10L,1,10) reachesassumptions. -
finance.npv_irrfinds every internal rate of return.[-100, 230, -132]has one at 10% and another at 20%, and bisection from the ends found neither. All roots are listed inirrs_percentwhen there is more than one, and the NPV is returned even when no IRR exists. -
finance.emistops the schedule when the balance reaches zero. A rounded-up instalment clears a long loan early; the schedule ran on into negative balances and a negative final payment.months_paidsays how many instalments there were. -
scaleinverts an explicitfactorininversemode, and scales to zero inlinearmode instead of refusing with a message written forinverse. -
datetimemeasures elapsed time in UTC.diff,duration_sumandoverlapacross a daylight-saving change counted wall-clock hours, so a day over the spring-forward change was 24. A wall time in the gap is moved forward and reported byparse,add,recurrenceandcron_next; an explicit offset settles a fold without the warning. -
datetimereads what it is given. A bare number is a Unix timestamp only with 9-13 digits, so2026is refused rather than answered as 1970; a written date fills missing parts with the first of the period and reports each fill;1/2is as ambiguous as1/2/2026; a trailing zone abbreviation is resolved rather than dropped;EST,MST,CETand the rest resolve as abbreviations rather than as tzdata's fixed-offset zones; cron reads7as Sunday inside a range;free_slotsreads9amas a time of day. -
holidaysaccepts country names and alpha-3 codes and returns the ISO-2 code, reads a month name, warns oncheckwhen the calendar has no data for that year, and declares only the parameters each mode reads. -
geo_offlineresolves coordinates inside a one-zone country to that zone (New Delhi answeredAsia/Kathmandu), refusesWashington,Portland,Birmingham,GeorgiaandLAas ambiguous, reads a zone name in any case and a backward-compatibility link such asAsia/Calcutta, matches country names regardless of accents, and no longer attaches a neighbouring territory's tzdata comment to a zone. -
text's regex guard covers four more shapes. Bounded nesting ((a{1,60}){1,60}), an alternation whose branches overlap by character class, a nullable body under a fixed repeat, and a nested quantifier behind anx-mode comment all ran to a hang. With the text length known the guard also budgets the polynomial shapes (a*a*a*bover 100,000 characters) and the slow-growing ones.validate.assert'smatchesruns through it too. -
textpositions are in the caller's string. A case-insensitivecountorfindreported offsets into the lower-cased text, which is a different length for a dotted I. -
collectionsmatches a CSV number against the same JSON number, leaves rows without the key out ofset_opsanddeduperather than treating them as equal, keeps leading zeros as identifiers, reads12,34the same way in every mode, refuses a numeric comparison on a text column, and refuses a deep or huge structure up front instead of raisingRecursionError. -
validatereportsEXEC, aDELETEinside a CTE,SELECT ... INTOandCOPYas writes; applies the PAN rule to a GSTIN's embedded PAN; rejects a card number of repeated digits; uses the same email check as modeemailforformat: email; reads a phone extension and rejects an unassigned country code; and refuses a URL with a control character, an out-of-range port or an emptytel:. -
encodecompares a Base64 digest exactly (a case-mangled digest verified), refuses akey_base64key that is not Base64 rather than signing with an empty key, decodes Base64 and hex strictly and names what is wrong, reads a JWT expiry given in milliseconds, and rejectsNaNandInfinitywhen parsing JSON. -
convertrounds currency half-up as it says it does; keeps the case of a unit, soMb/s,mPa,MmandmWhmean what they say; reads1,5the waynumbersdoes; reportsfactor_exactonly when the factor is exactly representable; boundsprecisionto 1-15; and tries the unit registry before reading three capitals as a currency. -
randomrefuses negative weights, a weighted unique draw larger than the list, a float range wider than a double, and a token request larger than the response can carry. -
colorrefusescmyk(0, 1, 1, 0)as ambiguous - it is red as fractions and near-white as percentages - and rounds the contrast ratio down, so a displayed figure never reaches a WCAG threshold the colours do not meet. -
Options are read as written across every tool.
"false"is false, a count must be a whole number, and no failure message carries a Python exception type orinvalid literal for int(). -
The loading skeleton replaces the page instead of covering it. The outgoing page was faded to a quarter and the skeleton laid over the top, so both were on screen at once and the text showed through the bars. The content now leaves the flow while the next page is on its way and the skeleton stands in its place; the docs pages still replace only the article and keep the sidebar readable. Coming back with the browser's Back button no longer leaves a stranded row of skeleton lines above the restored page.
[0.3.1] - 2026-08-27
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, a
picked/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.
[0.3.0] - 2026-08-27
Added
-
JSON tool reference.
/docs/toolsand/docs/tools/<name>serve JSON to a client and HTML to a browser, by content negotiation onAccept. The index lists all eighteen tools with their modes, the version, and the contract's error codes; a tool gives each mode's purpose, description, parameters (type, default, required) and example arguments. Neither needs a key./advertises the endpoint astools.tools/listover/mcpis unchanged and remains the authenticated, scope-aware route. -
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-IdandX-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. -
A 15-second compute ceiling (#28 §1 step 3).
math,text,validate,collectionsandnumberscalls run in a worker process that is terminated when the deadline passes. A call that reaches it returnstimeoutwithstopped: "worker_terminated", the limit and the elapsed time; a caller-suppliedtimeoutis clamped to the ceiling; a call that cannot get a worker within the queue deadline returns the retryablebusy. Workers carryRLIMIT_CPUandRLIMIT_AS, useforkserveron POSIX, and are recycled every 200 calls. Configured withLEFTBRAIN_COMPUTE_TIMEOUT,LEFTBRAIN_QUEUE_TIMEOUT,LEFTBRAIN_MAX_INFLIGHTand friends — see the README for the full table and the ingress relationship. Needspebble, added to theserver,allanddevextras; without it the server logs that isolation is off and runs in-process. 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 onlyok: 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,ResourceExhaustedandBusyare 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 passesretryable=Trueat the raise instead. -
Size caps: an input whose answer would be enormous is refused before it is computed (#28 §2e/§2f/§2g). Each check costs microseconds and returns
too_large, naming the limit and the parameter to change: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, so9^9^9^9,2^(2^100),2^1000000,factorial(factorial(20)),gamma(10**10)andexp(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; andfinance.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 formfe80::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 nomedianalthoughmath.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; andtext.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, so
datetime.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 isinvalid_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=5gave a one-year loan to a caller who described a five-year one. Both now record the choice inassumptions. -
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 literalInfinityandNaN, 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.recurrencewithFREQ=MINUTELYreturned four copies of the same date, and a sub-daily frequency now keeps its times;collections.pick_fieldssilently destroyed a field when two renames collided;collections.find_duplicateswith a key no record has reported every row as a duplicate of every other;datetime.free_slotsrefused two participants in the same timezone, which is two colleagues in one city, and now numbers them apart;math.eval √2produced a symbol calledsqrt2because the sign only bound inside brackets;collections.set_opsconflated1andtrue(Python hashes them the same), and now compares by type as well as value;finance.npv_irrreported one IRR for a cashflow with three sign changes and now reportssign_changesand warns that Descartes' rule allows several. -
Ranges that were never checked, and approximations that should have been refusals (#28 §2c/§2d). Every one of these returned
ok: truewith a confident number:geo_offlineacceptedlat=91(returningArctic/Longyearbyen, and a 10 118 km distance);datetime.business_dayssilently swapped a reversed range and now reportssignanddirectionlikediffdoes;convert.temperatureconverted −500 °C to −226.85 K, and a reading below absolute zero is now refused while adeltadifference still may be any sign;convert.currencyacceptedrate=-83;holidays.listacceptedmonth=13, and the month filter now applies tolong_weekendsas well asholidays;datetime.convert_tzanswered a wall time the clocks skipped or repeated with no note, and now warns naming which it is;geo_offline.distance "Delhi" → "Mumbai"returned 0 km because both were approximated by Asia/Kolkata's reference city, and an unknown place is nowneeds: coordinates— a name whose reference city is the place (Kolkata,London) still resolves;math.eval 1/0andtan(pi/2)returned SymPy's complex infinity rendered asnan + naniand are nowinvalid_inputpointing atmode: limit;math.solveon a degree-40 polynomial reportedno solutions foundwhere 40 roots exist, and now falls back to numeric roots saying so inassumptions. -
Predictable inputs are phrased instead of raising Python exceptions (#28 §4): five modes reported what broke inside leftbrain rather than what the caller did wrong.
collections.unflatten {"a": 1, "a.b": 2}wasTypeError: 'int' object does not support item assignmentand now names the key that is both a value and a prefix;scalewithto_qty: 0wasZeroDivisionError: Fraction(1, 0);datetime.add amount=1e9 unit=yearswasValueError: year must be in 1..9999and is now a stated 4 000 000 limit;finance.compound years=1000000wasinternalplus a bareInvalidOperationand is nowtoo_largeat 10 000 years;convert.units value=1e400wasOverflowError: int too large to convert to floatand is now a stated 1e300 magnitude limit. Each carriesdetailsand ahint. -
SSRF in
url_check(#28 §5):http://169.254.169.254/latest/meta-data/— the cloud metadata service, which answers with credentials — was actually fetched, as werelocalhostand RFC 1918 addresses. The host is now resolved and judged before the connection is opened and again after every redirect (a public host can answer302to a private address), and only publichttp/httpsaddresses are fetched.file:///etc/passwdwas being rewritten tohttps://file:///etc/passwdand attempted; any non-http(s) scheme is now refused instead. The connect budget drops from 15 s to 5 s. -
CSV formula injection in
collections.to_csv(#28 §5): cells such as=cmd|' /C calc'!A0,+1-2and@SUM(1)were written verbatim, so opening the file in a spreadsheet ran them. Cells beginning=,+,-,@, tab or CR are now prefixed with an apostrophe, with the count inassumptionsandescaped_cells;escape_formulas: falserestores the old behaviour and warns. A negative number is left alone —-12.5is data. -
A regular expression can no longer freeze the process (#28 §1):
text.regex_matchwith(a+)+$over"a"*40 + "b"never returned, and neither did(a|aa)+inregex_replaceor apatterninside avalidate.json_schemaschema. Once stdlibrestarts backtracking nothing can stop it —sreis a C loop that never reaches a bytecode boundary, so no timeout, signal or thread kill is delivered until it finishes. The three shapes that cause it (a quantified group that is itself unbounded, one that can match nothing, and a quantified alternation whose branches overlap) are now recognised and refused withunsupportedbefore the pattern is compiled, naming the shape and how to rewrite it.validate.regexjudges rather than runs, so it keeps answeringvalid: trueand reports the newbacktracking_riskfield with a warning. -
Silent truncation now says so (#28 §2f):
text.regex_match'scountis the total number of matches rather than the number returned — an agent readingcountoff a truncated response got the limit and believed it was the answer.returnedandtruncatedare new fields.datetime.recurrencewithcount=1000000returned 100 occurrences with nothing said about it, because acountlarger thanlimitsilenced the warning; it now setstruncatedand warns.holidays.nextwarns when the two-year search window holds fewer holidays than were asked for. -
A recursive JSON Schema is input, not a crash (#28 §1):
validate.json_schemawith{"$ref": "#"}or 200-deepallOfraisedRecursionError, which surfaced asinternalwith a traceback. Schemas nesting deeper than 50 levels are refused withtoo_large, and a$refcycle isinvalid_inputexplaining that validation cannot terminate. -
internalerrors no longer ship a stack trace to the caller (#28 §4): the traceback named server file paths in every response. It is logged server-side now; setLEFTBRAIN_DEBUG=1to get thetracefield back in the response as well.
Changed
-
A call that fails the input schema now answers in the contract (#28 §4):
convertwith no arguments, orcollectionswithwhereas a string, used to come back as an MCP transport error carrying a pydantic dump and anerrors.pydantic.devlink. Both now return{"ok": false, "error": "invalid_input", …}with the offending parameters underdetails.parametersandneeds.missingnaming anything required that was left out — the rejected values themselves are the caller's data and are not echoed back. -
Per-key tool scopes (#27): a key can be limited to chosen tools and, per tool, chosen modes — on the dashboard (a Tools disclosure on the create form, Edit scope on every key's row) or with
leftbrain-keys create|set … --tools "math,datetime,holidays:list+check"(--all-toolslifts it). A scoped key'stools/listshows only the tools it may call, and atools/calloutside the scope returns the contract error{"ok": false, "error": "forbidden", "message": "this key may not call holidays mode 'next'; allowed: list, check"}as a result, not a transport error.GET /keys/meandleftbrain-keys listreporttools(nullfor every tool). Existing keys have no scope and behave exactly as before; thekeystable gains a nullablescopecolumn on first start. -
math:round(...)over an expression withvarsfailed at parse time ("Cannot convert expression to float") because it evaluated its argument before the variables were substituted. It is now a deferred function that fires once the argument is numeric, and it rounds half-up on the decimal value, returning an exact rational (round(2.675, 2)→67/25= 2.68;round(basic * 12 / 365 * unpaid_days, 2)with vars →5128.77). -
datetime: a unix timestamp given as a digit string ("1787232546","1787232546000") was read as a year and refused. Any 9–13 digit value, string or number, is now an epoch in every mode that takes avalue(parse,convert_tz,add,diff, …), with the existing "unix timestamp read as UTC" / "read as milliseconds" assumptions; the MCPvalueparameter accepts numbers as well as strings, soconvert_tzcan take an epoch straight from another tool.
[0.2.0] - 2026-08-27
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;mixblends 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 itslabelback.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 needsingredient, 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 isok: 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.
[0.1.0] - 2026-08-26
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 returnok: 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 with
403 {"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/mereportsexpires_atandexpired;leftbrain-keys create --expires 90d|neverandset <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 integratetakeslower/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_qtyandfrom_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 from
LEFTBRAIN_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 sentCache-Control: no-store.