• v0.40.0 31af8520ef

    joeyr released this 2026-08-24 09:29:42 -05:00 | 89 commits to main since this release

    Canvas is the fifth tenant, and the first whose credential changes under the
    running process.
    C1 of docs/canvas-plan.md: the seam, the four rotation rules, the
    preflight line naming which credential it booted with, and one tool. Not deployed yet.

    The institution has disabled personal access token generation on this account, which
    closes the Authorization: Bearer path every public Canvas MCP server assumes. What is
    left is the browser session cookie, and Canvas re-issues it as the session slides.

    Three of the four rules fail silently

    A superseded canvas_session stays valid until its own embedded clock runs out, so a
    client that breaks one of these keeps working for hours and then stops for reasons
    nothing connects to the break.

    • Rotate only on a clean 200, and only when a value came back. The point is not
      gaining a cookie but not losing one. A 200 carrying no Set-Cookie is the other
      half
      — Canvas re-issues only once the value carries some age, so a client that reads
      a missing cookie as failure is broken from its second request.
    • Follow no redirects. An expired session bounces to the login page, and a client
      that follows it gets 200 and an HTML body, which reads as success. The content type
      is checked BEFORE the rotation for the same reason.
    • One writer — one replica, and a lock inside it.
    • The previous value is kept, so a bad write is recoverable without a browser.

    Added

    • list_courses, and deliberately nothing else. C1 proves the credential in
      production rather than delivering a surface. It reports a partial page rather than
      returning one — Canvas defaults to ten per page, and a client that ignores Link:
      returns the first ten and reports success.
    • A keepalive, part of the tenant rather than an optimisation. Measured against
      superseded values of known age: alive at 10.7 hours, alive at 19.3, dead at 51.6.
      A coursework connector is exactly the thing nobody calls over a weekend.
    • docs/operations.md gained Re-seeding the Canvas session, before deployment
      rather than after the first expiry — including the trap that editing
      CANVAS_COOKIE_SEED does not re-seed a running tenant
      , because the jar wins over
      the seed whenever it holds anything.
    • deploy/nginx/canvas.conf. What is behind this hostname is an education record.

    Testing

    All ten new guards were proved by breaking them. The concurrency test caught itself
    out first: it asserted "every value sent had been stored at some point", which passed
    with the lock removed
    — a thread sending a stale cookie is still sending a real one.
    Rewritten to assert what the lock actually promises, it fails immediately.

    The transport is httpx.MockTransport driving real httpx. Rule 1 turns entirely on what
    Response.cookies does with a Set-Cookie header, so a hand-written fake would be
    testing the fake.

    1349 tests green.

    Downloads
  • v0.39.2 cff4215765

    joeyr released this 2026-08-24 09:03:20 -05:00 | 90 commits to main since this release

    The credential was still in the one file nothing scans. 0.39.0 removed the real
    Canvas token from tests/test_feeds.py and, in the same commit, left it sitting in
    tests/test_no_pii.py — in the docstring of _looks_like_a_token, the helper written to
    catch exactly that shape. It survived two releases and every green run of the guard that
    exists to find it.

    Security

    • tests/test_no_pii.py no longer contains the token. The file exempts itself from
      _tracked_text_files by necessity — it has to carry examples of what a leak looks like,
      or its own detectors could not be proved — which makes it the one file in the repo where
      a pasted literal is checked by nothing at all. Every example now refers to
      _INVENTED_TOKEN: one literal, in one place, on a line whose comment says what it is.

    • A one-character mutation is not a redaction. The assertion's copy had been changed
      from …xd5… to …Xd5… and treated as invented. That is the credential with a typo in
      it, and trying both costs an attacker nothing. The replacement shares only the shape —
      upper and lower and digits together — which is all the case-mixing rule needs to be
      exercised by.

    Note

    • The value is also being removed from published history, along with the institution
      hostnames that 0.39.0 and 0.39.1 took out of the working tree. Rewriting a public history
      does not un-publish anything that was already fetched: the feed still has to be reset
      in Canvas
      , and until it is, the URL is live regardless of what this repo contains.

    • No runtime code changes. mcp_gateway/ is untouched apart from the version constant.

    Downloads
  • v0.39.1 40cfe948b2

    joeyr released this 2026-08-24 08:56:00 -05:00 | 91 commits to main since this release

    The sweep that removed the credential left the person behind it. 0.39.0 took a real
    Canvas feed URL out of tests/test_feeds.py and genericised the institution in its host.
    Four lines below that fix sat the same university's athletics host, and elsewhere in the
    suite sat the account holder's name and one of their calendar names — none of them a
    credential, all of them read off a real account or the person holding it.

    Security

    • No fixture names the account holder. A full real name stood as the
      comma-in-a-display-name example in mcp_gateway/mail/parts.py, a real calendar name was
      the colon fixture in tests/test_config.py and tests/test_eventid.py, and a first name
      was an OAuth email claim. Each replacement keeps the shape the test is actually about and
      drops the person: the display name still contains a comma, and the calendar name still
      contains the colon that forces CALENDARS to be quoted.

    • No fixture names the institution. The athletics host of the university that
      <school>.instructure.com had just stopped naming was still in tests/test_feeds.py,
      proving that a path segment with no extension redacts to the bare host. A reserved domain
      proves it identically.

    Added

    • tests/test_no_pii.py refuses an unrecognised real host in tests/. The Canvas rule
      added in 0.39.0 is pinned to a vendor, and a vendor-pinned rule is blind to a host at an
      unrelated domain however plainly it names the same institution — which is how one
      survived the commit that removed the other. A fixture may name a vendor endpoint the
      tenants integrate against or this project's own infrastructure, and nothing else; an
      organisation someone belongs to is neither.

      Scoped to tests/, not the repo. Prose has every reason to name real hosts — a
      registry, a spec, a changelog entry about an outage — and a rule that fought the
      documentation would be widened until it matched nothing. A fixture is invented input, so
      a real host in one arrived by being pasted from somewhere real.

      A port is stripped before the host is judged and an unparseable authority is skipped:
      the deployment tests carry :8443 and :8787, and test_icloudmail_unsubscribe feeds
      https://[oops to the gate on purpose. Proved in both directions — the rule was run
      against the host it exists to catch, and reports it and nothing else.

    Note

    • This release changes no runtime code. mcp_gateway/ is untouched apart from one
      docstring and the version constant, so the deployed behaviour is identical to 0.39.0.
    Downloads
  • v0.39.0 90c7edde5d

    joeyr released this 2026-08-23 21:40:06 -05:00 | 94 commits to main since this release

    A real Canvas feed URL was committed by the release that fixed feed-URL redaction.

    0.38.0 taught safe_url that a Canvas secret lives in the filename, and pinned the
    lesson with a fixture captured verbatim from the boot log that revealed it. The test
    proving the credential is hidden carried the credential.

    Security

    • tests/test_feeds.py no longer contains a live capability URL. A calendar
      subscription URL is the whole authorisation: anyone holding it reads the calendar,
      with no login. Host and token are now synthetic, and the host names no institution.
      The URL that was committed is compromised regardless — a published history keeps
      the value, so the remedy is resetting the feed in Canvas.
    • The institution host is out of CHANGELOG.md too.

    Added

    • tests/test_no_pii.py now checks capability URLs and Canvas hosts. It had two
      rules — addresses at unreserved domains, and iCloud principal paths — and a feed URL
      is neither, so it passed the leak on every run since 0.38.0. A guard against two
      shapes is not a guard against a class.
    • The token rule uses case mixing, not entropy: a hand-written placeholder like
      abc123secret%40group clears "16 characters, 8 distinct" without trying, so an
      entropy rule would fail on the synthetic fixtures beside the real one. Both guards
      were proved by breaking them with invented values of the right shape.

    1300 tests green.

    Downloads
  • v0.38.0 866a7afb28

    v0.38.0 Stable

    joeyr released this 2026-08-23 21:30:37 -05:00 | 95 commits to main since this release

    A feed URL'''s redaction kept the one segment that is sometimes the whole secret. Found by reading a real boot log, not by a test: a Canvas subscription printed as https://<school>.instructure.com/…/user_<hash>.ics — the elision worked on the middle of the path and then wrote out the credential.

    Security

    • safe_url now drops the entire path, keeping only scheme, host and a plausible file extension. It kept the last path segment on the reasoning that a capability URL "hides in the path, not the query". That is true of Google'''s secret address, where the secret is a middle segment and basic.ics is generic — and false of Canvas, where the filename IS the secret. Nothing in a URL distinguishes the two, so the rule is now uniform. What this gives up is telling a typo'''d filename from a 404; a typo'''d host still reads clearly, which is the case worth debugging from a log.

    • Feed.fallback_name is redacted too, and it was the worse of the two. It returned the same last path segment, and it does not only reach a log: it becomes Calendar.name for a feed that could not be fetched, so an unreachable Canvas feed would hand its capability URL to list_calendars — into a model'''s context and a transcript.

    • .env.example no longer promises something the code did not do. It told operators a log paste could not leak a feed URL. Anyone who configured a feed whose secret is its filename before this release should regenerate it — a capability URL has no other revocation, and the old value may sit in container logs.

    Testing

    Seven tests cover this and all seven were run against the previous implementation and watched to fail, per A new guard is not proved until it has been broken. The existing suite could not have caught it: every redaction test used the Google shape, where keeping the last segment happens to be safe. 1297 tests green.

    Downloads
  • v0.37.0 5a01728332

    joeyr released this 2026-08-23 20:00:17 -05:00 | 96 commits to main since this release

    The mailto half of unsubscribe_message works on Gmail, and two defects
    behind it are fixed. Neither was found by looking for it: the first turned up
    while trimming a comment in the stack file, and the second while writing the
    test for the first. Live pass in docs/operations.md — 11 checks, self-addressed
    throughout, so nothing was announced to anybody.

    Fixed

    • gmail.unsubscribe_message reads MAIL_SEND_ENABLED instead of a
      hard-coded False.
      The constant was correct when written — the tenant had
      no submission path — and became wrong the moment G4 gave it one in 0.34.0.
      The docstring directly above it promised the refusal would lift "the day
      sending ships, with no change here" while the code guaranteed it could not,
      and three releases went out with the two disagreeing. A capability withheld
      by a constant nobody re-read is indistinguishable, from the outside, from one
      the operator chose to withhold
      — which is exactly what MAIL_SEND_ENABLED
      exists to make visible. It now reads the same config icloudmail reads, so
      the gate and the thing that can actually send agree by construction.

    • Signature coverage now asks about the header the OFFER uses, on BOTH
      tenants.
      _REQUIRED_SIGNED demanded List-Unsubscribe-Post of every
      unsubscribe facility, citing RFC 8058 §3.2 — which is about one-click.
      List-Unsubscribe-Post is the header that MAKES an offer one-click, so a
      mailto-only sender has none and cannot sign one, and the gate refused it for
      the absence.

      It failed in the worst available direction. A mailto sender that signs its
      mail was refused BECAUSE it signed — its verified signature could not cover a
      header it never sent — while a sender with no signature at all read as
      signature_coverage_unknown and went through as a warning. The gate punished
      senders in proportion to how correctly they authenticated. True on
      icloudmail since 0.28.0 and invisible to the suite, because the fixture
      exercising a permitted mailto carries no DKIM-Signature and never reached the
      check. One-click still requires both headers; the default argument is the
      strict one, and a test pins which way that default fails.

    Added

    • gmail._unsubscribe_by_mail, through _submit — this tenant's ordinary
      send path, so an unsubscribe gets an ordinary send's single verdict. The mail
      tenant routes its equivalent through _submit_and_save because there the Sent
      copy is the only durable record the request was made; Gmail files that copy
      itself, so saved_to_sent stays None and to_dict says whose copy it is.
      Reading that None as "the record is missing" is the false alarm 0.34.0
      introduced the sentinel to prevent, and the tool layer is tested for it.

    • unsubscribe_message's description is built from the send gate, like
      every other conditional sentence in that module. It told the model this server
      "has no sending capability at all" for three releases after sending shipped —
      and a description is what a model reasons from when deciding whether to
      OFFER, so a stale one is not cosmetic.

    Changed

    • The Gmail client's unsubscribe_message returns three values rather than two,
      matching icloudmail's, so the mailto outcome has somewhere to go.
    Downloads
  • v0.36.0 acb9479c73

    joeyr released this 2026-08-23 18:18:26 -05:00 | 97 commits to main since this release

    G7: get_thread, and the Gmail tenant is complete at twenty-one tools. The
    one tool this provider can support and icloudmail cannot — iCloud advertises
    no threading, so the same tool there would mean guessing at References chains.
    It needs no setting: it reads, so it registers under the read allowlist beside
    the other seven. Live pass in docs/operations.md — 20 checks, preceded by a
    probe that decided the implementation rather than confirming it.

    Added

    • get_thread. Every message in one conversation, oldest first, each one
      exactly once, with spans_labels naming every label the conversation touches.
      Oldest-first is the one list on this tenant that is not newest-first, because
      a conversation printed newest-first puts the answers above the questions.

    • It gathers from every readable label, and that is the phase.
      probes/gmail_probe_thread.py measured a thread spanning labels in 12 of 12
      sampled cases, six with members in Sent Mail — so a thread read out of one
      label is half a conversation. And it measured the Bin and Spam sitting
      OUTSIDE All Mail
      , twice: a message in either was unreachable by
      X-GM-MSGID from All Mail, and a member binned mid-probe dropped out of the
      gather with its thread id intact. All Mail holds every member exactly once,
      which is what makes it the tempting gather point and what would have made the
      defect invisible — a conversation returned with the deleted message silently
      missing. The gather is search_messages' instead, keyed on the thread id, so
      a member in the Bin comes back and says the Bin.

    • An unsearchable label is named in the response, not just logged. A search
      that misses a folder returns fewer of many results; a thread that misses one
      is missing a participant, and the remainder still reads as a complete
      exchange. _thread_partial is its own copy of _partial rather than a call
      to it: that wording is about a window cut by UID and tells the model to narrow
      the query or raise limit, and a thread has neither.

    • A capped thread always contains the message that was asked about. Keeping
      the newest is right and would, alone, answer a question about one message with
      a list not containing it — the root of a long thread being exactly what "what
      is this about" is asked from. Matched by X-GM-MSGID, not by the handle,
      because the gather may meet that message under a different label first.

    Fixed

    • get_thread's description no longer promises the whole conversation.
      Gmail threads on References and a matching subject, and takes both — a
      true In-Reply-To reply with two words added to its subject gets a thread of
      its own. The probe had tested each half separately, seen both come back
      separate, and that was read as References is what threads; probes/g7_diag.py
      found the third case, surfacing as three red checks in the live pass against
      code that was working
      . The fixture had named its binned member Re: … root (deleted), so the message the section existed to bin was never in the thread
      it was being looked for in. The rule: this server returns the provider's own
      grouping and does not invent a better one — reassembling across a renamed
      subject would hand the user a conversation Gmail does not have.

    Changed

    • docs/gmail-plan.md is deleted, by the rule written at the top of it: it
      held only what was unbuilt, and nothing is. What was durable moved to
      CLAUDE.md — Why destroying is its own tool and not a boolean, cited from
      both mail clients, and Ruled out for the mail tenants, which is the list
      get_thread itself came off. The new A conversation is the provider's holds
      what this phase measured.

    • The Gmail surface test asserts twenty-one tools rather than "and not
      get_thread". That assertion turned red five times on purpose — G5, G3, G4,
      G6, G7 — and keeps its keep for the opposite reason now: the set is complete,
      so anything appearing in it is a tool nobody counted.

    Downloads
  • v0.35.0 881f0d6717

    joeyr released this 2026-08-23 12:53:40 -05:00 | 99 commits to main since this release

    [0.35.0] — 2026-08-23

    G6: drafts on the Gmail tenant. create_draft and send_draft, behind
    MAIL_SEND_ENABLED plus a writable drafts label. Live pass in
    docs/operations.md — 28 checks, and the probe that preceded it overturned the
    design before a line of it was written.

    Added

    • create_draft and send_draft. The only path on this tenant where a
      message exists in the mailbox before it exists on the wire, which is why they
      were split out of G4 rather than shipped with it.

    • send_draft mints a fresh Message-ID before submitting, ALWAYS, and
      that is the phase rather than a detail of it. probes/gmail_probe_drafts2.py
      submitted a draft's own bytes — what the tool naturally does — and Gmail
      dropped the message entirely: no delivery, no Sent copy, one object left
      in All Mail still wearing \Draft. Google already held that Message-ID, as
      the draft, and deduplicated against it. The tool as designed would then have
      removed the draft and reported the message sent, destroying the only copy of
      something already delivered — 0.28.1's defect reached by a different road. The
      mail tenant mints an id only when the draft carries none; here it is replaced
      whichever it is.

    • The Sent copy is confirmed before the draft is removed. By HEADER Message-ID in the sent label, found on the first look 1.0s after submission
      returned. Every failure answers no, an unreadable sent label included: a
      false negative costs a manual deletion and a false positive costs the message.
      Section 6 of the live pass makes the net catch something — it hides the sent
      label, then asserts the mail went, the draft survived, and the note leads with
      the message having already gone.

    • They register on the write allowlist as well as the send gate. G4's three
      touch no folder in this mailbox and ride MAIL_SEND_ENABLED alone;
      create_draft APPENDs, so on a deployment with nothing writable it could only
      refuse — and an offered tool is one the model promises the user before it
      discovers it cannot deliver. Preflight now asks the finer question the
      registration cannot: is the DRAFTS label specifically readable and writable.

    Fixed

    • BREAKING: get_message on gmail now nests its message under message,
      matching icloudmail and every sibling tool here. It had been flattening the
      message into the envelope since G1, so the two connectors answered different
      shapes for the same tool name — and a model that learned one read None from
      the other, which reads as this message has no subject rather than wrong
      shape
      . Found by G6's live pass, in a G1 tool; the same way G4's pass found a
      G2 defect. tests/test_gmail_drafts.py pins the agreement against both built
      servers, because a test that reads one side cannot see them drift.

    • The boot summary no longer reports folders the tenant does not use.
      sent_folder was printed on Gmail, which appends no Sent copy and never reads
      the setting, and trash_folder and drafts_folder were compared against
      iCloud's defaults on both tenants — so an untouched Gmail deployment reported
      (configured) and told the operator somebody had chosen [Gmail]/Trash.
      Nobody had. One helper now judges each against the defaults for the tenant it
      is running.

    Changed

    • Config.writes_sent_copy asks whether this server has to save the Sent copy
      itself — a question about the provider rather than a tenant name, alongside
      is_mail_tenant and mail_address.

    • send_message's description no longer tells the model this server cannot save
      a draft. Both tools ride the same gate, so the day one registers the other has
      too.

    Downloads
  • v0.34.0 86d7fe738b

    joeyr released this 2026-08-23 10:36:15 -05:00 | 100 commits to main since this release

    [0.34.0] — 2026-08-23

    G4: sending on the Gmail tenant. send_message, reply_message and
    forward_message, behind MAIL_SEND_ENABLED, which is off by default and
    registers nothing when it is. Live pass in docs/operations.md — 37 checks, and
    it found a defect in a G2 tool rather than in its own.

    Added

    • The three send tools, and the shortest send path in this project. Probe 9
      found Gmail writes [Gmail]/Sent Mail itself on submission, so this tenant
      appends nothing — no Sent folder to resolve, no APPENDUID to parse, no
      second verdict that can fail on its own. The deletions are the feature: the
      obvious port from icloudmail would leave a second copy in Sent every time
      this server mails anybody
      , in the folder the user opens to find out what was
      sent for them. The live pass counts the copies, and one is the passing answer.

    • One verdict, and a sentence saying whose copy it is.
      SendOutcome.saved_to_sent is None on this tenant and to_dict emits
      sent_copy instead of the boolean. Both values would be lies — false says
      the user's record is missing when it is not, true claims an operation this
      server never performed — and a JSON null is worse than either, since
      anything testing it for truth reads it as false.

    • Sending is NOT gated on MAIL_WRITABLE_FOLDERS here, because no folder is
      written. A deployment with an empty write allowlist can still mail a stranger,
      and that is stated in the instructions rather than left to be discovered. The
      one thing it costs is that a reply cannot flag its original \Answered, so
      marked_answered: false is the ORDINARY outcome on that deployment — the tool
      description says not to read it as a failure, and the live pass asserts both
      halves.

    • tests/test_mail_smtp.py, which nothing had ever driven. Every send test
      on either tenant replaces submit with a fake, so the lines deciding which
      account authenticates and whether the credential crosses before STARTTLS were
      reachable only from production. Found by the break-the-guard pass: swapping
      mail_address back to icloud_mail_address broke Gmail's send entirely and
      every test still passed.

    Fixed

    • A STORE that changes nothing is no longer reported as a vanished message —
      on BOTH tenants.
      add_flags of a flag the message already carries answers
      {}, with no untagged FETCH, and that was read as "the message is gone". So
      update_message(read=true) on an already-read message told the user their
      mail had probably been deleted from another device: a failure reported
      about a success, on the most ordinary call the tool has. Shipped in 0.25.0 on
      icloudmail and 0.31.0 on gmail.

      A vanished UID answers {} too, so the two states are indistinguishable from
      the STORE alone. The fix asks: on an empty answer the client FETCHes the flags
      back, and only a FETCH that also returns nothing means gone. Not a
      provider table
      — the two servers disagree even here, since removing a flag
      that was never set answers {} on Gmail and answers with the flag list on
      iCloud.

      Neither suite could fail this way, because a fake that answers a STORE answers
      it every time. The Gmail fake now carries silent_store and vanished as
      separate switches, and the two tests using them are a pair: a no-op must
      succeed, a vanished message must still refuse.

    Changed

    • icloudmail/smtp.py is mail/smtp.py, shared by both mail tenants and
      authenticating with Config.mail_address. Same move mail/errors.py made in
      0.29.0 and for the same reason: the alternative was gmail/ importing
      icloudmail/. Submission came out identical on both accounts — what is
      genuinely provider-specific is what happens to the copy afterwards, and that
      stays in each tenant's client.

    • The Gmail instructions no longer say a server that can send is READ-ONLY, and
      no longer tell the model it cannot send once the gate is on. Built from the
      settings the tools are registered on, the same way the unsubscribe and
      label-admin clauses already were — this is the third gate to reach that
      branch, which is why the refusal list is now assembled rather than written out.

    • Preflight gains a Gmail send line that verifies nothing, deliberately.
      The mail tenant's checks whether Sent and Drafts exist and are writable; this
      tenant writes no folder to send, and the credential was already proved by the
      IMAP login — the same app password serves both hosts. It is a grant line, and
      it does not dial SMTP.

    Downloads
  • v0.33.0 f9d35afaee

    joeyr released this 2026-08-23 09:37:54 -05:00 | 101 commits to main since this release

    G3: label administration on the Gmail tenant. create_folder, rename_folder and delete_folder, behind MAIL_FOLDER_ADMIN — which is parallel to MAIL_WRITABLE_FOLDERS rather than nested inside it, and registers nothing when off.

    The finding this phase turns on

    A straight port of icloudmail.delete_folder would have told the user their mail was about to be destroyed. That tool says the messages do not go to trash and nothing can recover them, which is true there. On Gmail it is false: a label holding two messages was deleted on the live account and both were still in All Mail afterwards — the one that carried no other label came back with [], archived rather than gone.

    What a label delete destroys is the filing, and that part is irreversible, because nothing records which messages carried the label. So the two guards stay and their numbers change meaning. expected_message_count is how many messages lose the label, and the refusal reports a second number beside it: how many are then left carrying no label at all, reachable only by searching All Mail. That is the number the user actually feels, and it cannot be derived from the first.

    Also in this release

    • create_folder reports the parent Gmail invents. CREATE a/b with no a succeeds and leaves a as a \HasChildren \Noselect placeholder — a hierarchy node, not a label. Returned as implied_parent, because "created Work/Receipts" otherwise reads as though there is now a Work label.
    • rename_folder reports how many nested labels moved with it. A Gmail rename carries the whole subtree, including labels outside MAIL_FOLDERS that this server cannot see. Every handle under the prefix dies — and UIDVALIDITY and the UIDs inside are untouched, so the handle breaks purely because it carries the old name.
    • A label with children is refused, although Gmail would allow it: Gmail keeps the children and goes on showing the parent name as an empty grouping.
    • [Gmail]/ is refused as a namespace by this server, because Google itself accepts a label created there.
    • The server no longer calls itself READ-ONLY while it can delete a label. Folder admin rides its own gate, so it reaches a configuration where nothing else changes anything.
    • The "add it to MAIL_FOLDERS first" caveat is now conditional on there being an allowlist. On the wildcard deployment a new label is readable the moment it exists.

    50 checks green in the live pass; see docs/operations.md. Full detail in CHANGELOG.md.

    Deploying: this needs MAIL_FOLDER_ADMIN: "true" in the stack file (already pushed to the homelab repo), a yaml stack update in Portainer with re-pull ticked, and the Gmail connector reconnected — the tool list changed.

    Downloads