Skip to content

fix(models): handle binary file downloads in tool execution - #190

Merged
jerann merged 3 commits into
mainfrom
fix/execute-binary-download-handling
Jun 3, 2026
Merged

jerann merged 3 commits into
mainfrom
fix/execute-binary-download-handling

Conversation

@jerann

@jerann jerann commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • File-download actions (e.g. googledrive_unified_download_file) serve raw binary with the file's own MIME type and a Content-Disposition header, so StackOneTool.execute() calling response.json() unconditionally raised UnicodeDecodeError on the first non-UTF-8 byte.
  • execute() now branches on Content-Type: JSON media types (application/json and +json suffixes) are parsed as before; any other type returns the file as {content: bytes, content_type, status_code, headers, file_name}, matching the StackOne generated SDKs' download response shape. file_name is parsed from Content-Disposition (including RFC 5987 filename*).
  • Existing JSON responses are unchanged; the fix applies to the RPC tool path transitively.

Note: content holds raw bytes and is not JSON-serializable — callers that re-serialize tool results (e.g. for an LLM) should handle that key. The sibling stackone-ai-node SDK has the same response.json() gap and warrants a matching fix.

🤖 Generated with Claude Code


Summary by cubic

Fixes StackOneTool.execute() failing on binary downloads by parsing JSON only when the Content-Type is JSON and returning bytes + metadata for other types. Improves filename parsing to honor RFC 5987 charsets. Adds README docs explaining the file-download return shape and the raw-bytes caveat.

  • Bug Fixes
    • Treat JSON only as application/json or +json types; otherwise return {content: bytes, content_type, status_code, headers, file_name}.
    • Parse file_name from Content-Disposition, including RFC 5987 filename*; honor declared charsets (UTF-8/ISO-8859-1), fall back to UTF-8 on unknown labels, and strip quotes on non-conformant values.
    • Applies to RPC actions (e.g. googledrive_unified_download_file) as well.
    • Added tests for binary bodies, missing Content-Type, JSON with charset, ISO-8859-1 and unknown filename* charsets, and the helper parsers.

Written for commit 10d7802. Summary will update on new commits.

Review in cubic

Download actions (e.g. googledrive_unified_download_file) serve raw binary
with the file's own MIME type and a Content-Disposition header, so
StackOneTool.execute() calling response.json() unconditionally raised
UnicodeDecodeError on the first non-UTF-8 byte.

Branch on Content-Type: parse JSON only for JSON media types (application/json
and +json suffixes); otherwise return the file as
{content: bytes, content_type, status_code, headers, file_name}, matching the
StackOne generated SDKs' download response shape. file_name is parsed from
Content-Disposition (incl. RFC 5987 filename*). Existing JSON behavior is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 3, 2026 07:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates StackOneTool.execute() to correctly handle file-download endpoints that return raw binary bodies by only JSON-parsing responses when the Content-Type indicates a JSON media type, and otherwise returning the raw bytes plus relevant metadata (including file_name parsed from Content-Disposition).

Changes:

  • Added Content-Type detection to parse JSON only for application/json / +json responses; otherwise return {content: bytes, content_type, status_code, headers, file_name}.
  • Added helper utilities for JSON media type detection and Content-Disposition filename extraction (including filename* support).
  • Added tests covering binary bodies, missing Content-Type, JSON with charset parameters, and the helper parsers.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
tests/test_tool_calling.py Adds regression and helper unit tests for binary download handling and header parsing.
stackone_ai/models.py Implements Content-Type-based branching in execute() and adds helper functions for JSON detection and filename extraction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread stackone_ai/models.py Outdated
Comment on lines +82 to +84
extended = re.search(r"filename\*\s*=\s*[^']*'[^']*'([^;]+)", value, re.IGNORECASE)
if extended:
return unquote(extended.group(1).strip())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10d7802: declared charset is now honoured via unquote(..., encoding=charset) with a UTF-8 fallback on unknown charsets, and surrounding quotes are stripped off non-conformant quoted filename* values (.strip('"')).

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread stackone_ai/models.py Resolved
Add a "File Downloads" section covering the bytes-plus-metadata dict that
execute()/call() return for non-JSON responses, the Content-Type detection
rule, and the not-JSON-serializable caveat for LLM-facing re-serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread stackone_ai/models.py Outdated
Comment on lines +82 to +85
extended = re.search(r"filename\*\s*=\s*[^']*'[^']*'([^;]+)", value, re.IGNORECASE)
if extended:
return unquote(extended.group(1).strip())
quoted = re.search(r'filename\s*=\s*"([^"]*)"', value, re.IGNORECASE)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10d7802 — charset is parsed from the filename* value and passed to unquote(..., encoding=...), falling back to UTF-8 on an unknown charset label.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This s great catch by copilot but only in Python for parity we should optionally change this to the node too?

Comment on lines +505 to +507
# RFC 5987 extended form is percent-decoded and takes precedence.
("attachment; filename=\"fallback.txt\"; filename*=UTF-8''na%C3%AFve.txt", "naïve.txt"),
("attachment", None),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 10d7802: ISO-8859-1 (non-UTF-8) charset, an unknown-charset fallback, and a quoted filename* case in test_filename_from_content_disposition.

The Content-Disposition filename* branch captured the charset in its
regex but discarded it, so unquote() always decoded with UTF-8. RFC 5987
also permits ISO-8859-1, so an ISO-8859-1 percent-encoded filename
decoded to mojibake despite the helper claiming RFC 5987 support.

- Decode the extended value with its declared charset; fall back to
  UTF-8 for an unknown or empty charset label (LookupError) instead of
  raising.
- Strip surrounding quotes off non-conformant quoted filename* values.
- Add tests for ISO-8859-1, an unknown charset, and a quoted filename*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: This change modifies core execution logic to handle binary responses differently, altering the return shape for non-JSON tool results, which could impact existing callers that rely on JSON-deserialized responses.

Re-trigger cubic

@willleeney willleeney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@jerann
jerann merged commit 8983c68 into main Jun 3, 2026
16 checks passed
@jerann
jerann deleted the fix/execute-binary-download-handling branch June 3, 2026 13:44
willleeney added a commit that referenced this pull request Sep 16, 2026
`download.execute({"id": "file-id"})` routed the id into the RPC envelope's
body, not its path — tools are listed flat-prefixed, and an unprefixed key falls
through to the body, so the call downloaded nothing. `path_id` is the key the
served schema actually names.

The download behaviour itself is current: /actions/rpc serves a download action's
body as raw binary, which StackOneRpcTool.execute still routes through the
Content-Type branch (#190).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
willleeney added a commit that referenced this pull request Sep 29, 2026
…nd hardened security (#199)

* test(mocks): run MCP mock server with tsx instead of bun

The mock server was bun-specific in two ways beyond its shebang: it read
Bun.argv and relied on Bun's default-export server convention, which Node
ignores entirely. Replace both with process.argv and @hono/node-server.

Add a root package.json pinning the mock's dependencies to the exact
versions the vendored submodule resolves (sdk 1.24.3, zod 4.1.13,
hono 4.10.7, @hono/mcp 0.1.5). Caret ranges resolve sdk to 1.30.0, which
rejects the raw inputSchema objects the vendor mock passes.

These 38 tests previously skipped because the submodule was never
initialised; they now run and pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: replace justfile with Makefile

just was supplied by the nix flake and by CI's setup-nix action, both of
which are being removed. make is preinstalled on macOS and Linux, so the
task runner no longer needs a package manager to bootstrap.

Positional arguments become variables: `just install --all-extras` is now
`make install EXTRAS="--all-extras"`, and `just run-example foo.py` is
`make run-example FILE=foo.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: remove nix toolchain in favour of uv and ruff

The flake provided four things: treefmt formatting, pre-commit hooks, the
devShell toolchain, and agent skills delivery. Replace each:

- Formatting: ruff is already a dev dependency, so `make lint` and
  `make format` call it directly. nixfmt is no longer needed (no .nix
  files remain) and oxfmt is dropped, so non-Python files are now
  unformatted.
- Hooks: removed. Lint, type check and tests run in CI on every push.
- CI: setup-nix replaced with astral-sh/setup-uv, plus pnpm and Node for
  the MCP mock server. gitleaks now uses its official action rather than
  installing the binary through nix.
- Skills: dropped. They were gitignored and only materialised inside
  `nix develop`, so they were never visible outside a nix shell.

Also drops the nix-flake and nix-flake-update workflows, the nix-workflow
rule, and the stale .pre-commit-config.yaml ignore entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): add AGENTS.md symlink as shared instruction source

AGENTS.md is the cross-tool standard that Cursor and other agents read
natively; Claude Code reads CLAUDE.md. Symlinking one to the other gives
both a single source of truth with no duplicated content, matching the
pattern already used in stackone-ai-node.

Also removes the two Available Skills tables (the section was duplicated,
listing different skills each time) and the nix-workflow row now that
skills and the nix rule are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): consolidate rules into CLAUDE.md

Fold the seven .claude/rules/ files into CLAUDE.md and delete .claude/
and .cursor/ entirely. AGENTS.md already symlinks to CLAUDE.md, so a
single file now serves every agent with no symlink tree to maintain.

Condensed rather than concatenated: a verbatim merge came to ~505 lines,
against the 200-line target above which adherence drops. Dropped the
illustrative good/bad code blocks and the sample UV script, keeping every
actual rule. Result is 192 lines.

Two corrections made while merging, both previously contradicted by the
repo itself:

- Line length was documented as 88 ("ruff default"); pyproject.toml sets
  110.
- The pre-commit hooks section described hooks that no longer exist.

Trade-off: the four path-scoped rules (examples, scripts, pyproject, *.py)
previously loaded only when touching matching files. They now load every
session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: remove scripts/ directory

scripts/ held only benchmark_search.py, a manual latency benchmark that
required STACKONE_API_KEY and STACKONE_ACCOUNT_ID to run. Nothing
referenced it: no CI job, no test, no Makefile target.

Also drops the now-dead scripts/ ruff per-file-ignore and the Scripts
section of CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: remove .envrc left over from nix removal

.envrc contained `use flake`, the nix-direnv hook that activates the nix
devShell on cd. flake.nix was removed in 3bf1a27, so the file errors for
anyone with direnv installed. It was tracked, so it affected everyone.

uv manages the virtualenv directly, so there is nothing left for direnv
to load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: raise minimum Python to 3.11, fix CI, harden action headers

Python 3.11 minimum. The CI matrix claimed to test 3.10-3.13 but the old
setup-nix action ignored matrix.python-version entirely, so every leg ran
on the same devShell interpreter. setup-uv honours it, which exposed that
the examples extra cannot install on 3.10 (onnxruntime 1.24.3 ships no
cp310 wheels).

CI fixes:
- gitleaks: the GitHub Action requires a paid licence for org-owned repos
  and would have exited 1 on every run. Install the free CLI and call
  `make gitleaks`, which also revives a Makefile target nothing installed.
- Scope pages/id-token write permissions to the coverage jobs instead of
  granting them workflow-wide, so the job running PR code can no longer
  mint an OIDC token.

Security: _build_action_headers stripped only exact-case "Authorization",
but header names are case-insensitive and tool arguments are
model-controlled, so headers_authorization passed straight through into
the RPC envelope. x-account-id was likewise overridable, allowing a
prompt-injected call to retarget another tenant. Both are now reserved and
filtered case-insensitively, with the account id applied after merging.
Adds regression tests for the case variants and the account override.

Also removes the dead epilogue in StackOneTool.execute: two discarded
datetime.now() calls and a metadata dict built in a finally block and
thrown away, left over from the removed implicit-feedback path. Dropping
TYPE_CHECKING means to_pydantic_ai_tool returns Any, since pydantic-ai is
optional and must not be imported at module level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor!: restructure package to types/tools/toolset, pass schemas through

Reduce stackone_ai to three modules built around one property: the toolset is
the served catalog. The schema listed to a model is the schema the MCP server
sent, and the request sent to /actions/rpc matches it.

- types.py: ToolParameters, ExecuteConfig, ParameterLocation, error hierarchy,
  shared aliases, DEFAULT_BASE_URL
- tools.py: StackOneTool, Tools, StackOneRpcTool, the MCP listing client
- toolset.py: StackOneToolSet

BREAKING: to_openai_function no longer filters the schema. It copied only
type/description/enum, silently discarding format, pattern, default,
minimum/maximum, oneOf/anyOf and nested required, so a model could not generate
valid arguments for any constrained field. The served schema now passes through
verbatim; only the SDK's internal `nullable` marker is stripped, becoming the
JSON Schema `required` list. This is what the conformance suite's
--strict-schema gate checks.

BREAKING: client-side search is removed — semantic_search, local_search, the
BM25/TF-IDF index, SearchConfig/SearchMode, the tool_search/tool_execute meta
tools and mode="search_and_execute". It is absent from the conformance contract
(the mock serves no /actions/search) and the contract docs flag client-side
search as something that should move server-side. Frees bm25s and numpy.

BREAKING: removes stackone_ai.integrations. LangGraph's own ToolNode and
bind_tools cover it; examples/langgraph_integration.py already used those
directly.

Replaces the vendored stackone-ai-node submodule with the one file the tests
actually used: mocks/mcp-server.ts, 275 lines with npm-only imports. Drops 764K
and 102 files, and the mock fixture now fails rather than skips when Node
dependencies are missing — a silent skip had let all 19 integration tests vanish
while CI stayed green.

Docs corrected against the code: stackone_ai/oas/, tests/snapshots/,
_process_response(), include_tools=, strict ty config, the context-window
warning, basic_usage/ and integrations/ example directories, and README's
configure_implicit_feedback section all described things that do not exist.

Also drops typing-extensions and pytest-snapshot (no references), the bin/**.py
ruff ignore (that directory has never existed), the T201/T203 codes (flake8-print
rules absent from `select`), and the redundant asyncio marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: add validate.sh covering examples, conformance and consumer smoke tests

One entry point for everything that consumes the SDK: example integrity, the
sdk-conformance wire contract (with --strict-schema), and the SDK / Pydantic AI /
Google ADK smoke suites.

Skips are never counted as passes. A section that cannot run says why and is
listed in the summary, so an absent sibling repo or missing credentials can never
read as green.

Two traps found while writing it, both the silent-green failure this repo keeps
hitting:

- Piping `ty` into grep is scored by `set -o pipefail`, which returns ty's
  non-zero exit even when grep matched — a successful detection read as "no
  match" and the check passed. Output is now captured before grepping.
- Grepping for `stackone_ai` matched ty's echoed source context, so every
  example's own import tripped it and a clean tree reported a failure. It now
  matches the diagnostic line itself.

Verified in both directions: injecting a reference to a removed symbol fails the
check, and a clean tree passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: enforce the conformance wire contract on every push

Adds a conformance job running `pnpm test:python -- --strict-schema`, which
fails on any schema keyword the SDK drops between what the server serves and
what the model is shown. Locally this is the `make`-adjacent equivalent of
scripts/validate.sh conformance.

StackOneHQ/sdk-conformance is private, so the default GITHUB_TOKEN cannot check
it out. The job requires a CONFORMANCE_REPO_TOKEN secret (PAT or App token with
read access) and fails with an explicit message when it is absent, rather than
skipping — a contract check that skips reads as green while enforcing nothing.

Also adds mcp to the dev dependency group. fetch_tools() hard-requires the mcp
client and no test guards its import, so a bare `uv sync` produced a venv where
the suite errored instead of skipping. Found by reproducing the job's layout from
a clean checkout: it failed with "MCP dependencies are required for fetch_tools"
where the local run passed only because .venv had been synced with --all-extras.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: recommend uv over pip, and fail examples that load zero tools

The SDK told users to `pip install "stackone-ai[mcp]"` in its own ImportError,
and the README and pydantic-ai example did the same, while CLAUDE.md states uv
is used for all dependency management. Switched to `uv add`.

Also strengthens scripts/validate.sh. Running an example live and checking its
exit code proves almost nothing: every integration example filters on
`workday_*`, and against a linked account with no Workday connector they load
zero tools, call the model anyway, and exit 0. Verified against the live API —
all five reported "Loaded 0 tools" while the validator called it a pass. The
live-run check now fails when an example loads nothing and prints the line that
gave it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: add a self-documenting make help target

`make` with no arguments ran `install` because it was the first target. It now
prints the target list instead, which is both safer and more discoverable.

Help is generated from the `##` comments above each target, so it cannot drift
from the Makefile the way a hand-maintained list would.

Also surfaces three things that have caught people out: `publish` pushes to PyPI
for real, `gitleaks` needs a binary nothing installs locally, and
`test-examples` only imports each example — the `__main__` guard means no
example body ever runs, so it goes green without exercising anything.

Adds a `validate` target wrapping scripts/validate.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: collapse make into one fix target, run tools directly in CI

`lint` and `format` were the same ruff invocation in opposite modes, which read
as two concerns rather than check-vs-fix. CI is the only caller that needs the
read-only mode, and it now runs ruff, ty and pytest directly instead of going
through make — so it verifies what was committed and cannot mutate the tree to
make itself pass.

`format` now fixes lint, formats, and type checks: one command before
committing.

Removes `lint` (CI runs the ruff commands itself), `ty` (folded into `format`),
`test-tools` (`test` already collects tests/ — verified, both give 174) and
`run-example` (`uv run examples/<file>` directly).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: remove the publish target

Publishing is release-please's job: merge to main, merge the release PR, and
release.yaml runs uv build + uv publish with PYPI_API_TOKEN. A local target
meant a developer machine could push to PyPI out of band, bypassing the version
bump, changelog and tag.

release.yaml already invokes uv directly, so nothing depended on the target.
`build` stays for checking the artifact locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: drop the gitleaks target, leaving secret scanning to CI

The target invoked a bare `gitleaks` binary that nothing installed once the nix
shell went, so it failed with command-not-found on every machine. CI installs
the CLI and runs the same command itself, with fetch-depth 0 so it scans the
full history rather than a shallow slice — which a local run would not have done
anyway.

.gitleaks.toml stays; it is the config CI passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: default EXTRAS to --all-extras so `make install` stops stripping the venv

`uv sync` makes the environment match the requested set exactly, so a bare
`uv sync` uninstalls every optional dependency. Measured: `make install` took
the venv from 207 packages to 56, removing openai, crewai, langgraph and
pydantic-ai — leaving the examples unable to import and the mcp-backed tests
unable to run.

The obvious command should not break the environment. EXTRAS now defaults to
--all-extras; EXTRAS="" still gets the minimal set, and the help says plainly
that it removes things.

`uv run` was not the culprit — the package count is unchanged across `make test`.

CI is unaffected: it runs `uv sync --all-extras --locked` directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor!: put mcp in core and langchain behind an extra

The dependency tiers were inverted. Every user installed langchain-core — and
transitively langsmith — for one of four adapters, while nobody could fetch a
tool out of the box because the MCP client was optional.

Measured on a clean venv:

  pydantic + httpx                 11 packages
  + langchain-core  (old core)     33 packages, and fetch_tools() still fails
  + mcp             (new core)     29 packages, and everything works

BREAKING: `stackone-ai[mcp]` no longer exists — mcp is a core dependency, since
fetch_tools() is the only route to a tool and it talks MCP. `to_langchain()` now
requires `stackone-ai[langchain]` and raises a clear ImportError without it,
mirroring how to_pydantic_ai_tool() already behaved.

Both misplacements were historical: mcp was genuinely optional when the SDK
shipped bundled OpenAPI specs and get_tools() worked offline, but d50d5fb
deleted that path and the extras never followed.

Verified on a bare install (30 packages, no langchain_core, no langsmith):
fetch_tools returns 22 live tools, to_openai works, to_langchain raises the
install hint. Conformance still passes with --strict-schema.

CLAUDE.md now records the rule so it does not drift back: core is what the SDK
needs to function, extras are per-framework adapters with lazy imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor!: collapse dependency-groups into extras

Two tiers: core and extras. The PEP 735 dev group was a third mechanism for
declaring dependencies, and one tier fewer is easier to reason about than the
distinction it bought.

Also drops the `stackone-ai` self-reference from dev, which only ever meant
"install the project", something uv does anyway.

Trade-off, stated plainly: a dev group is never published, an extra is. The
wheel now advertises `Provides-Extra: dev`, so `uv add 'stackone-ai[dev]'`
installs pytest, ruff and ty for a consumer. That is the cost of the simpler
model, not an oversight.

`make install` defaults to --all-extras, so the tooling still arrives with one
command, and CI's `uv sync --all-extras --locked` is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: split install into `install` and `extras`

`make install` now syncs core only and `make extras` syncs everything, so
`make install extras` reads as the sentence it is. Replaces the EXTRAS variable,
which required knowing that a bare `make install` would silently uninstall
openai, crewai and the test tooling.

Measured: `make install` gives 30 packages (mcp yes, pytest no, openai no);
`make install extras` gives 207. The help says outright that `install` removes
anything outside the core set, since that is what `uv sync` does and it is not
obvious.

Also fixes the help parser: it ran `sed 's/:.*//'` over every line, so any
description containing a colon was truncated — "Install everything: adapters,
examples and dev tooling" rendered as "Install everything". Targets and
descriptions are now parsed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: make extras a flag on install rather than its own target

`make install` and `make install extras=1`, one target instead of two. Any
non-empty value works, so extras=1 and extras=True both do.

Reverts the separate `extras` target added in f2acc61 — it was a second command
in the list for something that is a property of how you install, not a different
thing to run.

  make install            30 packages, no pytest
  make install extras=1   207 packages, pytest present

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: drop the coverage target

Nothing called it. CI runs the command inline, and the three report formats
exist for CI alone: coverage/coverage.json feeds the badge action and
coverage/html is uploaded to Pages. Locally `uv run pytest --cov` is the useful
form and needs no target.

Also trims coverage exclude_lines to the one pattern that still matches
anything. `if TYPE_CHECKING:` went when the adapter imports became lazy, and
there is no __repr__ or NotImplementedError in the package — verified 0 hits
each.

Coverage is unchanged at 96%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: load .env in validate.sh, or say it is missing

The live example run was skipping for want of credentials that were sitting in
.env two directories up from the check. validate.sh now sources .env when
present and prints which path it used; when absent it says so, so a skip is
always attributable rather than mysterious.

Only that one check needs credentials — conformance and the smoke suites drive a
mock API on 127.0.0.1 with a dummy key (conformance-key / smoke-key), which is
what lets the oracle assert that the schema an SDK listed matches the request it
sent.

With credentials loaded the examples check now fails rather than skipping, which
is correct: all five load 0 tools because they filter workday_* against accounts
that have no Workday connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: keep validate off the live API entirely

validate now runs only against the sdk-conformance mock on 127.0.0.1 with a
dummy key, so it is deterministic, needs no credentials, and cannot spend real
API calls or OpenAI tokens.

Removes the live example run and the .env loading added a commit earlier. The
live run was the one section that called StackOne, and it was also the least
informative: it drove each example against whatever account happened to be
linked, so its result depended on the connectors on that key rather than on
anything in this repo.

The static example checks stay — they catch a removed or renamed SDK symbol,
which is the regression the restructure could actually cause.

  6 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: drop examples/test_examples.py

It asserted nothing. `test_run_example` loaded each example as module name
"example", so every `if __name__ == "__main__":` guard stayed shut and no
example body ran; the `if spec and spec.loader:` guard meant a falsy spec passed
silently. All it proved was that the file parsed and its top-level imports
resolved.

scripts/validate.sh already does that and more — it also runs ty over the
function bodies, which catches a removed or renamed SDK symbol that importing
cannot see.

Removes the `test-examples` make target with it, and sets testpaths = ["tests"]
so pytest stops globbing examples/ for test files. Test count drops 174 → 167:
the seven that go were the vacuous ones.

Also teaches validate.sh to tell "this consumer is broken" apart from "this
consumer pins a version that is not on PyPI yet" — the ADK plugin now requires
stackone-ai 3.x, which cannot resolve until the release publishes, and that says
nothing about this SDK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: surface MCP failures, discover accounts, and support search_execute

The SDK could not work from an API key alone and could not say why when it
failed. Found by pointing it at a real key for the first time; none of it was
visible to the test suite, which is fixed in the same commit.

Behaviour:
- fetch_tools() discovers accounts from GET /accounts when none is supplied.
  /mcp requires x-account-id in every mode, so a bare api_key never worked.
  Accounts not in `active` are skipped, and a key with none usable now says
  which providers are broken instead of failing opaquely.
- tool_mode="search_execute" lists two meta tools per connector rather than one
  tool per action — 3 tools instead of 139 on a single linked account.
- Those meta tools have no /actions/rpc action behind them and 404 there, so
  StackOneMcpTool executes them over MCP tools/call.
- A tools/call failure arrives as an ordinary response with isError set. It was
  returned to the caller as a success; it now raises StackOneAPIError.

Errors:
- The MCP client raises inside a TaskGroup, so every failure read as
  "unhandled errors in a TaskGroup (1 sub-exception)". Failures are now unwrapped
  to the leaf and reported as StackOneAPIError carrying .status_code, so a caller
  can branch on 412 rather than parsing a message.
- A failed GET /accounts no longer falls into the catch-all that discarded status.
- One unusable account no longer costs the caller every healthy account's tools.

Schema:
- _normalize_schema_properties used setdefault, so a served `nullable` (OpenAPI
  3.0 style, meaning "accepts null") suppressed the required marker and the model
  omitted the field.
- The envelope split used setdefault, making precedence depend on caller dict
  order, and put a scalar-valued reserved key into the body — the body-field
  smuggling invariant 4 exists to prevent. Now two deterministic passes.

Tests — the mock was the reason none of this was caught. It answered
`c.req.header('x-account-id') ?? 'default'`, inventing an account and making it
more permissive than production, so an SDK that never sent the header still got
a catalog. Two tests had grown around that: one asserted an api-key-only toolset
returns tools, another asserted "x-account-id" not in headers. The mock now
returns the real 400, serves /accounts, and new tests cover account discovery,
inactive-account filtering, listing failures by status code, tool_mode routing,
and isError. A guard test asserts the mock still rejects a missing header, so
these cannot quietly go hollow again.

  178 tests pass; conformance and the SDK/Pydantic AI smoke suites pass;
  verified against the live API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(toolset): discover accounts and expose search/execute

An API key alone was not enough to use the SDK: the /mcp endpoint requires
x-account-id, so every caller had to know an account id up front. The toolset
now fetches GET /accounts, keeps the active ones, and fans out over them.

- fetch_accounts() exposes the account list, raising StackOneAPIError on failure
- search()/execute() drive the search_execute meta tools directly, so a caller
  can find and run an action without ever naming an account
- README Quick Start rewritten around the three flows: search+execute with no
  account filtering, filtering tools by account_id, and executing with args

The mock served x-account-id as `?? 'default'`, inventing an account that the
real endpoint rejects — which is why the tests stayed green while the SDK was
broken. It now requires the header (or query param) and 400s without it, and
serves the search_execute meta tools so tools/call is exercised for the first
time. Meta tools register with zod shapes because registerTool validates call
arguments with zod, not with the raw JSON Schema used for listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(toolset): one argument contract for execute(), tolerate failing connectors

execute() routed through the catalog when the action_id happened to be listed
there and through the connector's meta tool otherwise — so the same id took flat
`query_pageSize` keys or the nested `{"query": {...}}` envelope depending on
something the caller cannot see. It now always uses the meta tool, which is the
envelope every action's `example_request` shows; the flat form stays on
fetch_tools() tools, whose own served schema names the keys. It also no longer
fetches the whole catalog on every call.

search() raised on the first connector that errored, so one bad connector hid
every other connector's results. It now collects failures and raises only when
nothing succeeded — the rule fetch_tools() already applied to listing.

The mock's _execute_action took flat args, which the real endpoint does not; it
now takes the nested envelope and returns an example_request, and a test pins
that contract. README examples name an action id rather than indexing search
results.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(readme): fix the file-download example's argument key

`download.execute({"id": "file-id"})` routed the id into the RPC envelope's
body, not its path — tools are listed flat-prefixed, and an unprefixed key falls
through to the body, so the call downloaded nothing. `path_id` is the key the
served schema actually names.

The download behaviour itself is current: /actions/rpc serves a download action's
body as raw binary, which StackOneRpcTool.execute still routes through the
Content-Type branch (#190).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(readme): follow the trimmed structure

Quick Start is the one recommended path; integrations, then filtering as a
reference list, then examples and development.

- Features claimed a `"!*_delete_*"` exclusion syntax that does not exist —
  `_filter_by_action` is plain fnmatch, so that pattern still matches a delete
  tool. Replaced the list with what the SDK actually does.
- Adapter extras moved into Installation as a line: nothing else mentioned them,
  and every integration example below needs one.
- Advanced Filtering names three arguments with no signature in sight; one line
  says whose they are.
- Collapsed the blank runs left by the trim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close argument-hijack, header-injection and cache-poisoning defects

Found by an audit sweep that drove the live API rather than a mock. The three
that matter most were all exploitable through model-supplied tool arguments,
which are attacker-controlled whenever the model can be prompt-injected.

- execute() spread `arguments` over the pinned action_id, so a model-supplied
  "action_id" silently replaced the action the caller had fixed. action_id now
  goes last. Verified live: the override no longer takes.
- The reserved-header guard compared `str(key).lower()` with no strip, so
  " authorization" and "AUTHORIZATION\t" sailed past it, and CR/LF in a name or
  value was never rejected. It now normalises, casefolds, and enforces the
  RFC 7230 grammar — and it lives on the base class, because the MCP path, which
  is what search()/execute() actually use, had no guard at all.
- filename_from_content_disposition returned "../../.ssh/authorized_keys"
  verbatim from an attacker-chosen Content-Disposition. Sanitised to a basename
  at the source, after the RFC 5987 percent-decode where a caller's own filter
  would have been bypassed.

Correctness, same sweep:

- _tool_mode was mutated on self and restored in a finally, so a concurrent
  fetch_tools() could cache search_execute meta tools under the individual-mode
  key, permanently. The mode is a parameter now.
- The cache stored the Tools wrapper, whose tools are mutable, so one caller's
  set_account_id() rescoped every later caller's. It caches the listing and
  builds fresh tools per call. api_key and base_url join the key; providers and
  actions leave it (filtering is local and must not refetch); a partial catalog
  is no longer cached.
- The envelope split was caller-order-dependent: {"body_foo":1,"foo":2} and its
  reverse produced different wire bodies, and a third disagreed with the Node
  SDK, breaking the byte-equality the conformance suite asserts. Precedence is
  now a property of the kind of key.
- The split is schema-aware: a body field named path_to_file was being stolen
  into the path bucket. An empty schema means "no schema", not "nothing is
  declared" — the other way round loses every path parameter.
- search() caught only StackOneError, but transport failures arrive as
  ToolsetLoadError, so one unreachable connector aborted the whole search. It
  now catches what fetch_tools() catches, fans out in parallel rather than
  serially, ranks globally by similarity_score, and validates top_k locally
  instead of spending a round trip per connector to be told 1..50.
- Provider matching split on the first underscore, so browser_linkedin tools
  reported provider "browser" and asking for browser_linkedin returned nothing.
  Matched as a full prefix now, longest connector wins.
- fetch_tools no longer re-wraps StackOneAPIError, which threw away the status.
- fetch_accounts rejects a non-list body instead of list(dict) yielding keys.
- A non-list `required` no longer marks every property optional.
- A bodyless 2xx is not a file download; a malformed JSON response no longer
  reports itself as "invalid JSON in arguments".
- A scalar under an envelope key raises instead of vanishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: rewrite the README and examples around what actually runs

Every code block and every example is now executed live against a real key
before being committed. Several were not true before.

README:
- The Quick Start's sample search() output was invented. The real hits carry
  similarity_score and input_schema, and example_request is {"action_id": ...} —
  a template to edit, not a runnable call.
- Its execute() arguments, {"query": {"page_size": 25}}, matched no action's
  schema. The server drops unmatched arguments without an error, so the first
  line a reader copies returned 50 rows while appearing to ask for 25. Replaced
  with the schema-correct call and a warning that this failure mode is silent.
- Added the section the README most needed: the two calling surfaces take
  different argument shapes (nested vs flat-prefixed) and return different
  envelopes, and mixing them fails quietly.
- Added Accounts and Errors sections. The word "error" did not previously appear
  in the document.
- Integration examples rewritten: they all filtered on workday_*, so a reader
  without Workday silently got zero tools and an agent with an empty toolbox.
  They now discover whatever the key reaches. Added the missing OpenAI block.
- Dropped CrewAI. langchain_core.BaseTool is not crewai.tools.BaseTool, so the
  "works natively, seamless" claim raises a ValidationError on contact — verified
  against crewai 1.6.1.
- Removed StackOneToolSet(search={"method": "auto"}); no such parameter exists.
- Advanced Filtering gets worked examples with real counts, and documents that a
  leading "!" is not exclusion syntax: actions=["*", "!*_delete_*"] returns every
  delete tool, the opposite of what it reads as.

examples/:
- All six exited 0 while loading zero tools, so a green run proved nothing. They
  now discover actions and exit non-zero on an empty catalog.
- Added search_and_execute.py covering the recommended flow, which had no
  example at all.
- Added error handling, which every one of them lacked despite CLAUDE.md
  requiring it.
- Deleted crewai_integration.py; it only "passed" by loading no tools.

Also: an MCP-path failure reported status_code 0 while the payload carried the
real status, so the documented `except StackOneAPIError` could not branch on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): make the examples gate actually gate, and unblock pydantic-ai 2.x

The validate.sh check that guards examples across a breaking restructure was
vacuous. It grepped ty's output for a diagnostic naming `stackone_ai`, but the
case it exists to catch — `Object of type "Tools" has no attribute "gone"` —
never names the package, and a trailing `|| true` discarded ty's exit code, so
every other type error in examples/ was ignored too. It now scores the exit code;
verified by planting a call to a removed method and watching it fail.

That immediately found two real problems: a deprecated `create_react_agent`
import, and the fact that CI never type-checked examples at all. Added the step,
and corrected CLAUDE.md, which asserted a CI gate that did not exist.

- to_langchain() now raises ToolException rather than StackOneError. LangChain's
  handle_tool_error only catches ToolException, so a rejected call killed the
  graph instead of reaching the agent — with this, the langgraph example
  recovers from a bad argument guess and retries, which it could not do before.
- pydantic-ai's <2.0.0 cap is lifted. The smoke suite has been passing against
  2.43.0 all along, so the cap only blocked consumers, and shipping it in a major
  would bake it in for a release cycle.
- crewai leaves the examples extra (its integration is gone); langchain joins it,
  since the langgraph example needs it.
- The MCP ImportError told users to install a `[mcp]` extra that no longer
  exists — mcp has been core since the dependency retiering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: make the mocks refuse what the real API refuses

The SDK shipped unusable while the suite was green because every test double was
written to make the client's existing requests succeed, rather than to model what
the server demands. The missing x-account-id on /mcp has been fixed; these were
its surviving siblings.

- /actions/rpc accepted any request whose Authorization merely began with
  "Basic " and never looked at x-account-id at all, so an unscoped execution
  request — the same defect one endpoint over — could not be caught. It now 400s.
  Proven by mutation: dropping the header from _prepare_headers now fails three
  integration tests, where before it failed none.
- /mcp fell back to `accountTools.default` for an unknown account, serving a full
  catalog for an account that does not exist. That is the same `?? 'default'`
  permissiveness as the original bug, one line further down, and it made any
  wrong, stale or mangled account id invisible. It now 404s.
- test_execute_without_account_id asserted only that the envelope omitted
  x-account-id — the same shape as the assertion that pinned the shipped bug, a
  green test recording what the client happened to send. It now also checks the
  HTTP header, which is the one the API reads.
- Added TestServerRefusals covering both refusals directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: schema-driven header allowlist, faithful LangChain schemas, actionable errors

A second audit sweep drove the live API and found that several of this morning's
fixes were themselves wrong, plus one adapter that was broken for every call.

Security — the header guard becomes an allowlist:

- It was a two-name denylist, so Proxy-Authorization, x-stackone-account-id,
  Cookie, X-Api-Key and every other header reached the envelope. A denylist has
  to enumerate every synonym of "credential" in every provider's vocabulary.
  The allowlist is the served schema itself, so it needs no maintenance: zero of
  the 139 served actions declare a headers_* property, and the RPC server was
  measured to ignore the envelope's headers object outright.
- `$` also matches before a trailing newline, so `match` let "value\n" through —
  the one character class the CR/LF guard exists to reject. Now fullmatch.
- RFC 7230 permits obs-text, so legitimate non-ASCII values were being dropped.
- Filenames: Windows drive-relative paths (C:evil.exe), control characters and
  Unicode bidi overrides (U+202E renders "gnp.exe" as "exe.png") all survived the
  traversal fix. Capped at 255 bytes, and the filename regexes now anchor to a
  parameter boundary so a decoy `notfilename=` cannot win.

LangChain was broken for every tool, live:

- The adapter rebuilt an args schema from each property's top-level type, which
  discarded every nested object's fields, enums, bounds, item types and unions —
  the model was told "pass an object" with no field names. It now hands over the
  served JSON Schema, making this surface byte-equivalent to to_openai_function.
- pydantic materialised every optional as None and BaseTool forwarded them all,
  and the API reads an explicit null as "required field missing", so 10/10 list
  tools 400'd through the adapter while succeeding directly.
- ToolException carried str(exc) — httpx boilerplate linking to MDN. The field
  that is actually wrong is in response_body, so agents retried blind.

Errors and correctness:

- str(StackOneAPIError) now leads with the server's own message. This is the
  error every bad tool call produces and it pointed users at MDN's generic 400
  page while "The required field 'path.id' is missing" sat unread.
- fetch_accounts had no error handling at all — a dead host leaked httpx's own
  exception type out of the SDK.
- RPC and MCP tools parsed JSON before the base class's handler, so the
  documented ValueError never fired for any tool a user can actually obtain.
- _connector_of split on the last underscore, but nanoid's alphabet includes "_",
  so every action on such an account was unroutable. Ambiguous matches now warn.
- search() crashed with a bare TypeError if any connector returned a non-numeric
  similarity_score; an ambiguous connector picked one account silently.
- The "is this schema flat-prefixed" test used any(), which the very key it
  protects satisfies. ALL, not any: one bare name disproves it.
- A declared property named `query` was rejected as a malformed envelope container.
- A zero-byte body with a download content type is a real empty file, not a
  bodyless JSON success.
- Status extraction missed `statusCode`, the casing the API actually emits.
- action_id and set_accounts are type-checked like their siblings already were.

Tests: the MCP header guard — the security fix on the path search()/execute()
actually use — had zero coverage and could be deleted with everything green. It
now has nine cases. Also pinned: provider prefix matching, top_k validation,
the non-list /accounts body, and non-dict arguments. One of my own tests was
vacuous: it passed `set() or None`, which the parser folds to None, so the
branch it claimed to cover never ran.

validate.sh now points ADK at the real plugin path, so its skip reports the true
reason (the plugin pins a version not yet on PyPI) instead of a stale path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): warn when two accounts serve the same tool name

With two accounts on one provider the name map silently kept the last one, so
get_tool() routed every call to whichever account happened to list last — an
action running against an account the caller never chose. Nothing downstream
surfaces it: OpenAI accepts duplicate function names without complaint, so the
model just sees the tool twice and pays for it twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(toolset): stop clear_catalog_cache() being undone, and isolate cached state

Found by hammering one shared toolset from 28 threads against the live API.

- A listing in flight when clear_catalog_cache() fired wrote its pre-clear
  catalog back afterwards, so the stale catalog the clear existed to drop was
  served for the life of the process. Listings now record the cache generation
  they started under and refuse to write back if it has moved.
- Tools are rebuilt per call, but the rebuild copied only the top-level property
  dicts: nested schema objects, and the MCP headers dict shared by every tuple in
  a cached listing, were still aliased across callers. One caller mutating a
  nested schema changed what every later caller's model was shown.

Verified dead under the same load: the _tool_mode race and cross-caller
set_account_id() leakage, both fixed earlier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate PyPI publish on tests, unify execute() return shape, correct the docs

Release and CI, from an adversarial workflow audit:

- Publishing ran in the release-please job with no dependency on CI at all. CI is
  a separate workflow, so a red main still merged the release PR and shipped to
  PyPI. Publish is now its own job behind a `pypi` environment that re-runs the
  suite against the exact commit being published.
- Release runs had no concurrency group, so two quick merges could both reach
  `uv publish`. They now queue rather than cancel.
- CI ran twice per PR (push on every branch plus pull_request, in different
  concurrency groups, testing different commits). Push is now main-only.
- `curl | tar` without pipefail meant a failed download produced an empty tar,
  exited 0, and failed two steps later as "gitleaks: command not found".
- The conformance token was persisted base64-encoded into .git/config, which log
  masking does not match, beside third-party code run by pnpm install.
- Added a `ci-ok` aggregate check to require, since the matrix names checks per
  Python version and adding one silently left it unrequired.
- The coverage job held pages:write and id-token:write it never used.

SDK:

- toolset.execute() returned {"isError", "result"}, but an isError response has
  already raised, so the flag could only ever be False and the wrapper just made
  the two calling surfaces return different shapes. It now returns the payload.
- The Pydantic AI adapter let StackOneError escape the agent loop, ending the run.
  Tool.from_schema does no argument validation, so every wrong guess reached the
  API. It now raises ModelRetry with the server's explanation — verified live: the
  agent survives a failing call and reports the actual reason.

validate.sh printed a bare PASS when four of five sections had been skipped, and
discarded example import tracebacks; it now reports how many checks ran.

Docs: every README code block re-executed live. Fixed an OpenAI block that 400s on
the round trip (it omitted the assistant turn before the tool results), a LangGraph
install that cannot import create_agent, the claim that mixing surfaces fails
silently in both directions (only one), `[]` meaning "no filter" (it means unset),
"all four derive from" (three, from two unrelated bases), and live tool counts that
were true only for one API key. CLAUDE.md now describes the two surfaces, the
silent argument drop, the header allowlist, the fullmatch trap, and why test doubles
must model the server's refusals — the things agents here kept getting wrong.

Examples: removed a prompt sentence duplicated in all four by an earlier edit, and
which had only been masking the LangChain null-argument bug. Without it every
example passes three of three live runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: honour timeout on the MCP path, one root error, and pin every fix from this sweep

timeout= did nothing for listing, search() or execute(). The MCP client's own
defaults are a 30s connect and a 300s SSE read, and the SDK passed neither, so
StackOneToolSet(timeout=2) against a host that accepts and never answers hung for
over five minutes. The execution path honoured it; the MCP path — which every
listing and the whole search()/execute() flow use — did not. The timeout now
reaches every transport leg, and anyio.fail_after bounds the exchange overall so a
slow handshake cannot evade it either. Measured: 300s+ -> 2.0s on all three.

ToolsetError now subclasses StackOneError, so `except StackOneError` catches
everything this SDK raises. They were unrelated siblings, which meant the obvious
catch-all silently missed ToolsetConfigError and ToolsetLoadError — the errors a
user is most likely to hit on their first call. Existing `except ToolsetError`
clauses are unaffected.

Tests: every fix made in this sweep was mutation-tested by reverting it and
running the suite. Three reverts survived, and each was a real gap:

- The fullmatch fix. The allowlist now drops undeclared headers before the
  grammar check, so the CR/LF tests never reached it — the check is only
  exercised for a declared header, which is exactly where it matters.
- Filename traversal had no test at all.
- The timeout test used the default thread, so a regression would have hung the
  suite for five minutes rather than failing. It now fails within 15s.

With those closed, reverting any of these fails the suite: the action_id hijack
fix, underscore account routing, longest-connector routing, global search ranking,
full-prefix provider matching, the cache-generation guard, deep schema copies, the
top_k guard, fullmatch, the header allowlist, LangChain's null-argument drop, the
MCP timeout, the single error root, and filename sanitising. Also pinned: that
adapter errors carry the server's reason to the model on both LangChain and
Pydantic AI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: contain encoding failures; trim the sdist; dependabot that can actually pass CI

- A lone surrogate — what a model emits when a token boundary splits an emoji —
  or a value JSON cannot encode (a set, bytes) escaped from deep inside httpx as a
  bare UnicodeEncodeError or TypeError, outside the SDK's exception contract. It
  is an argument problem, so it now raises ValueError naming the tool.
- The sdist shipped the whole dev tree: uv.lock, pnpm-lock.yaml, CLAUDE.md,
  .github/ and the TypeScript mocks. It is now the package, README, LICENSE,
  CHANGELOG and pyproject. The py.typed force-include was redundant — hatchling
  ships it from `packages`, verified by building without it.
- Dependabot used the `pip` ecosystem, which bumps pyproject.toml without
  regenerating uv.lock, so every Python PR it opened failed `uv sync --locked`.
  Switched to `uv`, and added `npm` for the mock server, ignoring the MCP SDK,
  which is pinned exactly because newer versions reject the mock's raw schemas.
- `make install` now uses --locked, as CI does, so a local sync cannot silently
  re-lock and drift from what CI verified. The help parser no longer lets an
  undocumented target inherit the previous target's description.
- Deleted dead config: an `integration` pytest marker applied to no test, an
  unused fixture alias, and `langgraph` from the examples extra — nothing imports
  it, and `langchain`, where `create_agent` lives, depends on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(readme): trim to the Quick Start, integrations and filtering structure

Moves the two calling surfaces under Advanced Filtering and drops the Accounts,
Errors and input_schema callout sections, keeping the README to the recommended
path plus reference material.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(tools): execute_openai_tool_calls, so the OpenAI loop is not hand-written

The LangChain and Pydantic AI integrations run tool calls for you inside the
framework. With raw OpenAI every caller wrote the loop themselves — look up each
tool by name, execute it, catch failures, json.dumps the result, pair it with its
tool_call_id — and it is easy to get wrong. Ours was: the README version omitted
the assistant turn, so OpenAI returned a 400 on the round trip, and the example
carried its own hand-rolled error handling.

Tools.execute_openai_tool_calls(tool_calls) returns the `tool` messages ready to
send back. It pairs with to_openai(): that turns tools into what OpenAI accepts,
this turns what OpenAI returns back into messages for it.

- A failed call becomes an error message the model can read and retry from,
  rather than raising — what the other two adapters already do.
- A call to a tool not in the collection is reported the same way.
- A file download's raw bytes are base64-encoded; json.dumps crashed on them.
- Accepts the openai package's objects or plain dicts, so it needs no extra.

Verified live: the example passes three of three runs, and the README block's
messages are accepted by OpenAI on the follow-up call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pr-reviews): resolve Stu's schema, CI, and toolset feedback

* fix(ci): evaluate secret existence in a setup job to avoid workflow parse error

* style: run ruff format

* fix(toolset): prevent duplicate keyword argument error when spreading tool schema

* fix(ci): pin pnpm version for conformance setup

* fix(toolset): pop properties from rest dict to fix initialization

* docs(toolset): mirror multi-account warning in fetch_tools docstring

* fix(ci, tools): gate conformance skip on forks/dependabot, warn on bare params

* docs(changelog): document removal of connector and get_connectors

* fix(toolset, tools): bump langchain-core, handle non-JSON /accounts, narrow nullable strip

* fix(deps, ci): bump mcp floor to 1.24.3, pin conformance checkout to SHA, add tests for nullable and non-JSON accounts

* chore: revert manual CHANGELOG header in favor of squash BREAKING CHANGE footer

BREAKING CHANGE: removed StackOneTool.connector property and Tools.get_connectors().

* fix(deps): bump pytest to 9.1.1 to resolve CVE-2025-71176

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants