Skip to content

refactor!: MCP-only toolset — every tool over tools/call, with search/execute sessions and submitFeedback() - #383

Open
willleeney wants to merge 29 commits into
mainfrom
refactor/mcp-only-toolset
Open

willleeney wants to merge 29 commits into
mainfrom
refactor/mcp-only-toolset

Conversation

@willleeney

@willleeney willleeney commented Sep 29, 2026 •

Copy link
Copy Markdown

Rewrites the Node SDK as the 3.0 MCP-only client, matching python#210. Graded by StackOneHQ/sdk-conformance#5. Upgrading from 2.x is covered in MIGRATION.md.

What changes

MCP-only

  • Tools are listed from /mcp for each account and executed over tools/call on the endpoint and account that listed them.
  • Non-header arguments are sent unchanged; the server maps them. GET /accounts is the only request outside MCP. When no account is configured, the SDK discovers the active accounts linked to the API key, with one request shared by concurrent callers.
  • A tool returns the server's result exactly as the server wrote it: {isError: false, result, defenderMetadata?, policyMetadata?} for action tools, bare JSON for search. isError raises StackOneAPIError with the status from its payload.
  • A file action returns UCA's single-use download link as result, or raises with status 501 when no link can be issued.
  • Removed:
    • the RPC client and envelope splitting;
    • binary downloads;
    • the param-style pin;
    • direct HTTP execution;
    • client-side search;
    • SDK-side Defender config, which is now managed in the dashboard;
    • the client-side feedback tool.

Search, execute and feedback

  • search() ranks across every connector and puts the search session_id on every hit. SearchResult types description, similarity_score, input_schema and example_request.
  • execute(actionId, args, { sessionId }) pins the caller's action_id, so a model-supplied one can't replace it.
  • submitFeedback() lists tool-mode=search_execute on the first account (the first of accountIds if given, else the first in GET /accounts order) and makes exactly one tools/call to the server's stackone_submit_feedback. fetchTools() lists that tool once however many accounts serve it.

Schemas and security

  • toJsonSchema() passes the served schema through: root keywords, $defs, and required in the served order.
  • Header arguments (entries of a nested headers object, or top-level headers_<name>) are forwarded only if the served schema declares them in the same form; an open headers object (as on *_execute_action) declares every name. Authorization, x-account-id and User-Agent are never forwarded, and the SDK always sets them itself, so a model can't switch tenant. Dropped headers warn with the reason.
  • Every SDK error extends StackOneError.

Also: executeOpenAIToolCalls(), a 2.x → 3.0 migration guide, and CI that runs the conformance suite against the built package.

Verification

  • pnpm test: 590 passed, 4 skipped. tsc --noEmit on the root project and vitest run --project root pass with examples/node_modules present and absent (as the ai-peer-range jobs install it).
  • oxlint 1.42.0 with type-check, oxfmt 0.23.0, knip and the build are clean.
  • The conformance suite at the pinned commit passes against the built dist (--strict-schema).

BEGIN_COMMIT_OVERRIDE
refactor: execute every tool over MCP tools/call

BREAKING CHANGE: StackOneRpcTool, RpcExecuteConfig, BinaryDownloadResult and isBinaryDownloadResult are removed. Action results are the server's { isError, result, ... } object rather than the /actions/rpc body. A file action's result is the server's single-use { download_url, expires_at, file } instead of bytes, and the call raises StackOneAPIError with status 501 when no link can be issued. The /mcp URL no longer pins param-style=flat_prefixed, so tool argument names follow the server's own param style. dryRun returns { url, method: 'tools/call', name, arguments }.

refactor: rebuild the toolset on the served MCP catalog

BREAKING CHANGE: RequestBuilder and direct-HTTP execution are removed: ExecuteConfig has no http kind, ParameterLocation is no longer exported, and BaseTool#execute throws unless overridden. BaseTool no longer takes headers and loses getHeaders()/setHeaders() and connector; Tools#getConnectors() is removed. StackOneTool's fifth constructor argument is the account id. StackOneToolSetConfig loses authentication, strict and rpcClient. The constructor throws ToolSetConfigError without an API key. fetchTools() returns fresh tool instances on every call, ordered by account id, and discovers accounts when none is configured.

refactor: remove AuthenticationConfig and BaseToolSetConfig

BREAKING CHANGE: AuthenticationConfig and BaseToolSetConfig are no longer exported. Pass apiKey in StackOneToolSetConfig.

refactor: remove ToolExecution#headers

BREAKING CHANGE: ToolExecution, the execution metadata toAISDK() can attach, no longer carries headers, so it can no longer expose the request credential.

refactor: remove client-side search from the Node SDK

BREAKING CHANGE: searchTools(), searchActionNames(), getSearchTool(), SearchTool, the tool_search/tool_execute meta tools, openai({ mode: 'search_and_execute' }) and the search constructor option are removed. Use the server-side search() instead.

refactor: remove SemanticSearchClient and its types

BREAKING CHANGE: SemanticSearchClient, SemanticSearchError and the SemanticSearchOptions, SemanticSearchResponse and SemanticSearchResult types are no longer exported. search() runs on the server and returns SearchResult objects.

refactor: remove the client-side search option types

BREAKING CHANGE: the SearchMode, SearchToolsOptions, SearchActionNamesOptions and SearchConfig types are no longer exported. Use SearchOptions with search().

refactor: remove getSearchConfig() and getTools()

BREAKING CHANGE: StackOneToolSet#getSearchConfig() and StackOneToolSet#getTools() are removed. For search and execute meta tools, construct the toolset with toolMode: 'search_execute' and call fetchTools().

refactor: remove SDK-side defender configuration

BREAKING CHANGE: DefenderConfig, DefenderMode and DEFAULT_DEFENDER_CONFIG are no longer exported, and the defender option and StackOneToolSet#defenderMode are removed. Configure Defender in the StackOne dashboard.

refactor: remove the client-side feedback tool

BREAKING CHANGE: createFeedbackTool is no longer exported and fetchTools() no longer appends a tool_feedback tool. Use submitFeedback() or the served stackone_submit_feedback tool.

fix(toolsets): stop model-supplied headers from switching tenant

BREAKING CHANGE: headers a tool call supplies are dropped unless the tool's served schema declares them.

fix(tools): forward only the header arguments a served schema declares

BREAKING CHANGE: a header argument (an entry of a nested headers object, or a top-level headers_ argument) is forwarded only if the served schema declares it in the same form: headers.properties., any name when headers is an object schema with no properties, or the headers_ property. Authorization, x-account-id and User-Agent are never forwarded as header arguments, even when declared. Every other argument is sent unchanged.

fix(toolsets): ignore SDK-owned names in the headers option

BREAKING CHANGE: Authorization, x-account-id and User-Agent in the headers constructor option are ignored with a warning; the SDK always sets them itself. Use apiKey and accountId/accountIds instead.

refactor: root every SDK error at StackOneError

BREAKING CHANGE: ToolSetError (and so ToolSetConfigError and ToolSetLoadError) now extends StackOneError.

fix(errors): keep the message StackOneAPIError is given

BREAKING CHANGE: StackOneAPIError no longer appends responseBody.message to its message. Read the server's explanation from error.responseBody.

feat: add search(), execute() and submitFeedback() with session ids
feat(tools): add executeOpenAIToolCalls()
feat(tool): preserve root schema keywords in toJsonSchema pass-through
END_COMMIT_OVERRIDE

Copilot AI balanced review requested due to automatic review settings September 29, 2026 12:01
@willleeney
willleeney requested a review from a team as a code owner September 29, 2026 12:01
@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/StackOneHQ/stackone-ai-node/@stackone/ai@383

commit: d0876f0

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review completed against the latest diff

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread src/tool.ts Outdated
willleeney and others added 18 commits September 29, 2026 15:55
Mirrors the same removal in stackone-ai-python. Search is absent from the
sdk-conformance wire contract — the mock serves no /actions/search and no runner
exercises any search method — and the contract docs flag client-side search as
something that belongs server-side.

BREAKING: removes searchTools(), searchActionNames(), getSearchTool(), the
SearchTool class, the tool_search/tool_execute meta tools, openai({ mode:
'search_and_execute' }), the StackOneToolSet `search` option, and the
SearchMode / SearchConfig / SearchToolsOptions / SearchActionNamesOptions types.
Scope the catalog with `providers` / `actions` on fetchTools() instead.

Deletes semantic-search.ts, local-search.ts, utils/tfidf-index.ts and
utils/normalize.ts, and drops the now-unused @orama/orama dependency and its
catalog entry.

Methods were removed by brace-matching rather than line ranges, and knip caught
two leftovers the grep missed: an unresolved `normalizeActionName` import in
toolsets.ts and the orphaned DEFAULT_HYBRID_ALPHA constant.

  255 tests pass, no type errors, lint and knip clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fc3752a deleted buildTools but left two callers, so getTools() and
openai({ mode: 'search_and_execute' }) would have thrown
"this.buildTools is not a function" at runtime. Both were meta-tool entry
points with nothing behind them once search was removed, so both are gone.

Also drops scripts/benchmark-search.ts, which benchmarked the removed search
API, and an unused SearchTool import in toolsets.test.ts.

None of this was caught because the typechecker was not running: vitest spawns
tsc, the flake supplies typescript-go, and these commands were run outside
`nix develop`. Vitest reported "Type Errors: no errors" while also printing
"spawn tsc ENOENT" — so a typecheck that never ran looked like a clean one.
`tsc --noEmit` finds all four the moment it can actually execute.

Verified with a one-off typescript: typecheck clean, 255 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Defender is configured per project in the StackOne dashboard now, so the
SDK no longer carries its own copy of the setting or sends one with each
RPC call. Dropping it removes a second source of truth that could silently
override what the dashboard shows.

- Remove the `defender` constructor option, the `defenderMode` getter and
  the once-per-process override warning.
- Stop sending `defender_config` in the `/actions/rpc` payload.
- Remove the defender-config example and its tests, and the README section.

BREAKING CHANGE: `DefenderConfig`, `DefenderMode` and
`DEFAULT_DEFENDER_CONFIG` are no longer exported, the `defender` option of
`StackOneToolSetConfig` and `StackOneToolSet#defenderMode` are removed, and
RPC requests no longer carry `defender_config`. Configure Defender in the
StackOne dashboard.
`fetchTools()` appended a `tool_feedback` tool that the SDK built itself
and posted to `/ai/tool-feedback`. The server now serves a feedback tool of
its own, `stackone_submit_feedback`, only when feedback is enabled for the
project. A client-side stand-in reports success for feedback that goes
nowhere when the project has it off, and it skewed every tool count by one.

BREAKING CHANGE: `createFeedbackTool` is no longer exported and
`fetchTools()` no longer appends a `tool_feedback` tool. Use the
server-served `stackone_submit_feedback` tool (or the toolset's
`submitFeedback()` method, added later in this series).
The RPC tool merged the envelope's headers OVER its own with
`defu(extraHeaders, baseHeaders)`. Tool arguments are model-controlled, so
a call carrying `headers_x-account-id` (or a nested `headers` object)
replaced the account the tool was fetched for, both in the envelope and in
the `x-account-id` HTTP header the API reads: a prompt injection could run
any action against any tenant the API key reaches.

- Filter model-supplied headers through an allowlist built from the served
  schema's own `headers_*` properties, compared trimmed and
  case-insensitively, and reject values carrying CR/LF or other control
  characters.
- Apply the SDK's own headers after the merge, so `x-account-id` is always
  the tool's account.
- Serve the mock catalog through a low-level MCP `Server`. `McpServer`
  expects a Zod shape and listed every JSON Schema as `properties: {}`, so
  the tests never saw a declared parameter.

Regression tests cover the flat and nested injection paths and a declared
header that must survive.

BREAKING CHANGE: headers a tool call supplies are dropped unless the tool's
served schema declares them as a `headers_<name>` property. Undeclared
headers were previously forwarded in the RPC envelope.
`ToolSetError`, `ToolSetConfigError` and `ToolSetLoadError` extended
`Error` directly, so `error instanceof StackOneError` missed the two errors
a user is most likely to hit first: a missing API key and a catalog that
will not load. They now extend `StackOneError`, alongside
`StackOneAPIError`, and live in `src/utils/error-toolset.ts`.

BREAKING CHANGE: `ToolSetError` (and so `ToolSetConfigError` and
`ToolSetLoadError`) now extends `StackOneError`. Code that distinguished
the two with `instanceof StackOneError` must check the toolset errors
first.
`fileName` on a download result came straight from the server's
Content-Disposition, which whoever uploaded the file to the connected
provider chooses. A caller that writes the download to
`result.fileName` — the obvious thing to do — could be made to write
`../../.ssh/authorized_keys` or `/etc/cron.d/x`, and the RFC 5987 branch
percent-decoded, so `%2e%2e%2f` got through any filter applied earlier.

Port the Python SDK's `_safe_basename`, applied last, after decoding:

- treat `/`, `\` and `:` as separators (covers `C:evil.exe` and NTFS
  alternate data streams such as `report.pdf:payload`);
- strip control and Unicode format characters (the U+202E extension spoof);
- return null for an empty name, `.` or `..`;
- cap at 255 UTF-8 bytes on a character boundary, keeping the extension.

Parameters are now matched at a parameter boundary (`notfilename=` is
ignored), and the extended form decodes with its declared charset
(ISO-8859-1 included), falling back to UTF-8 for an unknown label, as the
Python SDK does.

The Python version leaves a name with no dot uncapped and does not treat
`:` inside a name as a separator; both are handled here.
Port the Python SDK 3.0 toolset. Tools are listed from the MCP endpoint
per account and executed over `/actions/rpc`, or over MCP `tools/call` for
the tools with no action behind them. Schemas are passed through as
served.

Listing and accounts
- An API key alone is enough: with no account configured, `fetchTools()`
  discovers the key's active accounts via `GET /accounts` (now public as
  `fetchAccounts()`). It used to send an unscoped request the API refuses.
- Accounts are listed concurrently, at most 10 at a time, with
  `param-style=flat_prefixed` pinned and pagination followed. A failing
  account is skipped with a warning unless every account fails.
- The cache holds listings, not tools: every call builds fresh tools on a
  deep copy of the schema, so one caller can no longer rescope or mutate
  another's. The key covers the account scope, mode, base URL and API key,
  and a generation guard stops an in-flight listing from repopulating the
  cache after `clearCatalogCache()`. Degraded listings are not cached.
- `toolMode` / `fetchTools({ mode })` select `search_execute`, whose meta
  tools execute over `tools/call`. `stackone_submit_feedback` executes
  over `tools/call` in every mode and is returned once, however many
  accounts list it.
- Provider filtering matches a full prefix (`browser_linkedin`), action
  globs follow `fnmatch`, and a name served by two accounts warns.

Execution
- RPC arguments are split into the envelope by their flat_prefixed
  location, decided once from the served schema: only when ALL declared
  keys are prefixed (otherwise a warning). A reserved container key given
  a scalar is rejected; prototype-named fields are plain data.
- Headers are allowlisted from the served `headers_*` properties on both
  the RPC and MCP paths. `Authorization`, `x-account-id` and `User-Agent`
  are set by the SDK after every merge, including the `headers` option.
- Every MCP call (listing and `tools/call`) and every RPC call honours
  `timeout`. MCP transport failures become `StackOneAPIError` with the
  HTTP status (e.g. 412 for a dead account); an `isError` result raises
  with the status from its payload. API errors lead with the server's own
  message and keep status and body.

Schemas
- `toJsonSchema()` is the served root verbatim (`$schema`, `$defs`,
  `title`, `additionalProperties`, `oneOf`, ...) with the served
  `required`. No internal marker is needed, so nothing named `nullable` is
  ever touched.
- `toOpenAI()`, `toAnthropic()`, `toOpenAIResponses()`, `toAISDK()` and
  `toClaudeAgentSdkTool()` fold a top-level `oneOf`/`anyOf`/`allOf`
  (which OpenAI and Anthropic reject) into the root. Strict Responses and
  the AI SDK close the root with `additionalProperties: false`.

Mocks refuse an unscoped `/mcp` or `/actions/rpc` request (400) and an
unknown account (404), serve schemas verbatim, `GET /accounts`, the
search/execute meta tools with a `session_id`, and optionally
`stackone_submit_feedback`. `defu` and the `zod` peer are no longer used.

BREAKING CHANGE: `RequestBuilder` and direct-HTTP execution are removed:
`ExecuteConfig` no longer has an `http` kind, `ParameterLocation` is no
longer exported, and `BaseTool#execute` throws unless overridden.
`BaseTool` no longer takes a `headers` constructor argument and loses
`getHeaders()`/`setHeaders()` and `connector`; `Tools#getConnectors()` is
removed. `StackOneToolSetConfig` loses `authentication`, `strict` and
`rpcClient`, and `AuthenticationConfig` and `BaseToolSetConfig` are no
longer exported. The constructor throws `ToolSetConfigError` without an
API key. `fetchTools()` returns fresh tool instances on every call (never
the cached `Tools`), ordered by account id, and discovers accounts when
none is configured. `ToolExecution` no longer carries `headers`. An RPC
timeout is a `StackOneError` rather than a `StackOneAPIError` with status
0, and `StackOneAPIError` no longer appends `responseBody.message` to the
message it is given. A `dryRun` result's `headers` are the HTTP request
headers, without `Authorization`.
The recommended surface: find an action in natural language, run it by
id, and record how it went, without loading a tool catalog into a model's
context. Ported from the Python SDK 3.0, plus the Node side of the
feedback/session contract.

- `search(query, { topK, accountIds })` queries every linked connector's
  `*_search_actions` meta tool (`tool-mode=search_execute`) concurrently
  and ranks the hits globally by `similarity_score`, rather than leaving
  them grouped by whichever connector answered first. `topK` is validated
  1..50 before any round trip. A connector that fails is skipped with a
  warning unless all do. Every hit carries the `session_id` of the search
  that produced it, when the server issued one.
- `execute(actionId, args?, { sessionId, accountIds })` routes to the
  longest connector whose name prefixes the action id, stripping the
  account id from the meta tool's name by identity (nanoid ids contain
  `_`). `session_id` is forwarded as a top-level argument only when given,
  and `action_id` is pinned last, so a model-supplied `action_id` in the
  arguments cannot swap the action. The `{ isError, result }` wrapper is
  unwrapped, and an `isError` result raises.
- `submitFeedback({ rating, toolNames, feedback?, category?, sessionId?,
  source?, accountIds? })` finds the server-served
  `stackone_submit_feedback` in the catalog (any mode) and calls it over
  `tools/call` with snake_case arguments, omitting unset keys rather than
  sending null. When the server does not serve it, it raises
  `ToolSetLoadError`: feedback is not enabled for the project.
The counterpart to `toOpenAI()`, ported from the Python SDK's
`execute_openai_tool_calls`: run a Chat Completions response's tool calls
and get back the `tool` messages to send to the model, in order.

A failed call does not throw. The error, and the server's response body
when there is one, become the message content, so the model can read why
and retry; an unknown tool or a non-function call is reported the same
way. Errors that are not the SDK's still propagate. File bytes are
base64-encoded rather than serialised as a byte array, as the Claude Agent
SDK handler now does too.
- Add a search-and-execute example (accounts, ranked search, input
  schema, execute with the hit's session id, submitFeedback) and a test
  that runs the real example file against the mock API with nothing but
  an API key.
- Complete the OpenAI example's tool-call loop with
  `executeOpenAIToolCalls()`, and show account discovery in the
  auth-management example.
- README: lead with search/execute; document account discovery and
  `fetchAccounts()`, tool modes, feedback, what each adapter does to the
  served schema (including strict `additionalProperties: false`), the
  header allowlist, the error hierarchy, timeouts, safe download names and
  the new `dryRun` shape. Fix the `Tools#setAccountId` snippet, which never
  existed, and drop `zod` from the install line now it is not a peer.
- Remove stale references left by the search removal: the Orama skill in
  CLAUDE.md and flake.nix, and `search-tools` in .env.example, which also
  now marks STACKONE_ACCOUNT_ID optional.
Mirror the Python SDK's conformance gate. The suite drives the built
`dist` against a mock of the Unified Cloud API and grades the wire
contract both SDKs share: RPC envelopes, schema pass-through (strict),
account discovery and search/execute over `tools/call`.

- `check-secret` lets fork PRs and Dependabot, which cannot read
  CONFORMANCE_REPO_TOKEN, skip the gate; everything else must pass it and
  fails if the token is missing or expired.
- `conformance` checks out StackOneHQ/sdk-conformance pinned to a full
  SHA, builds the SDK and runs `pnpm test:node -- --strict-schema` with
  NODE_SDK_DIST pointed at the built dist.
- `ci-ok` aggregates every job into the one status check to require, so
  renaming a job or adding a matrix entry cannot silently drop a check.
  There was no aggregate job before.

Actions are pinned to full SHAs with version comments, as elsewhere.
Per-action tools posted to /actions/rpc, so the SDK split each tool
call into the RPC envelope itself and had to pin param-style so its
split matched the listed schema. Every tool now executes over tools/call
on the endpoint and account that listed it, and the server maps the
arguments with its own reverse map. The only request outside MCP is
GET /accounts for discovery.

A tool returns the server's result exactly as the server wrote it. For
an action tool, *_execute_action and feedback that is { isError: false,
result, defenderMetadata?, policyMetadata? }, while search results are
bare JSON. isError still raises StackOneAPIError. The unwrap that lived
only in execute() is gone, so execute() and tool.execute() return the
same shape for the same action. That unwrap also dropped defender
metadata.

The header allowlist reads a nested `headers` object as well as flat
headers_* properties, because the served style is no longer pinned.

BREAKING CHANGE: StackOneRpcTool, RpcExecuteConfig, BinaryDownloadResult
and isBinaryDownloadResult are removed. Action results are the server's
{ isError, result, ... } object rather than the /actions/rpc body. A
file action's `result` is the server's single-use { download_url,
expires_at, file } instead of bytes, and the call raises
StackOneAPIError with status 501 when no link can be issued. The /mcp
URL no longer pins param-style=flat_prefixed. dryRun returns
{ url, method: 'tools/call', name, arguments }.
The README described RPC execution, an RPC dryRun and byte downloads. It now shows the tools/call dryRun and the download link a file action returns, and says to reduce the provider-chosen filename before writing it.
StackOneHQ/sdk-conformance#5 grades execution over tools/call, forbids /actions/rpc (invariant 13) and grades results as the server wrote them. The previous pin still graded /actions/rpc envelopes, which this SDK no longer sends.
@willleeney
willleeney force-pushed the refactor/mcp-only-toolset branch from 28cb186 to 7b14868 Compare September 29, 2026 15:57
@willleeney willleeney changed the title refactor!: MCP-only toolset with root schema pass-through refactor!: MCP-only toolset — every tool over tools/call, with search/execute sessions and submitFeedback() Sep 29, 2026
The root project type-checks tests/, and the example's e2e test imported it by a literal path, so tsc followed it into examples/search-and-execute.ts. Its '@stackone/ai' import resolves only when the examples workspace is installed, which the ai-peer-range jobs skip, so they failed. The test now imports the example by a computed URL. Also formats src/toolsets.search-execute.test.ts the way CI's oxfmt 0.23.0 does.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Re-trigger cubic

fd862c1 hid the example from the root typecheck by importing it through a computed URL, so tsc
stopped checking it at all. Import it by its literal path again and map '@stackone/ai' to
./src/index.ts in the root tsconfig instead: the example now resolves the package to source
whether or not the examples workspace is installed, which the ai-peer-range jobs skip.

The three "'error' is of type 'unknown'" errors were fallout of the unresolved import, not
defects in the example. The mapping only affects type resolution for files that import the
package by name, which src never does, so the built dist and its published types are unchanged.
A header argument is an entry of a top-level `headers` object, or a top-level `headers_<name>`
argument. Each is now forwarded only when the served schema declares it: `<name>` under
`properties.headers.properties`, or the `headers_<name>` property itself. A `headers` property
served as an object with no `properties` (as on every `*_execute_action`) declares any name, so
`toolset.execute()` can now pass host-set headers such as `x-custom` through to the action.

`Authorization`, `x-account-id` and `User-Agent` are never forwarded as header arguments, even
when declared. Previously a `headers_<name>` argument went to tools/call unfiltered, and a flat
`headers_<name>` declaration also let the nested `headers.<name>` through.

Every other argument goes to tools/call unchanged. Each dropped header argument now warns with
the real reason, "not declared by the schema" or "set by the SDK", instead of claiming every
drop was undeclared.

The tenant-switch regression test now asserts the arguments tools/call received, not only the
transport's x-account-id.

BREAKING CHANGE: a `headers_<name>` tool argument is dropped with a warning unless the served
schema declares `headers_<name>`, and is never forwarded for `authorization`, `x-account-id` or
`user-agent`. A nested `headers.<name>` is forwarded only when `properties.headers` declares it
(or is an open object); a flat `headers_<name>` declaration no longer admits it.
submitFeedback() listed the catalog in the toolset's own mode across every scoped account.
The feedback tool is global, so it now lists `tool-mode=search_execute` (two meta tools per
connector rather than every action) on a single account and makes exactly one tools/call there.

That account is the first in the order given, not the first by id: the first of the call's
`accountIds`, else the toolset's, else the first active account `GET /accounts` lists. Account
scoping keeps a separate, unsorted path for this; listing still sorts its scope for the cache key.
Two accounts can serve the same tool name. The Tools unit test pins first-match-wins; this pins
it end to end: the listing is ordered by account id whatever order the caller named them in, so
the duplicate getTool() returns is predictable.
Nothing has called it since the RPC client went: tools/call failures build their message from
the result's own text in mcp-client.ts. Drop it and its tests, and stop the README promising the
"400 Bad Request: path.id is missing" message format it produced.
…nt calls

The discovered accounts were cached only once the request returned, so search() and fetchTools()
started together on a fresh toolset each sent their own GET /accounts. Share the in-flight
promise instead. A failed discovery is not kept, so the next call retries, and
clearCatalogCache() drops an in-flight one as it already drops the result.
JSON.stringify(undefined) returns undefined, not a string, so a hand-built tool that returned
nothing produced a tool message with `content: undefined` from executeOpenAIToolCalls(), and an
empty text part from the Claude Agent SDK adapter. Write "null" instead, which is what the Python
SDK sends for None (`json.dumps(None)`).
SearchResult typed only action_id and session_id, so reading a hit's score or schema needed a
typeof guard, and passing `best.example_request` to execute() (as the README does) did not
type-check. Declare `description`, `similarity_score`, `input_schema` and `example_request`
as the per-connector `*_search_actions` tool serves them. All are optional: the server omits
input_schema and example_request for an action with no inputs, and the SDK passes hits through
without validating them. The example reads the score without narrowing.
Every breaking change since 2.10.0, each with the 2.x code and its 3.0 replacement: removed
exports, constructor options, server-side search and execute, the result shape and download
links, the header-argument allowlist, account discovery and ordering, submitFeedback(), the
error hierarchy and StackOneAPIError messages, schema pass-through, and hand-built tools.
Structured like the Python SDK's MIGRATION.md so the two read the same. Linked from the top of
the README.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 18 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. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="examples/search-and-execute.ts">

<violation number="1" location="examples/search-and-execute.ts:41">
P2: `similarity_score ?? 0` only guards null/undefined, but search hits are passed through unvalidated and can carry a non-number score. The library's own `scoreOf()` (src/toolsets.ts) and its test (`tolerating bad scores`, `similarity_score: '0.99'`) both protect against this: a string score yields `'0.99'.toFixed(3)`, which throws `TypeError: '0.99'.toFixed is not a function` and crashes the example. Keep the typeof guard (or use its number check) instead of the nullish coalescing.</violation>
</file>

<file name="src/toolsets.ts">

<violation number="1" location="src/toolsets.ts:903">
P2: This truthiness check discards an explicitly selected empty account ID, causing `submitFeedback()` to use a different configured or discovered account. Pass the selected ID whenever it is defined, or reject empty IDs before routing, so feedback cannot silently switch accounts.</violation>
</file>

Requires human review: Auto-approval skipped because cubic reviewed only this push, not the earlier force-push. Comment @cubic review to review the whole pull request.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


console.log('\nRanked matches:');
for (const action of actions) {
console.log(` ${(action.similarity_score ?? 0).toFixed(3)} ${action.action_id}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: similarity_score ?? 0 only guards null/undefined, but search hits are passed through unvalidated and can carry a non-number score. The library's own scoreOf() (src/toolsets.ts) and its test (tolerating bad scores, similarity_score: '0.99') both protect against this: a string score yields '0.99'.toFixed(3), which throws TypeError: '0.99'.toFixed is not a function and crashes the example. Keep the typeof guard (or use its number check) instead of the nullish coalescing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At examples/search-and-execute.ts, line 41:

<comment>`similarity_score ?? 0` only guards null/undefined, but search hits are passed through unvalidated and can carry a non-number score. The library's own `scoreOf()` (src/toolsets.ts) and its test (`tolerating bad scores`, `similarity_score: '0.99'`) both protect against this: a string score yields `'0.99'.toFixed(3)`, which throws `TypeError: '0.99'.toFixed is not a function` and crashes the example. Keep the typeof guard (or use its number check) instead of the nullish coalescing.</comment>

<file context>
@@ -38,8 +38,7 @@ const searchAndExecute = async (): Promise<void> => {
 	for (const action of actions) {
-		const score = typeof action.similarity_score === 'number' ? action.similarity_score : 0;
-		console.log(`  ${score.toFixed(3)}  ${action.action_id}`);
+		console.log(`  ${(action.similarity_score ?? 0).toFixed(3)}  ${action.action_id}`);
 	}
 
</file context>

Comment thread src/toolsets.ts
// connector where individual mode lists every action.
const [accountId] = await this.#accountsInOrder(options.accountIds);
const tool = (
await this.fetchTools({ accountIds: accountId ? [accountId] : [], mode: 'search_execute' })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This truthiness check discards an explicitly selected empty account ID, causing submitFeedback() to use a different configured or discovered account. Pass the selected ID whenever it is defined, or reject empty IDs before routing, so feedback cannot silently switch accounts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/toolsets.ts, line 903:

<comment>This truthiness check discards an explicitly selected empty account ID, causing `submitFeedback()` to use a different configured or discovered account. Pass the selected ID whenever it is defined, or reject empty IDs before routing, so feedback cannot silently switch accounts.</comment>

<file context>
@@ -860,9 +894,14 @@ export class StackOneToolSet {
+		// connector where individual mode lists every action.
+		const [accountId] = await this.#accountsInOrder(options.accountIds);
+		const tool = (
+			await this.fetchTools({ accountIds: accountId ? [accountId] : [], mode: 'search_execute' })
+		).getTool(SUBMIT_FEEDBACK_TOOL_NAME);
 		if (!tool) {
</file context>
Suggested change
await this.fetchTools({ accountIds: accountId ? [accountId] : [], mode: 'search_execute' })
await this.fetchTools({ accountIds: [accountId], mode: 'search_execute' })

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants