Skip to content

v0.4.0

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 29 Aug 20:14
· 29 commits to main since this release

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 — coordinates is now zone_reference, because that is what it always held.
    tz_for_coords gained a coordinates echoing 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 — renamed date_locale, which is what it does. locale still works.
  • numbers.allocate — exact_unrounded is 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/call that did work costs exactly one.
  • collections.sort_by honours order when the key is given as keys. Anything relying on
    that silently sorting ascending will now sort descending, correctly.
  • math refuses trigonometry without an angle even where the expression folds to a
    constant, and datetime.recurrence refuses "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),
    PKCE S256, and refresh-token rotation. Needs LEFTBRAIN_SECRET and LEFTBRAIN_BASE_URL;
    without both, nothing is mounted. lblz_ keys are unaffected — same responses, same quota
    headers, same tools/list filtering.
  • 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_authorization and /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 returns 202 and
    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's resource_documentation.

Added

  • holidays mode categories, mirroring subdivisions: the category values a country
    actually accepts. There is no single enum to publish — optional is 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" returned 0 — the radian answer, as an exact integer, with nothing in
    assumptions to say the parameter had been dropped. SymPy folds sin(pi) to 0 while the
    expression is still being parsed, and the degree conversion ran afterwards, on a tree with no
    sin left in it to convert; a plain numeric argument survives parsing, which is why sin(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) is sin(π°) ≈ 0.0548. cos(pi),
    sin(pi/2), tan(pi/4), cos(2·pi) and asin(1) were all wrong the same way. (#66)
  • The mandatory angle refusal was skipped for exactly those expressions. math refuses
    trigonometry without an angle rather than guessing between two readings that differ by a
    factor of 57 — but the check looked for trig in the parsed tree, so sin(pi) with no angle
    at all answered 0 instead of asking. Trigonometry is now detected in the source, which the
    parse cannot erase. (#69)
  • text.count ignored a substring it was given and answered a different question. "how
    many r in blueberry" reaches the tool as count(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 no what now counts that needle and says so; a needle given
    alongside a what that cannot read it is reported rather than dropped. The what refusal also
    omitted substring — the spelling that actually worked — from the values it listed. (#65)
  • numbers.sequence ignored start in silence. end, step and ratio were already
    reported when the kind did not read them; start was 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 each tools/call spent 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-Today and
    meta.quota.remaining_today are the same number by construction, and both now count the call
    they ride on: an MCP POST holds 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)
  • simplify removed a point from the domain without saying so. (x^2-1)/(x-1) came back as
    x + 1 with an empty assumptions list — but the input is undefined at x = 1 and x + 1 is
    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 carries restrictions (["x != 1"]) and says in
    prose which points it gained. It covers every mode that can cancel, not just simplify —
    eval and factor do it too — and the denominators are read off an unevaluated parse,
    because x*(x-2)/(x-2) is already x by the time an evaluated tree exists. expand, which
    keeps the denominator, says nothing. (#68)
  • math.convert_form refused value, which the tool's own signature advertises. The mode
    re-renders a quantity, so value is the word a caller reaches for, and the flat schema lists it
    because stats takes one; it is an alias for expr here, 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 Any renders as anyOf: [{}, {"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 made encode'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_by ignored order whenever the key was given as keys — 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 singular key would have
    concluded order was fine. Mixed directions are now expressible too, as {field, order} or a
    -field prefix, and an order that cannot be applied is refused rather than dropped. (#84)
  • holidays applied a categories: ["public"] filter and never mentioned it. check
    returned is_holiday: false for 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. Omitting subdiv already
    produced an assumption; this was the one narrowing that stayed hidden. The default is now
    named on every call that used it, and check reports a date the dataset knows under another
    category as a near miss rather than a bare false. (#72, and the visible half of #80)
  • geo_offline reported the zone's reference city as coordinates, whatever place was
    asked about: tz_for_place("Mumbai") and ("Chennai") both returned Kolkata's, 1,650 and
    1,300 km out. tz_for_coords was visibly self-contradictory — those coordinates beside
    distance_to_reference_km: 0.5. The field is now zone_reference, a coordinate lookup echoes
    the point it was asked about, and nearest_reference shows which reference actually chose the
    zone. (#73)
  • datetime.convert_tz reported 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 in source, with is_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.recurrence read "every 2nd tuesday" as monthly, silently. "Every other tuesday"
    means the same thing to most speakers and produced a fortnightly schedule; assumptions was
    empty either way, so an agent scheduling a fortnightly standup got a monthly meeting series.
    It is refused now, the way 03/04/2025 already is, offering both readings as rules the tool
    accepts. (#81)
  • datetime.free_slots refused days: "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, and weekdays/weekends groups are accepted now. (#82)
  • numbers.allocate spent 8 KB of context on a 7-way split, emitting ~1,100 digits of
    142.857142… per share while truncated reported false, because nothing had been cut. The
    exact value is 1000/7. 9,516 bytes down to 1,151. (#74)
  • A defaulted mode was invisible, and its refusal spoke in the wrong vocabulary. A call
    naming no mode gets the schema's default; meta.mode was absent, and a validate call the
    caller believed said mode: "email" was told it needed rules, which belongs to assert.
    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 a forbidden never ran the mode at all.
    (#79)
  • holidays.locale never localised anything. It is the date-parsing locale, which is why
    hi was refused and a country code demanded. It is date_locale now; locale still 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 echoed region: "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, and countries returns 250 named entries
    instead of ~500 undifferentiated strings mixing three code systems. (#83)
  • collections called the same thing by different names in different modes — records were
    items or data, the grouping key key or by — so learning one mode did not transfer to
    the next, and group_by cost 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/Tokyo includes
    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, not 429. Both rate-limit paths passed the key record into
    Verdict's message field positionally, and it is not serialisable — so every caller who hit
    their rate limit or exhausted their daily quota got a server error with no Retry-After and 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's percent_change_a_to_b divides by |a|, matching
    finance.percent: from -100 to -50 is +50%, not -50%. It is omitted, with an assumption
    saying why, when a is zero.

  • color.contrast's ratio is rounded down, and ratio_exact carries the unrounded
    figure. 2.99979 was displayed as 3.0 beside "does not meet 3:1".

  • Each mode of convert and holidays declares only the parameters it reads. decimals
    on convert.units, delta on convert.currency, n on holidays.check and date on
    holidays.list were accepted and ignored; they are refused now.
    convert.temperature converts temperatures rather than whatever units it is handed.

  • math.solve filters solutions by domain. x^40 = 2 over the reals returned 26
    solutions, 24 of them complex. An identity reports identity: true and an inconsistent
    system reports no solutions, both ok.

  • numbers.allocate is 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.semver refuses a version given as a number, which cannot tell 1.10 from
    1.1, and refuses a leading zero in a numeric part.

  • encode.base64 decodes strictly: data after the padding, padding in the middle and
    characters outside the alphabet are refused rather than quietly ignored.

  • convert.units refuses S, H and T as ambiguous - siemens or seconds, henry or
    hours, tesla or tonnes.

Fixed

  • math read a decimal literal as a binary float. exact on 0.1 + 0.2 - 0.3 returned
    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
    is 0, 0.1 * 3 is exactly 3/10 and 2^0.5 is sqrt(2).

  • math read grouped digits as a tuple. 17.5% of 8,45,000 + 12% of 1,20,000 came back as
    five numbers with no warning. Grouped digits are read as one number, with a line in
    assumptions, in the same two shapes numbers.parse accepts: 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)^1000000 is 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. eval takes the tree unevaluated and returns the decimal the
    caller asked for, with warnings saying why there is no exact; exact refuses as
    too_large in a millisecond and points at eval.

  • ode rejected the syntax its own docstring documents. y'' + 4y' + 4y = 0 failed the
    token guard with disallowed token: "'", because the prime rewrite wanted a word boundary
    before y and there is none after a coefficient. The documented form now works, as does
    4y for 4*y(x).

  • solve reported "no real solutions" as a numeric failure. x^2 + 1 = 0 with
    domain=real answered unsupported — "no closed form exists and its roots could not be found
    numerically" — with equations: ["False"] leaking out. Neither half was true. It is now ok
    with an empty list, and assumptions says no real solutions; 2 complex roots exist (domain='complex' to see them). The numeric fallback also respects the domain: the four
    complex roots of x^4 - 2x^2 + 3 are no longer offered as real solutions.

  • expand never expanded trigonometry, and said nothing. sin(2*x) came back untouched
    while exp(x + y) split. The multiple-angle and sum identities are applied now; log(x*y)
    is left alone, correctly, and assumptions says why. expand, factor and simplify all
    say so when they return the input unchanged, so "already in simplest form" and "this mode
    does not do that" can be told apart.

  • factor returned an integer unchanged. factor 12 is now 2**2*3, with the primes and
    exponents in factors and prime: true for a prime.

  • factor on an equation surfaced CPython's parser. x^2 - 5*x + 6 = 0 answered
    invalid syntax (<string>, line 1). A bare = where an expression is expected now says to
    drop the = 0 and points at solve; no parse failure carries the (<string>, line 1)
    artefact any more, and unbalanced brackets are called that.

  • math cross-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 carries verified. SymPy 1.14 answers 0 for the integral of 1/(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.

  • math limits that do not exist say so. 1/x at 0 and sin(1/x) at 0 returned
    exists: true beside zoo and AccumBounds. Both return exists: false now, with left
    and right, or oscillates_between.

  • math.matrix refuses a right-hand side whose length does not match the matrix, reports
    an inconsistent system as consistent: false rather 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**n before computing it.

  • math.plot_points drops a sample that is a pole showing through (tan(x) beside a
    half-turn), falls back to SymPy where lambdify's math module has no such function
    (zeta), and refuses an expression that still has a second free symbol in it.

  • math.convert_form honours tolerance on 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 through Decimal so 10^400 is not inf.

  • math.stats refuses 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.

  • numbers computes at 1,200 significant digits. Decimal's default context is 28, so
    sequence terms, to_words values and parsed numbers past 28 digits were rounded in
    silence and any quantize past them raised. A number that would round is refused as
    too_large.

  • numbers.parse resolves . and , as a whole. 1.234,56 is 1234.56, 1,234.56 is
    1234.56 and 12,34,567.89 is 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.

  • finance reads a rate written with its sign. rate="12%" was 0.12%. rate, tip,
    percent and discounts take a percentage, and how any amount was read (10L, 1,10)
    reaches assumptions.

  • finance.npv_irr finds 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_percent when there is more than one, and the NPV is returned even when no IRR exists.

  • finance.emi stops 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_paid says how many instalments there were.

  • scale inverts an explicit factor in inverse mode, and scales to zero in linear
    mode instead of refusing with a message written for inverse.

  • datetime measures elapsed time in UTC. diff, duration_sum and overlap across 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 by parse, add,
    recurrence and cron_next; an explicit offset settles a fold without the warning.

  • datetime reads what it is given. A bare number is a Unix timestamp only with 9-13
    digits, so 2026 is refused rather than answered as 1970; a written date fills missing
    parts with the first of the period and reports each fill; 1/2 is as ambiguous as
    1/2/2026; a trailing zone abbreviation is resolved rather than dropped; EST, MST,
    CET and the rest resolve as abbreviations rather than as tzdata's fixed-offset zones;
    cron reads 7 as Sunday inside a range; free_slots reads 9am as a time of day.

  • holidays accepts country names and alpha-3 codes and returns the ISO-2 code, reads a
    month name, warns on check when the calendar has no data for that year, and declares only
    the parameters each mode reads.

  • geo_offline resolves coordinates inside a one-zone country to that zone (New Delhi
    answered Asia/Kathmandu), refuses Washington, Portland, Birmingham, Georgia and
    LA as 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 an x-mode comment all ran to a hang. With the text
    length known the guard also budgets the polynomial shapes (a*a*a*b over 100,000
    characters) and the slow-growing ones. validate.assert's matches runs through it too.

  • text positions are in the caller's string. A case-insensitive count or find
    reported offsets into the lower-cased text, which is a different length for a dotted I.

  • collections matches a CSV number against the same JSON number, leaves rows without
    the key out of set_ops and dedupe rather than treating them as equal, keeps leading
    zeros as identifiers, reads 12,34 the same way in every mode, refuses a numeric
    comparison on a text column, and refuses a deep or huge structure up front instead of
    raising RecursionError.

  • validate reports EXEC, a DELETE inside a CTE, SELECT ... INTO and COPY as
    writes; applies the PAN rule to a GSTIN's embedded PAN; rejects a card number of repeated
    digits; uses the same email check as mode email for format: 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 empty tel:.

  • encode compares a Base64 digest exactly (a case-mangled digest verified), refuses a
    key_base64 key 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
    rejects NaN and Infinity when parsing JSON.

  • convert rounds currency half-up as it says it does; keeps the case of a unit, so
    Mb/s, mPa, Mm and mWh mean what they say; reads 1,5 the way numbers does;
    reports factor_exact only when the factor is exactly representable; bounds precision to
    1-15; and tries the unit registry before reading three capitals as a currency.

  • random refuses 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.

  • color refuses cmyk(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 or invalid 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.