Repository navigation
v0.4.0
Pre-releaseTwo 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: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 beside
distance_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 of
142.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 why
hiwas 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 were
itemsordata, 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 into
Verdict'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 withallowed: 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|, matching
finance.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.decimals
onconvert.units,deltaonconvert.currency,nonholidays.checkanddateon
holidays.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.10from
1.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.3returned
277555756156289/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 in
assumptions, 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 as
too_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 does
4yfor4*y(x). -
solvereported "no real solutions" as a numeric failure.x^2 + 1 = 0with
domain=realansweredunsupported— "no closed form exists and its roots could not be found
numerically" — withequations: ["False"]leaking out. Neither half was true. It is nowok
with 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 = 0answered
invalid 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 returned
exists: truebesidezooandAccumBounds. Both returnexists: falsenow, withleft
andright, 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 of
A**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, so
sequenceterms,to_wordsvalues and parsed numbers past 28 digits were rounded in
silence and anyquantizepast them raised. A number that would round is refused as
too_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 in
irrs_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 inlinear
mode 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 as
1/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,Georgiaand
LAas ambiguous, reads a zone name in any case and a backward-compatibility link such as
Asia/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-insensitivecountorfind
reported 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 a
key_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, so
Mb/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.