refactor!: MCP-only toolset with account discovery, search/execute, and hardened security - #199
willleeney wants to merge 49 commits into
Conversation
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>
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>
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>
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>
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>
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>
.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>
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>
…hrough 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>
…ke 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>
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>
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>
`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>
`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>
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>
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>
…ng 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>
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>
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>
`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>
`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>
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>
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>
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>
| run: | | ||
| curl -sSfL "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/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ | ||
| | tar -xz -C /usr/local/bin gitleaks |
There was a problem hiding this comment.
Binary, code or archive is pulled from a remote source without integrity verification - medium severity
A GitHub Actions Workflow was built using an artifact from a remote source without any integrity verification. If the remote artifact were silently replaced with a malicious version (for example, through a supply chain attack), the integrity and confidentiality of the environment in which the container is deployed could be compromised.
Show fix
Remediation: Validate the artifact against a trusted SHA-512 checksum in the CI/CD pipeline using sha512sum in check mode. Store the expected checksum in a file (e.g., artifact.sha512), then verify it with: sha512sum -c artifact.sha512. Enable strict error handling (for example, set -e in shell scripts) so the pipeline fails if verification fails or outputs errors.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
CVE-2025-71176 in pytest - medium severity
pytest through 9.0.2 on UNIX relies on directories with the /tmp/pytest-of-{user} name pattern, which allows local users to cause a denial of service or possibly gain privileges.
Details
Remediation Aikido suggests bumping this package to version 9.0.3 to resolve this issue
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
7 issues found across 70 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="tests/mocks/serve.ts">
<violation number="1" location="tests/mocks/serve.ts:20">
P3: The header comment still says the file "Imports createMcpApp from stackone-ai-node vendor submodule," but this refactor removed the vendor submodule (no vendor/ dir remains) and the import now resolves to ./mcp-server in the same directory. Update the comment so it doesn't mislead readers about where the mock app comes from.</violation>
</file>
<file name="tests/mocks/mcp-server.ts">
<violation number="1" location="tests/mocks/mcp-server.ts:35">
P3: The docstring example references a `createMcpHandler` export and a `./mocks/node` module that do not exist (the file exports `createMcpApp`, and only `mcp-server.ts`/`serve.ts` exist under tests/mocks). Update the example to use `createMcpApp` and the real import path so it doesn't mislead future test authors.</violation>
<violation number="2" location="tests/mocks/mcp-server.ts:81">
P2: When an MCP client calls any registered tool through `/mcp`, `params` is undefined because the SDK passes arguments directly. Use the direct arguments object for `structuredContent` so the mock can serve tool calls.</violation>
</file>
<file name="tests/conftest.py">
<violation number="1" location="tests/conftest.py:69">
P3: The check verifies only that `node_modules` exists, but the mock server actually requires `pnpm` on PATH: `serve.ts` runs through the shebang `#!/usr/bin/env -S pnpm exec tsx`. When `node_modules` was installed by another tool and `pnpm` is missing, the check passes, then the fixture burns the 30 s wait and raises a generic "failed to start" RuntimeError instead of the actionable failure this change is meant to provide. Check for the real prerequisite (`pnpm exec`/`tsx` availability) so the fail-fast diagnostic fires whenever the server cannot launch.</violation>
</file>
<file name="scripts/validate.sh">
<violation number="1" location="scripts/validate.sh:43">
P2: When `make validate` follows the documented core-only `make install`, the examples section reports missing optional framework packages as failures instead of the SKIP behavior promised by this script. Detect missing example dependencies and call `skip`, matching `test_examples.py`.</violation>
</file>
<file name=".github/workflows/ci.yaml">
<violation number="1" location=".github/workflows/ci.yaml:31">
P1: The gitleaks job cannot install its binary because the unprivileged runner cannot write to `/usr/local/bin`. Extract into a writable bin directory or run the tar extraction with `sudo`.</violation>
</file>
<file name="CLAUDE.md">
<violation number="1" location="CLAUDE.md:183">
P2: The documented pass-through contract is false for root schema keywords such as `additionalProperties`. Preserve the served root schema when building OpenAI parameters, or narrow this statement until the converter retains those constraints.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| - name: Install gitleaks | ||
| run: | | ||
| curl -sSfL "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/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ | ||
| | tar -xz -C /usr/local/bin gitleaks |
There was a problem hiding this comment.
P1: The gitleaks job cannot install its binary because the unprivileged runner cannot write to /usr/local/bin. Extract into a writable bin directory or run the tar extraction with sudo.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ci.yaml, line 31:
<comment>The gitleaks job cannot install its binary because the unprivileged runner cannot write to `/usr/local/bin`. Extract into a writable bin directory or run the tar extraction with `sudo`.</comment>
<file context>
@@ -24,11 +22,15 @@ jobs:
+ - name: Install gitleaks
+ run: |
+ curl -sSfL "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/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
+ | tar -xz -C /usr/local/bin gitleaks
+ env:
+ GITLEAKS_VERSION: 8.29.0
</file context>
| | tar -xz -C /usr/local/bin gitleaks | |
| + | sudo tar -xz -C /usr/local/bin gitleaks |
| exampleBamboohrTools, | ||
| mixedProviderTools, | ||
| } from "../../vendor/stackone-ai-node/mocks/mcp-server"; | ||
| } from "./mcp-server"; |
There was a problem hiding this comment.
P3: The header comment still says the file "Imports createMcpApp from stackone-ai-node vendor submodule," but this refactor removed the vendor submodule (no vendor/ dir remains) and the import now resolves to ./mcp-server in the same directory. Update the comment so it doesn't mislead readers about where the mock app comes from.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/mocks/serve.ts, line 20:
<comment>The header comment still says the file "Imports createMcpApp from stackone-ai-node vendor submodule," but this refactor removed the vendor submodule (no vendor/ dir remains) and the import now resolves to ./mcp-server in the same directory. Update the comment so it doesn't mislead readers about where the mock app comes from.</comment>
<file context>
@@ -16,9 +17,9 @@ import {
exampleBamboohrTools,
mixedProviderTools,
-} from "../../vendor/stackone-ai-node/mocks/mcp-server";
+} from "./mcp-server";
-const port = parseInt(process.env.PORT || Bun.argv[2] || "8787", 10);
</file context>
| * @example | ||
| * ```ts | ||
| * import { server } from './mocks/node'; | ||
| * import { createMcpHandler, defaultMcpTools, accountMcpTools } from './mocks/mcp-server'; |
There was a problem hiding this comment.
P3: The docstring example references a createMcpHandler export and a ./mocks/node module that do not exist (the file exports createMcpApp, and only mcp-server.ts/serve.ts exist under tests/mocks). Update the example to use createMcpApp and the real import path so it doesn't mislead future test authors.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/mocks/mcp-server.ts, line 35:
<comment>The docstring example references a `createMcpHandler` export and a `./mocks/node` module that do not exist (the file exports `createMcpApp`, and only `mcp-server.ts`/`serve.ts` exist under tests/mocks). Update the example to use `createMcpApp` and the real import path so it doesn't mislead future test authors.</comment>
<file context>
@@ -0,0 +1,275 @@
+ * @example
+ * ```ts
+ * import { server } from './mocks/node';
+ * import { createMcpHandler, defaultMcpTools, accountMcpTools } from './mocks/mcp-server';
+ *
+ * // In your test setup
</file context>
| if not (vendor_dir / "package.json").exists(): | ||
| pytest.skip("stackone-ai-node submodule not initialized. Run 'git submodule update --init'") | ||
| if not (project_root / "node_modules").is_dir(): | ||
| pytest.fail("Node dependencies missing for the MCP mock server. Run 'pnpm install'.") |
There was a problem hiding this comment.
P3: The check verifies only that node_modules exists, but the mock server actually requires pnpm on PATH: serve.ts runs through the shebang #!/usr/bin/env -S pnpm exec tsx. When node_modules was installed by another tool and pnpm is missing, the check passes, then the fixture burns the 30 s wait and raises a generic "failed to start" RuntimeError instead of the actionable failure this change is meant to provide. Check for the real prerequisite (pnpm exec/tsx availability) so the fail-fast diagnostic fires whenever the server cannot launch.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/conftest.py, line 69:
<comment>The check verifies only that `node_modules` exists, but the mock server actually requires `pnpm` on PATH: `serve.ts` runs through the shebang `#!/usr/bin/env -S pnpm exec tsx`. When `node_modules` was installed by another tool and `pnpm` is missing, the check passes, then the fixture burns the 30 s wait and raises a generic "failed to start" RuntimeError instead of the actionable failure this change is meant to provide. Check for the real prerequisite (`pnpm exec`/`tsx` availability) so the fail-fast diagnostic fires whenever the server cannot launch.</comment>
<file context>
@@ -58,13 +57,16 @@ def test_mcp_integration(mcp_mock_server):
- if not (vendor_dir / "package.json").exists():
- pytest.skip("stackone-ai-node submodule not initialized. Run 'git submodule update --init'")
+ if not (project_root / "node_modules").is_dir():
+ pytest.fail("Node dependencies missing for the MCP mock server. Run 'pnpm install'.")
# find port
</file context>
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Refactors the SDK around an MCP-served catalog, removes client-side search, and replaces the Nix toolchain with uv/make and conformance CI.
Changes:
- Consolidates runtime code into
types.py,tools.py, andtoolset.py. - Adds flat-prefixed RPC handling, schema tests, and case-insensitive header protections.
- Updates dependencies, examples, mocks, CI, and validation tooling.
File summaries
| File | Description |
|---|---|
| tests/test_toolset.py | Updated as part of this pull request. |
| tests/test_tool_calling.py | Updated as part of this pull request. |
| tests/test_tfidf_index.py | Updated as part of this pull request. |
| tests/test_models.py | Updated as part of this pull request. |
| tests/test_local_search.py | Updated as part of this pull request. |
| tests/test_integrations_pydantic_ai.py | Updated as part of this pull request. |
| tests/test_integrations_langgraph.py | Updated as part of this pull request. |
| tests/test_fetch_tools.py | Updated as part of this pull request. |
| tests/test_feedback.py | Updated as part of this pull request. |
| tests/test_agent_tools.py | Updated as part of this pull request. |
| tests/mocks/serve.ts | Updated as part of this pull request. |
| tests/mocks/mcp-server.ts | Updated as part of this pull request. |
| tests/conftest.py | Updated as part of this pull request. |
| stackone_ai/utils/tfidf_index.py | Updated as part of this pull request. |
| stackone_ai/utils/normalize.py | Updated as part of this pull request. |
| stackone_ai/utils/init.py | Updated as part of this pull request. |
| stackone_ai/types.py | Updated as part of this pull request. |
| stackone_ai/semantic_search.py | Updated as part of this pull request. |
| stackone_ai/models.py | Updated as part of this pull request. |
| stackone_ai/local_search.py | Updated as part of this pull request. |
| stackone_ai/integrations/langgraph.py | Updated as part of this pull request. |
| stackone_ai/integrations/init.py | Updated as part of this pull request. |
| stackone_ai/feedback/tool.py | Updated as part of this pull request. |
| stackone_ai/feedback/init.py | Updated as part of this pull request. |
| stackone_ai/constants.py | Updated as part of this pull request. |
| stackone_ai/init.py | Updated as part of this pull request. |
| scripts/validate.sh | Updated as part of this pull request. |
| scripts/benchmark_search.py | Updated as part of this pull request. |
| README.md | Updated as part of this pull request. |
| pyproject.toml | Updated as part of this pull request. |
| package.json | Updated as part of this pull request. |
| Makefile | Updated as part of this pull request. |
| justfile | Updated as part of this pull request. |
| flake.nix | Updated as part of this pull request. |
| flake.lock | Updated as part of this pull request. |
| examples/test_examples.py | Updated as part of this pull request. |
| examples/search_tools.py | Updated as part of this pull request. |
| examples/pydantic_ai_integration.py | Updated as part of this pull request. |
| CLAUDE.md | Updated as part of this pull request. |
| .mcp.json | Updated as part of this pull request. |
| .gitmodules | Updated as part of this pull request. |
| .gitignore | Updated as part of this pull request. |
| .github/workflows/release.yaml | Updated as part of this pull request. |
| .github/workflows/nix-flake.yaml | Updated as part of this pull request. |
| .github/workflows/nix-flake-update.yaml | Updated as part of this pull request. |
| .github/workflows/ci.yaml | Updated as part of this pull request. |
| .github/actions/setup-nix/action.yaml | Updated as part of this pull request. |
| .envrc | Updated as part of this pull request. |
| .claude/rules/uv-scripts.md | Updated as part of this pull request. |
| .claude/rules/release-please-standards.md | Updated as part of this pull request. |
| .claude/rules/package-installation.md | Updated as part of this pull request. |
| .claude/rules/no-relative-imports.md | Updated as part of this pull request. |
| .claude/rules/nix-workflow.md | Updated as part of this pull request. |
| .claude/rules/git-workflow.md | Updated as part of this pull request. |
| .claude/rules/examples-standards.md | Updated as part of this pull request. |
| .claude/rules/development-workflow.md | Updated as part of this pull request. |
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
tests/mocks/mcp-server.ts:85
registerToolinvokes its callback with the tool-arguments object as the first parameter, not{ params: ... }. With this destructuring, a call containing{foo: ...}leavesparamsundefined and the mock returns emptystructuredContent, so execution through this server does not reflect the submitted arguments.
tests/mocks/serve.ts:20- The new local
./mcp-serverimport leaves the header comment claiming this file imports from thestackone-ai-nodevendor submodule. Update that comment to match the source actually used by the test server, otherwise the mock setup documentation is misleading.
- Files reviewed: 65/70 changed files
- Comments generated: 9
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| - name: Install gitleaks | ||
| run: | | ||
| curl -sSfL "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/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ | ||
| | tar -xz -C /usr/local/bin gitleaks |
| @@ -1260,11 +143,11 @@ def fetch_tools( | |||
| if cached is not None: | |||
| return cached | |||
| schema = tool_def.input_schema or {} | ||
| parameters = ToolParameters( | ||
| type=str(schema.get("type") or "object"), | ||
| properties=self._normalize_schema_properties(schema), | ||
| ) |
| @@ -29,7 +29,6 @@ def get_example_files() -> list[str]: | |||
| "crewai_integration.py": ["crewai", "mcp"], | |||
| [project.optional-dependencies] | ||
| mcp = ["mcp>=1.3.0,<2.0.0"] | ||
| langchain = ["langchain-core>=0.1.0"] | ||
| pydantic-ai = ["pydantic-ai-slim>=1.83.0,<2.0.0"] |
| if isinstance(details, dict): | ||
| type_str = details.get("type", "string") | ||
| is_nullable = details.get("nullable", False) | ||
| if type_str == "number": | ||
| python_type = float |
| # For agent-driven discovery, enable search on the constructor: | ||
| # toolset = StackOneToolSet(search={"method": "auto"}) |
| except ImportError as exc: # pragma: no cover - depends on optional extra | ||
| raise ToolsetConfigError( | ||
| "MCP dependencies are required for fetch_tools. Install with 'uv add \"stackone-ai[mcp]\"'." | ||
| ) from exc |
…onable 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>
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>
There was a problem hiding this comment.
4 issues found across 8 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/validate.sh">
<violation number="1" location="scripts/validate.sh:22">
P2: The new default ADK path `$SDK/../adk-26-ci` no longer matches the canonical downstream repo. The script's own header (line 8) still documents `ADK=/path/to/stackone-adk-plugin`, and this PR's migration notes name `stackone-adk-plugin` as the repo with prepared branches. A name like `adk-26-ci` reads as a CI/testing fork. Consequence: a local `make validate` run with the plugin checked out under the documented name `stackone-adk-plugin` finds no `adk-26-ci` directory and silently SKIPs the ADK smoke while still exiting 0 ("VALIDATE: PASS (with N skipped)"), so ADK coverage drops without any signal. If the fork is the intended default, update the header usage comment and confirm `adk-26-ci` is a stable repo; otherwise revert the default to `stackone-adk-plugin`.</violation>
</file>
<file name="stackone_ai/types.py">
<violation number="1" location="stackone_ai/types.py:131">
P2: When a provider returns a filename longer than 255 bytes without a short ASCII suffix, this guard still returns an overlong filename. Callers saving the download can then hit filesystem name-length errors; calculate the retained suffix in bytes or truncate the complete UTF-8 byte string.</violation>
</file>
<file name="tests/test_models.py">
<violation number="1" location="tests/test_models.py:230">
P2: Lines 230-231 exceed the configured ruff line length of 110 (measured 111 and 113 chars) and would be rewritten by `make format`, so the CI ruff format/lint check fails as committed. Wrap the comparison operands so each line stays within the limit.</violation>
</file>
<file name="stackone_ai/toolset.py">
<violation number="1" location="stackone_ai/toolset.py:449">
P2: When `base_url` is malformed, `fetch_accounts()` still leaks `httpx.InvalidURL` because this handler catches only `httpx.HTTPError`. Catch `httpx.InvalidURL` as well so direct account discovery consistently raises `ToolsetLoadError.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| SDK="$(dirname "$HERE")" | ||
| CONFORMANCE="${CONFORMANCE:-$SDK/../sdk-conformance}" | ||
| ADK="${ADK:-$SDK/../adk-26-ci}" |
There was a problem hiding this comment.
P2: The new default ADK path $SDK/../adk-26-ci no longer matches the canonical downstream repo. The script's own header (line 8) still documents ADK=/path/to/stackone-adk-plugin, and this PR's migration notes name stackone-adk-plugin as the repo with prepared branches. A name like adk-26-ci reads as a CI/testing fork. Consequence: a local make validate run with the plugin checked out under the documented name stackone-adk-plugin finds no adk-26-ci directory and silently SKIPs the ADK smoke while still exiting 0 ("VALIDATE: PASS (with N skipped)"), so ADK coverage drops without any signal. If the fork is the intended default, update the header usage comment and confirm adk-26-ci is a stable repo; otherwise revert the default to stackone-adk-plugin.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/validate.sh, line 22:
<comment>The new default ADK path `$SDK/../adk-26-ci` no longer matches the canonical downstream repo. The script's own header (line 8) still documents `ADK=/path/to/stackone-adk-plugin`, and this PR's migration notes name `stackone-adk-plugin` as the repo with prepared branches. A name like `adk-26-ci` reads as a CI/testing fork. Consequence: a local `make validate` run with the plugin checked out under the documented name `stackone-adk-plugin` finds no `adk-26-ci` directory and silently SKIPs the ADK smoke while still exiting 0 ("VALIDATE: PASS (with N skipped)"), so ADK coverage drops without any signal. If the fork is the intended default, update the header usage comment and confirm `adk-26-ci` is a stable repo; otherwise revert the default to `stackone-adk-plugin`.</comment>
<file context>
@@ -19,7 +19,7 @@ set -uo pipefail
SDK="$(dirname "$HERE")"
CONFORMANCE="${CONFORMANCE:-$SDK/../sdk-conformance}"
-ADK="${ADK:-$SDK/../stackone-adk-plugin}"
+ADK="${ADK:-$SDK/../adk-26-ci}"
failed=0
</file context>
| ADK="${ADK:-$SDK/../adk-26-ci}" | |
| ADK="${ADK:-$SDK/../stackone-adk-plugin}" |
| if len(base.encode("utf-8", "ignore")) > 255: | ||
| stem, dot, suffix = base.rpartition(".") | ||
| keep = 255 - len(dot + suffix) | ||
| base = (stem.encode("utf-8")[: max(keep, 1)].decode("utf-8", "ignore")) + dot + suffix |
There was a problem hiding this comment.
P2: When a provider returns a filename longer than 255 bytes without a short ASCII suffix, this guard still returns an overlong filename. Callers saving the download can then hit filesystem name-length errors; calculate the retained suffix in bytes or truncate the complete UTF-8 byte string.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At stackone_ai/types.py, line 131:
<comment>When a provider returns a filename longer than 255 bytes without a short ASCII suffix, this guard still returns an overlong filename. Callers saving the download can then hit filesystem name-length errors; calculate the retained suffix in bytes or truncate the complete UTF-8 byte string.</comment>
<file context>
@@ -118,8 +120,19 @@ def _safe_basename(name: str | None) -> str | None:
+ base = "".join(ch for ch in base if unicodedata.category(ch) not in ("Cc", "Cf")).strip()
+ if base in ("", ".", ".."):
+ return None
+ if len(base.encode("utf-8", "ignore")) > 255:
+ stem, dot, suffix = base.rpartition(".")
+ keep = 255 - len(dot + suffix)
</file context>
| if len(base.encode("utf-8", "ignore")) > 255: | |
| stem, dot, suffix = base.rpartition(".") | |
| keep = 255 - len(dot + suffix) | |
| base = (stem.encode("utf-8")[: max(keep, 1)].decode("utf-8", "ignore")) + dot + suffix | |
| if len(base.encode("utf-8", "ignore")) > 255: | |
| stem, dot, suffix = base.rpartition(".") | |
| suffix_bytes = (dot + suffix).encode("utf-8", "ignore") | |
| if len(suffix_bytes) >= 255: | |
| base = suffix_bytes[:255].decode("utf-8", "ignore") | |
| else: | |
| keep = 255 - len(suffix_bytes) | |
| stem = stem.encode("utf-8", "ignore")[:keep].decode("utf-8", "ignore") | |
| base = stem + dot + suffix |
| assert set(langchain_tools[0].args_schema["properties"]) == set(mock_tool.parameters.properties.keys()) | ||
| assert set(langchain_tools[1].args_schema["properties"]) == set(second_tool.parameters.properties.keys()) |
There was a problem hiding this comment.
P2: Lines 230-231 exceed the configured ruff line length of 110 (measured 111 and 113 chars) and would be rewritten by make format, so the CI ruff format/lint check fails as committed. Wrap the comparison operands so each line stays within the limit.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_models.py, line 230:
<comment>Lines 230-231 exceed the configured ruff line length of 110 (measured 111 and 113 chars) and would be rewritten by `make format`, so the CI ruff format/lint check fails as committed. Wrap the comparison operands so each line stays within the limit.</comment>
<file context>
@@ -229,12 +227,8 @@ def test_to_langchain_multiple_tools(mock_tool):
- assert set(langchain_tools[1].args_schema.__annotations__.keys()) == set(
- second_tool.parameters.properties.keys()
- )
+ assert set(langchain_tools[0].args_schema["properties"]) == set(mock_tool.parameters.properties.keys())
+ assert set(langchain_tools[1].args_schema["properties"]) == set(second_tool.parameters.properties.keys())
</file context>
| assert set(langchain_tools[0].args_schema["properties"]) == set(mock_tool.parameters.properties.keys()) | |
| assert set(langchain_tools[1].args_schema["properties"]) == set(second_tool.parameters.properties.keys()) | |
| assert set(langchain_tools[0].args_schema["properties"]) == set( | |
| mock_tool.parameters.properties.keys() | |
| ) | |
| assert set(langchain_tools[1].args_schema["properties"]) == set( | |
| second_tool.parameters.properties.keys() | |
| ) |
| return cached | ||
|
|
||
| endpoint = f"{self.base_url.rstrip('/')}/mcp?param-style={_MCP_PARAM_STYLE}" | ||
| except httpx.HTTPError as exc: |
There was a problem hiding this comment.
P2: When base_url is malformed, fetch_accounts() still leaks httpx.InvalidURL because this handler catches only httpx.HTTPError. Catch httpx.InvalidURL as well so direct account discovery consistently raises `ToolsetLoadError.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At stackone_ai/toolset.py, line 449:
<comment>When `base_url` is malformed, `fetch_accounts()` still leaks `httpx.InvalidURL` because this handler catches only `httpx.HTTPError`. Catch `httpx.InvalidURL` as well so direct account discovery consistently raises `ToolsetLoadError.</comment>
<file context>
@@ -401,14 +437,21 @@ def fetch_accounts(self) -> list[JsonDict]:
+ },
+ timeout=self._timeout,
+ )
+ except httpx.HTTPError as exc:
+ # The only public method with no error handling at all: a dead host, a bad
+ # scheme or a timeout leaked httpx's own exception type straight out of the
</file context>
| except httpx.HTTPError as exc: | |
| except (httpx.HTTPError, httpx.InvalidURL) as exc: |
…ched 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>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="stackone_ai/toolset.py">
<violation number="1" location="stackone_ai/toolset.py:137">
P2: When an account discovery is in flight during `clear_catalog_cache()`, the stale discovery can repopulate the cache after the clear. Make discovery generation-aware: capture the generation before `fetch_accounts()` and retry instead of publishing or caching results when the generation changes.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| def get_search_tool(self, *, search: SearchMode | None = None) -> SearchTool: | ||
| """Get a callable search tool that returns Tools collections. | ||
| with self._cache_lock: | ||
| self._cache_generation += 1 |
There was a problem hiding this comment.
P2: When an account discovery is in flight during clear_catalog_cache(), the stale discovery can repopulate the cache after the clear. Make discovery generation-aware: capture the generation before fetch_accounts() and retry instead of publishing or caching results when the generation changes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At stackone_ai/toolset.py, line 137:
<comment>When an account discovery is in flight during `clear_catalog_cache()`, the stale discovery can repopulate the cache after the clear. Make discovery generation-aware: capture the generation before `fetch_accounts()` and retry instead of publishing or caching results when the generation changes.</comment>
<file context>
@@ -124,8 +133,10 @@ def clear_catalog_cache(self) -> None:
- self._catalog_cache.clear()
- self._discovered_account_ids = None
+ with self._cache_lock:
+ self._cache_generation += 1
+ self._catalog_cache.clear()
+ self._discovered_account_ids = None
</file context>
…t 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>
…x 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>
…ctually 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>
There was a problem hiding this comment.
7 issues found across 14 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name=".env.example">
<violation number="1" location=".env.example:4">
P3: The comment is inaccurate: langchain_integration.py, langgraph_integration.py, and pydantic_ai_integration.py each require STACKONE_ACCOUNT_ID (they print "Set STACKONE_ACCOUNT_ID..." and return when absent), not just auth_management.py. Only openai_integration.py actually runs without it, since fetch_tools() discovers accounts from the key. Update the comment so users don't skip the variable and hit failures in the listed examples.</violation>
</file>
<file name=".github/workflows/release.yaml">
<violation number="1" location=".github/workflows/release.yaml:71">
P1: A failed repository CI gate can still publish to PyPI because this job depends only on `release-please` and reruns pytest, not `ci-ok` or the strict-schema conformance checks. Gate `uv publish` on the complete required CI result, or run the same conformance and quality checks in this workflow before publishing.</violation>
<violation number="2" location=".github/workflows/release.yaml:72">
P1: When release-please reports no release, this job still runs because the `"false"` output string is truthy in a GitHub Actions condition. Compare the output with `'true'` before starting the publish job.</violation>
</file>
<file name="CLAUDE.md">
<violation number="1" location="CLAUDE.md:210">
P3: This sentence inaccurately states that str(StackOneAPIError) leads with the server's own message. In the code, every StackOneAPIError message is prefixed with SDK-side text ("MCP request to ... failed with", "Tool 'name' failed:", "Listing accounts at ... failed with") and the server body only follows that prefix. Reword to reflect that the message prefixes the SDK's own context, or drop the claim.</violation>
</file>
<file name=".github/workflows/ci.yaml">
<violation number="1" location=".github/workflows/ci.yaml:5">
P2: Pushes to non-main branches no longer run CI. A branch pushed before opening a PR, or one never opened as a PR, gets no tests, lint, or secret scan despite the repository's documented every-push CI contract. Run this workflow for all branch pushes (or change the documented contract).</violation>
</file>
<file name="README.md">
<violation number="1" location="README.md:232">
P2: The LangGraph example now installs `langchain` but drops `langgraph`, while the code below it calls `create_agent` from `langchain.agents`, which requires langgraph at runtime. A user following the README will hit an import error. Restore `langgraph` in the install line.</violation>
<violation number="2" location="README.md:261">
P2: Without an `account_ids` filter, this action can produce one tool per active account, not exactly one. Add an account scope to make the example deterministic or describe the per-account result; otherwise `get_tool()` can route to the last account listed.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| # so a red main still merged the release PR and shipped to PyPI. The suite runs | ||
| # again here against the exact commit being published. | ||
| publish: | ||
| needs: release-please |
There was a problem hiding this comment.
P1: A failed repository CI gate can still publish to PyPI because this job depends only on release-please and reruns pytest, not ci-ok or the strict-schema conformance checks. Gate uv publish on the complete required CI result, or run the same conformance and quality checks in this workflow before publishing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/release.yaml, line 71:
<comment>A failed repository CI gate can still publish to PyPI because this job depends only on `release-please` and reruns pytest, not `ci-ok` or the strict-schema conformance checks. Gate `uv publish` on the complete required CI result, or run the same conformance and quality checks in this workflow before publishing.</comment>
<file context>
@@ -44,17 +63,46 @@ jobs:
+ # so a red main still merged the release PR and shipped to PyPI. The suite runs
+ # again here against the exact commit being published.
+ publish:
+ needs: release-please
+ if: ${{ needs.release-please.outputs.release_created }}
+ runs-on: ubuntu-latest
</file context>
| # again here against the exact commit being published. | ||
| publish: | ||
| needs: release-please | ||
| if: ${{ needs.release-please.outputs.release_created }} |
There was a problem hiding this comment.
P1: When release-please reports no release, this job still runs because the "false" output string is truthy in a GitHub Actions condition. Compare the output with 'true' before starting the publish job.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/release.yaml, line 72:
<comment>When release-please reports no release, this job still runs because the `"false"` output string is truthy in a GitHub Actions condition. Compare the output with `'true'` before starting the publish job.</comment>
<file context>
@@ -44,17 +63,46 @@ jobs:
+ # again here against the exact commit being published.
+ publish:
+ needs: release-please
+ if: ${{ needs.release-please.outputs.release_created }}
+ runs-on: ubuntu-latest
+ # Configure a required reviewer on this environment in the repository settings.
</file context>
| if: ${{ needs.release-please.outputs.release_created }} | |
| if: ${{ needs.release-please.outputs.release_created == 'true' }} |
|
|
||
| ```bash | ||
| pip install langgraph langchain-openai | ||
| uv add 'stackone-ai[langchain]' langchain langchain-openai |
There was a problem hiding this comment.
P2: The LangGraph example now installs langchain but drops langgraph, while the code below it calls create_agent from langchain.agents, which requires langgraph at runtime. A user following the README will hit an import error. Restore langgraph in the install line.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 232:
<comment>The LangGraph example now installs `langchain` but drops `langgraph`, while the code below it calls `create_agent` from `langchain.agents`, which requires langgraph at runtime. A user following the README will hit an import error. Restore `langgraph` in the install line.</comment>
<file context>
@@ -214,7 +229,7 @@ print(agent.run_sync("Use a tool to list a few records, then summarise them.").o
```bash
-uv add 'stackone-ai[langchain]' langgraph langchain-openai
+uv add 'stackone-ai[langchain]' langchain langchain-openai
</file context>
</details>
```suggestion
uv add 'stackone-ai[langchain]' langchain langchain-openai langgraph
|
|
||
| on: | ||
| push: | ||
| branches: |
There was a problem hiding this comment.
P2: Pushes to non-main branches no longer run CI. A branch pushed before opening a PR, or one never opened as a PR, gets no tests, lint, or secret scan despite the repository's documented every-push CI contract. Run this workflow for all branch pushes (or change the documented contract).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ci.yaml, line 5:
<comment>Pushes to non-main branches no longer run CI. A branch pushed before opening a PR, or one never opened as a PR, gets no tests, lint, or secret scan despite the repository's documented every-push CI contract. Run this workflow for all branch pushes (or change the documented contract).</comment>
<file context>
@@ -2,6 +2,8 @@ name: CI
on:
push:
+ branches:
+ - main
pull_request:
</file context>
| toolset.fetch_tools() # every tool, every active account | ||
| toolset.fetch_tools(providers=["linear"]) # one connector | ||
| toolset.fetch_tools(actions=["linear_list_*"]) # one connector's list actions | ||
| toolset.fetch_tools(actions=["linear_get_issue"]) # exactly one tool |
There was a problem hiding this comment.
P2: Without an account_ids filter, this action can produce one tool per active account, not exactly one. Add an account scope to make the example deterministic or describe the per-account result; otherwise get_tool() can route to the last account listed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 261:
<comment>Without an `account_ids` filter, this action can produce one tool per active account, not exactly one. Add an account scope to make the example deterministic or describe the per-account result; otherwise `get_tool()` can route to the last account listed.</comment>
<file context>
@@ -240,12 +255,12 @@ applied locally to one cached listing — changing a filter never refetches.
+toolset.fetch_tools() # every tool, every active account
+toolset.fetch_tools(providers=["linear"]) # one connector
+toolset.fetch_tools(actions=["linear_list_*"]) # one connector's list actions
+toolset.fetch_tools(actions=["linear_get_issue"]) # exactly one tool
toolset.fetch_tools(providers=["linear"],
- actions=["*_get_*"]) # 23 (AND)
</file context>
| toolset.fetch_tools(actions=["linear_get_issue"]) # exactly one tool | |
| toolset.fetch_tools(actions=["linear_get_issue"], account_ids=["acc-123"]) # exactly one tool for this account |
| # Required for all examples. Account ids are discovered from the key. | ||
| STACKONE_API_KEY=your-stackone-api-key | ||
|
|
||
| # Only examples/auth_management.py needs a specific account |
There was a problem hiding this comment.
P3: The comment is inaccurate: langchain_integration.py, langgraph_integration.py, and pydantic_ai_integration.py each require STACKONE_ACCOUNT_ID (they print "Set STACKONE_ACCOUNT_ID..." and return when absent), not just auth_management.py. Only openai_integration.py actually runs without it, since fetch_tools() discovers accounts from the key. Update the comment so users don't skip the variable and hit failures in the listed examples.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .env.example, line 4:
<comment>The comment is inaccurate: langchain_integration.py, langgraph_integration.py, and pydantic_ai_integration.py each require STACKONE_ACCOUNT_ID (they print "Set STACKONE_ACCOUNT_ID..." and return when absent), not just auth_management.py. Only openai_integration.py actually runs without it, since fetch_tools() discovers accounts from the key. Update the comment so users don't skip the variable and hit failures in the listed examples.</comment>
<file context>
@@ -1,6 +1,8 @@
+# Required for all examples. Account ids are discovered from the key.
STACKONE_API_KEY=your-stackone-api-key
+
+# Only examples/auth_management.py needs a specific account
STACKONE_ACCOUNT_ID=your-account-id
</file context>
| # Only examples/auth_management.py needs a specific account | |
| # Needed by examples that scope to one account (auth_management, langchain_integration, langgraph_integration, pydantic_ai_integration); openai_integration discovers accounts from the key |
| - Response handling in `_process_response()` | ||
| - **Error handling**: `StackOneError`/`StackOneAPIError` and `ToolsetError`/ | ||
| `ToolsetConfigError`/`ToolsetLoadError` are two **unrelated** hierarchies in | ||
| `types.py`. `str(StackOneAPIError)` leads with the server's own message. |
There was a problem hiding this comment.
P3: This sentence inaccurately states that str(StackOneAPIError) leads with the server's own message. In the code, every StackOneAPIError message is prefixed with SDK-side text ("MCP request to ... failed with", "Tool 'name' failed:", "Listing accounts at ... failed with") and the server body only follows that prefix. Reword to reflect that the message prefixes the SDK's own context, or drop the claim.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLAUDE.md, line 210:
<comment>This sentence inaccurately states that str(StackOneAPIError) leads with the server's own message. In the code, every StackOneAPIError message is prefixed with SDK-side text ("MCP request to ... failed with", "Tool 'name' failed:", "Listing accounts at ... failed with") and the server body only follows that prefix. Reword to reflect that the message prefixes the SDK's own context, or drop the claim.</comment>
<file context>
@@ -159,28 +190,43 @@ via release-please after a merge to main, never from a developer machine.
-- **File downloads**: non-JSON responses return raw bytes plus metadata, not decoded text
+- **Error handling**: `StackOneError`/`StackOneAPIError` and `ToolsetError`/
+ `ToolsetConfigError`/`ToolsetLoadError` are two **unrelated** hierarchies in
+ `types.py`. `str(StackOneAPIError)` leads with the server's own message.
+- **File downloads**: non-JSON responses return raw bytes plus metadata. The filename
+ comes from an attacker-controllable header and is reduced to a safe basename.
</file context>
| `types.py`. `str(StackOneAPIError)` leads with the server's own message. | |
| `types.py`. `str(StackOneAPIError)` prefixes its own context and appends the server's body. |
…ucture 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>
…d-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>
There was a problem hiding this comment.
4 issues found across 15 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name=".github/dependabot.yaml">
<violation number="1" location=".github/dependabot.yaml:42">
P3: The npm `ignore` covers only `@modelcontextprotocol/sdk`, but `zod`, `hono`, and `@hono/mcp` are pinned exactly in `package.json` under the mock-server policy that these pins must never be changed (mock must model what the real API demands). Dependabot will open weekly PRs bumping those three exact pins, churning the very dependencies the repo pins for behavioral stability and raising the risk of silent mock drift. Either add them to `ignore` too, or document in the comment why they are safe to bump (e.g. CI integration tests cover them).</violation>
<violation number="2" location=".github/dependabot.yaml:42">
P2: This ignore rule blocks all Dependabot updates for `@modelcontextprotocol/sdk`, not just major releases. Patch and security updates will never be proposed; add `version-update:semver-major` under `update-types` if only major upgrades must remain blocked.</violation>
</file>
<file name="stackone_ai/tools.py">
<violation number="1" location="stackone_ai/tools.py:1074">
P1: When the catalog contains the same action name for multiple active accounts, this lookup silently executes the last-listed account's tool. Require an account-scoped catalog or reject ambiguous names before dispatching OpenAI calls.</violation>
</file>
<file name="CLAUDE.md">
<violation number="1" location="CLAUDE.md:210">
P2: The new error-handling rule is false for public tool methods: invalid tool arguments raise `ValueError`, and missing optional adapters raise `ImportError`. Either wrap those failures in `StackOneError` or document these exceptions instead of telling callers that only `StackOneError` can escape.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| messages: list[JsonDict] = [] | ||
| for call in tool_calls or []: | ||
| call_id, name, arguments = _read_openai_tool_call(call) | ||
| tool = self.get_tool(name) |
There was a problem hiding this comment.
P1: When the catalog contains the same action name for multiple active accounts, this lookup silently executes the last-listed account's tool. Require an account-scoped catalog or reject ambiguous names before dispatching OpenAI calls.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At stackone_ai/tools.py, line 1074:
<comment>When the catalog contains the same action name for multiple active accounts, this lookup silently executes the last-listed account's tool. Require an account-scoped catalog or reject ambiguous names before dispatching OpenAI calls.</comment>
<file context>
@@ -990,6 +1050,40 @@ def to_openai(self) -> list[JsonDict]:
+ messages: list[JsonDict] = []
+ for call in tool_calls or []:
+ call_id, name, arguments = _read_openai_tool_call(call)
+ tool = self.get_tool(name)
+ if tool is None:
+ result: Any = {"error": f"Unknown tool {name!r}"}
</file context>
| labels: | ||
| - dependencies | ||
| ignore: | ||
| - dependency-name: "@modelcontextprotocol/sdk" |
There was a problem hiding this comment.
P2: This ignore rule blocks all Dependabot updates for @modelcontextprotocol/sdk, not just major releases. Patch and security updates will never be proposed; add version-update:semver-major under update-types if only major upgrades must remain blocked.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/dependabot.yaml, line 42:
<comment>This ignore rule blocks all Dependabot updates for `@modelcontextprotocol/sdk`, not just major releases. Patch and security updates will never be proposed; add `version-update:semver-major` under `update-types` if only major upgrades must remain blocked.</comment>
<file context>
@@ -23,6 +24,23 @@ updates:
+ labels:
+ - dependencies
+ ignore:
+ - dependency-name: "@modelcontextprotocol/sdk"
+
# GitHub Actions
</file context>
| - dependency-name: "@modelcontextprotocol/sdk" | |
| - dependency-name: "@modelcontextprotocol/sdk" | |
| update-types: | |
| - version-update:semver-major |
| - Response handling in `_process_response()` | ||
| - **Error handling**: everything derives from `StackOneError` (`types.py`) — both | ||
| `StackOneAPIError` and the `ToolsetError` family. Nothing outside that hierarchy | ||
| should escape a public method. `str(StackOneAPIError)` leads with the server's own |
There was a problem hiding this comment.
P2: The new error-handling rule is false for public tool methods: invalid tool arguments raise ValueError, and missing optional adapters raise ImportError. Either wrap those failures in StackOneError or document these exceptions instead of telling callers that only StackOneError can escape.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLAUDE.md, line 210:
<comment>The new error-handling rule is false for public tool methods: invalid tool arguments raise `ValueError`, and missing optional adapters raise `ImportError`. Either wrap those failures in `StackOneError` or document these exceptions instead of telling callers that only `StackOneError` can escape.</comment>
<file context>
@@ -205,9 +205,10 @@ tools = toolset.fetch_tools(providers=["linear"], actions=["*_list_*"])
- `types.py`. `str(StackOneAPIError)` leads with the server's own message.
+- **Error handling**: everything derives from `StackOneError` (`types.py`) — both
+ `StackOneAPIError` and the `ToolsetError` family. Nothing outside that hierarchy
+ should escape a public method. `str(StackOneAPIError)` leads with the server's own
+ message.
- **File downloads**: non-JSON responses return raw bytes plus metadata. The filename
</file context>
| labels: | ||
| - dependencies | ||
| ignore: | ||
| - dependency-name: "@modelcontextprotocol/sdk" |
There was a problem hiding this comment.
P3: The npm ignore covers only @modelcontextprotocol/sdk, but zod, hono, and @hono/mcp are pinned exactly in package.json under the mock-server policy that these pins must never be changed (mock must model what the real API demands). Dependabot will open weekly PRs bumping those three exact pins, churning the very dependencies the repo pins for behavioral stability and raising the risk of silent mock drift. Either add them to ignore too, or document in the comment why they are safe to bump (e.g. CI integration tests cover them).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/dependabot.yaml, line 42:
<comment>The npm `ignore` covers only `@modelcontextprotocol/sdk`, but `zod`, `hono`, and `@hono/mcp` are pinned exactly in `package.json` under the mock-server policy that these pins must never be changed (mock must model what the real API demands). Dependabot will open weekly PRs bumping those three exact pins, churning the very dependencies the repo pins for behavioral stability and raising the risk of silent mock drift. Either add them to `ignore` too, or document in the comment why they are safe to bump (e.g. CI integration tests cover them).</comment>
<file context>
@@ -23,6 +24,23 @@ updates:
+ labels:
+ - dependencies
+ ignore:
+ - dependency-name: "@modelcontextprotocol/sdk"
+
# GitHub Actions
</file context>
StuBehan
left a comment
There was a problem hiding this comment.
Ran this locally: 247 tests pass, ruff and ty clean, and py.typed still ships in the wheel (checked, dropping the force-include is fine). pnpm test:python in the conformance repo is a clean PASS too - 11/11, inv 9, inv 10, schema pass-through. Security fixes all look right to me and the comments explaining why are solid.
Three things I'd want before merge, left inline. The schema one is the main one, I don't think the byte-for-byte claim holds against the real server. The other two are that the conformance gate has never actually run in CI - missing secret, and a pnpm setup break sitting behind it.
I'd do the fixture fix in StackOneHQ/sdk-conformance#2 first so the schema regression fails the harness rather than being fixed on trust.
| """ | ||
|
|
||
| type: str = Field(description="JSON Schema type") | ||
| properties: JsonDict = Field(description="JSON Schema properties") |
There was a problem hiding this comment.
This only carries type and properties, so to_openai_function() rebuilds the root as {type, properties, required} and anything else at the root goes. UCA emits $schema: 2020-12 at the root of every non-empty tool's inputSchema (input-json-schema.util.ts:264), so we're dropping that on every tool today.
Worse - I fed it a schema with $defs and a property using $ref: "#/$defs/Money". The $ref survives and $defs doesn't, so the model gets a dangling ref that OpenAI strict mode won't take.
Can we keep the served root here and only swap required? Also want to double check what the 139-tool byte-for-byte comparison was actually comparing, feels like it must have been normalising the root away 🤔
| # missing when a job is renamed or a matrix entry is added, and nothing notices. | ||
| ci-ok: | ||
| if: always() | ||
| needs: [gitleaks, ci, conformance] |
There was a problem hiding this comment.
Dependabot and fork PRs never get repo secrets, so conformance exits 1 on the token check and this needs takes ci-ok down with it. If ci-ok is the single required check then every dependabot PR is blocked forever, which is a shame given we just moved the python ecosystem to uv so they could actually pass. Can we treat "this context can't have the secret" as not-run rather than a failure?
| - name: Setup pnpm | ||
| uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 | ||
| with: | ||
| package_json_file: sdk-conformance/package.json |
There was a problem hiding this comment.
There's no version: here and sdk-conformance's package.json has no packageManager or devEngines, so action-setup's readTargetVersion throws No pnpm version is specified. We haven't seen it because the token check fails first. Worth fixing on the conformance side (StackOneHQ/sdk-conformance#2) or pinning version: here, otherwise this job still won't run once the secret is set.
| X-Api-Key, ...) and is wrong the moment one is missed. The previous two-name | ||
| list let all of those through. | ||
|
|
||
| The allowlist is the served schema itself, so this needs no maintenance: today |
There was a problem hiding this comment.
On a *_execute_action meta tool self.parameters.properties is action_id/path/query/body/headers, so the headers_* allowlist is always empty and every header gets dropped. Checked it - meta tool keeps {}, an rpc tool with a declared headers_x-custom-tenant keeps it.
Fine as a default. But the comment says it'll work with no SDK release the day an action needs a header, and on the search/execute path it won't. Correct the comment, or build the allowlist from the target action's schema?
| _account_id: str | None = PrivateAttr(default=None) | ||
|
|
||
| @property | ||
| def connector(self) -> str: |
There was a problem hiding this comment.
This still splits on the first _, so browser_linkedin_search_people comes out as browser - the same thing _filter_by_provider and _connector_of got fixed for. get_connectors() feeds off it. Worth pulling the three into one helper?
| # ALL, not any: under flat_prefixed every parameter is prefixed, so one bare | ||
| # name is proof the schema is not. `any` would be satisfied by the very key | ||
| # this exists to protect — a declared body field called `path_to_file`. | ||
| prefixed = not named or all(_FLAT_ENVELOPE_KEY_PATTERN.match(k) for k in named) |
There was a problem hiding this comment.
If the server ever serves one property that isn't path|query|body|headers-prefixed, this flips off for the whole schema and every prefixed key falls into the body. Tried it with {path_id, raw_extra} and arg path_id, got body: {path_id: 'abc'} with path empty. Agree all is right over any, but should we log when it flips? Silent is rough on whoever's debugging a missing path param.
| if not effective_account_ids and self.account_id: | ||
| effective_account_ids = [self.account_id] | ||
| if not effective_account_ids: | ||
| effective_account_ids = self._discover_account_ids() |
There was a problem hiding this comment.
This is the headline "api key is enough" path, and it's one MCP listing per active account, 10 at a time. /accounts has no limit on the bare-array branch so we'll see all of them - an org with 50 linked accounts pays 50 round trips on the first fetch_tools() and gets 50x the catalog into the model. Do we want a cap, or at least a note in the README?
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name=".github/workflows/ci.yaml">
<violation number="1" location=".github/workflows/ci.yaml:111">
P2: If `check-secret` fails or is cancelled, `conformance` is skipped and `ci-ok` can still pass because it does not depend on `check-secret` and ignores skipped results. Include `check-secret` in `ci-ok.needs` so an infrastructure failure in the new gate cannot silently bypass the conformance requirement.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| fi | ||
|
|
||
| conformance: | ||
| needs: check-secret |
There was a problem hiding this comment.
P2: If check-secret fails or is cancelled, conformance is skipped and ci-ok can still pass because it does not depend on check-secret and ignores skipped results. Include check-secret in ci-ok.needs so an infrastructure failure in the new gate cannot silently bypass the conformance requirement.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ci.yaml, line 111:
<comment>If `check-secret` fails or is cancelled, `conformance` is skipped and `ci-ok` can still pass because it does not depend on `check-secret` and ignores skipped results. Include `check-secret` in `ci-ok.needs` so an infrastructure failure in the new gate cannot silently bypass the conformance requirement.</comment>
<file context>
@@ -92,8 +92,24 @@ jobs:
+
conformance:
- if: secrets.CONFORMANCE_REPO_TOKEN != ''
+ needs: check-secret
+ if: needs.check-secret.outputs.has-token == 'true'
runs-on: ubuntu-latest
</file context>
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Requires human review: Auto-approval blocked by 41 unresolved issues from previous reviews.
Re-trigger cubic
|
Thanks Stu — addressed all of your points across both PRs:
|
There was a problem hiding this comment.
0 issues found across 3 files (changes from recent commits).
Requires human review: Auto-approval blocked by 40 unresolved issues from previous reviews.
Re-trigger cubic
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="stackone_ai/toolset.py">
<violation number="1" location="stackone_ai/toolset.py:171">
P3: This warning is incorrect when the toolset already has account scope: `fetch_tools()` uses `set_accounts()` or the constructor's `account_id` before discovering accounts. Qualify the note so scoped callers are not told that an omitted per-call `account_ids` always fans out to every active account.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| For organizations with multiple connected accounts, calling `fetch_tools()` | ||
| without `account_ids` discovers and fetches the catalog for every active account. | ||
| If your organization has many accounts, pass explicit `account_ids` to avoid | ||
| excessive round trips and blowing model context limits. |
There was a problem hiding this comment.
P3: This warning is incorrect when the toolset already has account scope: fetch_tools() uses set_accounts() or the constructor's account_id before discovering accounts. Qualify the note so scoped callers are not told that an omitted per-call account_ids always fans out to every active account.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At stackone_ai/toolset.py, line 171:
<comment>This warning is incorrect when the toolset already has account scope: `fetch_tools()` uses `set_accounts()` or the constructor's `account_id` before discovering accounts. Qualify the note so scoped callers are not told that an omitted per-call `account_ids` always fans out to every active account.</comment>
<file context>
@@ -166,6 +166,12 @@ def fetch_tools(
tools = toolset.fetch_tools(actions=['*_list_employees'])
+
+ Note:
+ For organizations with multiple connected accounts, calling `fetch_tools()`
+ without `account_ids` discovers and fetches the catalog for every active account.
+ If your organization has many accounts, pass explicit `account_ids` to avoid
</file context>
| For organizations with multiple connected accounts, calling `fetch_tools()` | |
| without `account_ids` discovers and fetches the catalog for every active account. | |
| If your organization has many accounts, pass explicit `account_ids` to avoid | |
| excessive round trips and blowing model context limits. | |
| For organizations with multiple connected accounts, calling `fetch_tools()` | |
| without `account_ids` (and without accounts set via `set_accounts()` or | |
| `StackOneToolSet(account_id=...)`) discovers and fetches the catalog for every | |
| active account. If your organization has many accounts, pass explicit | |
| `account_ids` to avoid excessive round trips and blowing model context limits. |
StuBehan
left a comment
There was a problem hiding this comment.
Schema fix is spot on. Checked it against a UCA-shaped schema and $schema, $defs, additionalProperties, title and a root oneOf all come through now, with nullable still stripped from what the model actually sees. 247 tests, ruff and ty clean here too. Thanks for turning that round quickly.
One to go back on, left inline on ci-ok. Dropping get_connectors() along with the connector property is fine by me on a major, but worth a CHANGELOG line given it isn't in the description.
| steps: | ||
| - name: Require every job to have passed | ||
| run: | | ||
| if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then |
There was a problem hiding this comment.
Taking skipped out fixes forks and dependabot, which is what I asked for, but it's gone a bit wide. If CONFORMANCE_REPO_TOKEN ever gets revoked or expires, conformance quietly skips and this goes green with no contract check having run at all. Same for ci or gitleaks if either ever skips.
Could we gate the skip on github.event.pull_request.head.repo.fork and the dependabot actor instead, so "this context can't have the secret" and "someone deleted the secret" don't look the same?
| # | ||
| # StackOneHQ/sdk-conformance is private, so the default GITHUB_TOKEN cannot | ||
| # check it out. Set the CONFORMANCE_REPO_TOKEN secret to a PAT or App token with | ||
| # read access. Without it this job fails rather than skipping — a skipped |
There was a problem hiding this comment.
This still says the job fails rather than skipping, which is now the opposite of what it does. Worth rewording so the next person doesn't trust it.
| # this exists to protect — a declared body field called `path_to_file`. | ||
| prefixed = not named or all(_FLAT_ENVELOPE_KEY_PATTERN.match(k) for k in named) | ||
| if named and not prefixed: | ||
| logger.debug( |
There was a problem hiding this comment.
debug is off by default, so this is still effectively silent when it fires - and when it fires every path param ends up in the body. Bump to warning? The other guards in here (dropped headers, duplicate tool names, failed accounts) are all warning.
Summary
A major (3.0.0) rewrite. The SDK is now a thin wrapper over the StackOne MCP endpoint, it works with just an API key, and it has been tested against the live API rather than only against mocks. The previous release could not list a single tool while its whole test suite passed — most of this PR is about making sure that can't happen again.
What changed for users
GET /accountsand uses everyactiveone. Before, every call to/mcpwent out withoutx-account-idand was rejected.search()+execute()— find an action in plain English and run it, without loading the full tool catalog into the model's context.types.py,tools.py,toolset.py. Client-side search, OpenAPI parsing and the CrewAI integration are gone.mcpis now a core dependency; framework adapters are lazy extras.to_openai_function()matches the served schema byte for byte. The LangChain adapter now passes the same schema through; before, it dropped every nested field, and every call made through it failed with a 400.StackOneError. An API error's message now leads with the server's own explanation instead of generic httpx text.timeout=now actually applies to MCP calls. Before, it was ignored, sotimeout=2against a host that never answered hung for over five minutes.Security (tool arguments are attacker-controlled under prompt injection)
action_idinsideexecute()'s arguments could replace the action the caller had pinned." authorization"bypassed, and not filtered at all on the MCP path. They now go through an allowlist built from the served schema. Zero live actions declare a header, and the server was found to ignore the envelope'sheadersobject.../../.ssh/authorized_keyswas returned as-is. They are now reduced to a safe basename.Testing and CI
/mcpor/actions/rpcrequest gets a 400, an unknown account a 404. The old mock filled in a missing account as'default', which is how the original bug stayed invisible.pypienvironment and re-runs the tests first; before, a redmaincould still ship.examples/, and a newci-okjob aggregates the required checks.Test plan
make test: 241 passedmake format: ruff and ty clean,examples/includedmake validate: conformance with strict schema (now the default), SDK smoke, Pydantic AI 1.x and 2.x. The ADK smoke skips because the plugin pinsstackone-ai>=3.0.0, which isn't on PyPI yet.fetch_accounts,fetch_tools,search,execute, pagination, and write actions (create → read → update → delete, all cleaned up)Needs repo-settings changes
pypienvironmentci-okthe single required status checkuv.lockcommit withGITHUB_TOKEN, which doesn't trigger CI. It needs a personal access token or GitHub App token.CONFORMANCE_REPO_TOKENsecretKnown gaps, not in this PR
search/execute; the conformance harness reports both as failuresPairs with StackOneHQ/sdk-conformance#2.
🤖 Generated with Claude Code