diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7539f55c..ec4583ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -146,7 +146,7 @@ jobs: env: # Pinned to a commit on stackone-ai-python's main, not the branch, so a Python change never # silently changes what this job grades. Bump it deliberately. - PYTHON_SDK_REF: 049abf548c7f5448a89e7999e95336980d23afc3 + PYTHON_SDK_REF: d76c26663c9141bf106a19d385252da9a8c7e75d steps: - name: Check conformance token is configured env: @@ -166,7 +166,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: StackOneHQ/sdk-conformance - ref: 5f60cc0dfdf4d1a25afad93a68eeefe30e89568d + ref: 4ead1c25bb443ef1fd1986d5f053b2d1475d0e90 token: ${{ secrets.CONFORMANCE_REPO_TOKEN }} path: sdk-conformance persist-credentials: false @@ -187,8 +187,9 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # pnpm from sdk-conformance's packageManager; in each SDK checkout pnpm switches to that + # repo's own packageManager version, so one setup serves all three installs. with: - version: 10.26.0 package_json_file: sdk-conformance/package.json # The SDK's devEngines pins the exact Node version, and the suite runs on it too. diff --git a/README.md b/README.md index fae716e2..30ab93e8 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ for await (const message of result) { ### Search and execute -`search()` asks every linked connector for actions matching a natural-language query and ranks the results together, best first. `execute()` runs one by id, through the connector that serves it. +`search()` makes one call to StackOne's `stackone_search_actions` tool, which searches every linked connector for actions matching a natural-language query and ranks them together, best first. `execute()` runs one by id with one call to `stackone_execute_action`. Each call spans every account in scope, up to 50; more throws `ToolSetConfigError` before any request, so pass `accountIds` to narrow the scope. ```typescript const actions = await toolset.search('create a time-off request', { topK: 5 }); @@ -309,17 +309,16 @@ const [best] = actions; if (!best) throw new Error('No action matched'); // `input_schema` describes what the action accepts; `example_request` shows the shape const result = await toolset.execute(best.action_id, best.example_request, { - accountIds: [best.account_id], sessionId: best.session_id, }); ``` - `execute()` takes the nested envelope an action's `example_request` shows: `{ path, query, body }`. (A `fetchTools()` tool takes the keys its own served schema names instead: read them from `tool.parameters.properties`.) -- The connector is the longest one whose name prefixes the action id, so `browser_linkedin_*` actions are not routed to `browser`. - `action_id` is always the one you pass: an `action_id` inside `args` — for example, one a prompt-injected model put there — is ignored. - Each hit carries the `session_id` of the search that found it, when the server issued one. Pass it back as `sessionId` to link the calls together. -- Each hit carries the `account_id` of the account that found it, and the same action linked on two accounts is two hits. When an action's connector is linked on more than one account, `execute()` throws `ToolSetConfigError` rather than pick one: pass the hit's account as `{ accountIds: [hit.account_id] }`. Likewise, if an account in scope failed to list and its provider is the action's connector or unknown, `execute()` lists it again at once (asking `GET /accounts` first for any provider it does not know, and throwing its 429 if rate limited; an account that lookup failed to name is neither asked about nor listed early again for 30 seconds), and if it still fails, throws `ToolSetLoadError` rather than run the action on whichever account did list. A failed account on another provider is not listed again early, so it never costs the call a listing timeout, though learning its provider with explicit ids takes one `GET /accounts`; like any account, it is listed again once its 30 seconds are up. -- `topK` (1–50, default 10) caps the whole result, after ranking across every connector. A connector that fails to search is skipped with a warning, unless every connector fails. +- Each hit names its `provider` and the accounts that can run it in `available_accounts`, so an action linked on two accounts is one hit. With one account in scope, `available_accounts` holds just that account's `id`. When the action's connector is linked on more than one account in scope, the server refuses to pick one and `execute()` throws `StackOneAPIError`: pass the account to use as `{ accountIds: [id] }`, one of the hit's `available_accounts`. +- `topK` (1–50, default 10) caps the whole result. +- If the server does not serve the search or execute tool (the project's policy disables search and execute), `search()` and `execute()` throw `ToolSetLoadError`. [View full example](examples/search-and-execute.ts) @@ -346,13 +345,16 @@ await toolset.submitFeedback({ // One tool per action (the server default) const toolset = new StackOneToolSet(); -// Two meta tools per connector instead: `*_search_actions` and `*_execute_action` +// StackOne's search and execute tools instead: `stackone_search_actions` and +// `stackone_execute_action`, spanning every account in scope const compact = new StackOneToolSet({ toolMode: 'search_execute' }); const metaTools = await compact.fetchTools(); ``` `search_execute` keeps the catalog small however many accounts are linked, which suits handing the tools to a model directly. A single call can override the mode with `fetchTools({ mode })`. +The tools have the same names whatever the scope. With several accounts the server also serves `stackone_list_accounts`, and the two tools take a `stackone_account_id` to choose among them. With one account the server serves a pair named for that account (`__search_actions` and `_execute_action`), and the SDK presents it as `stackone_search_actions` and `stackone_execute_action`, with those names in its descriptions and results. A `stackone_account_id` or `provider` that names the account or its connector is dropped; any other throws `ToolArgumentsError` before any request. + ### Filtering Tools with fetchTools() You can filter tools by account IDs, providers, and action patterns: @@ -406,7 +408,7 @@ The catalog is cached per account scope and mode, so changing a filter never ref ### Headers -A tool call's header arguments — the entries of a `headers` object argument, and `headers_` arguments — are forwarded only if the tool's served schema declares them: `` under `properties.headers.properties`, or a `headers_` property. A `headers` property served as an open object — `type: "object"`, no `properties`, and `additionalProperties` not `false` — as on every `*_execute_action`, declares any name, so `toolset.execute(id, { headers: { 'x-custom': '…' } })` forwards it. Anything undeclared is dropped with a warning; every other argument is sent unchanged. +A tool call's header arguments — the entries of a `headers` object argument, and `headers_` arguments — are forwarded only if the tool's served schema declares them: `` under `properties.headers.properties`, or a `headers_` property. A `headers` property served as an open object — `type: "object"`, no `properties`, and `additionalProperties` not `false` — as on `stackone_execute_action`, declares any name, so `toolset.execute(id, { headers: { 'x-custom': '…' } })` forwards it. Anything undeclared is dropped with a warning; every other argument is sent unchanged. A top-level `headers` argument that isn't a plain object is dropped with a warning, unless the served schema declares `headers` itself as a non-object field (for example `type: "string"`), in which case it's an ordinary argument that happens to be named `headers` and is sent as given. A `headers_` argument is dropped with a warning when its value is an array or object — only a string, number or boolean can be a header value. diff --git a/examples/search-and-execute.ts b/examples/search-and-execute.ts index 697bf88d..8a7bc3ca 100644 --- a/examples/search-and-execute.ts +++ b/examples/search-and-execute.ts @@ -1,8 +1,9 @@ /** * Find an action in natural language and run it, without loading a tool catalog. * - * This is the recommended way to use the SDK: search() asks every linked connector and returns - * ranked actions, so a catalog of hundreds of tools never has to fit in a model's context. + * This is the recommended way to use the SDK: search() makes one call across every linked account + * and returns ranked actions, so a catalog of hundreds of tools never has to fit in a model's + * context. * * Prerequisites: STACKONE_API_KEY and at least one active linked account. No account id is * needed — the toolset discovers the ones your key can reach. @@ -32,8 +33,10 @@ const printAccounts = (accounts: StackOneAccount[]): void => { const printRankedMatches = (actions: SearchResult[]): void => { console.log('\nRanked matches:'); for (const action of actions) { + const accounts = (action.available_accounts ?? []).map((account) => account.id).join(', '); // oxlint-disable-next-line typescript/no-unnecessary-type-conversion -- a server can send the score as a numeric string - console.log(` ${Number(action.similarity_score ?? 0).toFixed(3)} ${action.action_id}`); + const score = Number(action.similarity_score ?? 0).toFixed(3); + console.log(` ${score} ${action.action_id} [${accounts}]`); } }; @@ -57,13 +60,14 @@ const searchAndExecute = async (): Promise => { console.log(`\ninput_schema for ${best.action_id}:`); console.log(JSON.stringify(best.input_schema ?? {}, null, 2).slice(0, 600)); - // accountIds pins the call to the account that found the hit; session_id links it to the - // search. + // The hit lists the accounts that can run it. With more than one, accountIds chooses; with one, + // the server picks it. session_id links the call to the search. + const [account] = best.available_accounts ?? []; const result = await toolset.execute( best.action_id, { query: { limit: 2 } }, { - accountIds: [best.account_id], + accountIds: account === undefined ? null : [account.id], sessionId: best.session_id, }, ); diff --git a/mocks/mcp-server.ts b/mocks/mcp-server.ts index f92760cb..ecc53c50 100644 --- a/mocks/mcp-server.ts +++ b/mocks/mcp-server.ts @@ -5,6 +5,13 @@ * * It refuses what the real endpoint refuses — an unscoped request, an unknown account — so a * client bug that sends no account, or the wrong one, fails here instead of in production. + * + * `x-account-id` may name several accounts, comma-separated, in the header or the query, as the + * real endpoint allows; more than 50 is refused with a 400. Under `tool-mode=search_execute` it + * serves what the real endpoint serves, with its handlers' shapes and error texts: for two or more + * accounts the account-spanning trio — `stackone_list_accounts`, `stackone_search_actions`, + * `stackone_execute_action` — and for one account that account's pair, + * `__search_actions` and `__execute_action`. */ import type { Hono as HonoApp } from 'hono'; import { StreamableHTTPTransport } from '@hono/mcp'; @@ -13,6 +20,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Hono } from 'hono'; import { basicAuth } from 'hono/basic-auth'; +import { sorted } from '../src/utils/array'; import { isRecord } from './handlers.utils'; export interface McpToolDefinition { @@ -29,6 +37,7 @@ export interface McpToolDefinition { /** A `tools/call` exactly as it reached the server. */ export interface RecordedToolCall { + /** Every account the request named, deduplicated and comma-joined. */ accountId: string; toolMode: string | undefined; name: string; @@ -38,6 +47,11 @@ export interface RecordedToolCall { export interface MockMcpServerOptions { /** Tools available per account ID. An account not listed here is refused with a 404. */ accountTools: Record; + /** + * The provider of each account, for `search_execute`: `mock` unless named here. Each provider + * serves one action, `_list_items`. + */ + accountProviders?: Record; /** * Serve the global `stackone_submit_feedback` tool, in every tool mode, the way the real * endpoint does when the org flag and project setting are on. Off models a project without @@ -74,33 +88,42 @@ const submitFeedbackTool = { }, } as const satisfies McpToolDefinition; -/** The two meta tools the real endpoint serves per connector under `tool-mode=search_execute`. */ -const metaTools = (accountId: string): McpToolDefinition[] => [ +/** The account-spanning tools the real endpoint serves under `tool-mode=search_execute`, for 2+ accounts. */ +const sessionTools: McpToolDefinition[] = [ { - name: `mock_${accountId}_search_actions`, - description: 'Search for available actions in natural language.', + name: 'stackone_list_accounts', + description: + 'Lists the accounts connected to this session as `{ id, name, connector }` objects.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'stackone_search_actions', + description: 'Search for available actions across every connected integration.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, top_k: { type: 'integer', minimum: 1, maximum: 50 }, session_id: { type: 'string' }, + provider: { type: 'string' }, + stackone_account_id: { type: 'string' }, }, required: ['query'], }, }, { - name: `mock_${accountId}_execute_action`, + name: 'stackone_execute_action', description: 'Execute an action by its action_id.', inputSchema: { type: 'object', properties: { action_id: { type: 'string' }, - path: { type: 'object' }, - query: { type: 'object' }, - body: { type: 'object' }, - headers: { type: 'object' }, + path: { type: 'object', additionalProperties: {} }, + query: { type: 'object', additionalProperties: {} }, + body: { type: 'object', additionalProperties: {} }, + headers: { type: 'object', additionalProperties: { type: 'string' } }, session_id: { type: 'string' }, + stackone_account_id: { type: 'string' }, }, required: ['action_id'], }, @@ -125,35 +148,289 @@ const wrapped = (result: unknown): CallToolResult => { }; }; -const callMetaTool = (name: string, args: Record): CallToolResult | undefined => { - if (name.endsWith('_search_actions')) { +/** An account of a `search_execute` session, as the real handlers name it. */ +interface SessionAccount { + id: string; + provider: string; +} + +/** `name (id)` for each account, as the real handlers list the choices. The mock's name is the id. */ +const choices = (accounts: readonly SessionAccount[]): string => + accounts.map(({ id }) => `${id} (${id})`).join(', '); + +/** An `isError` result shaped as the real handlers' `buildErrorCallToolResult`. */ +const invalid = (error: string, errorCode: string): CallToolResult => + text( + { + error, + status_code: 400, + error_code: errorCode, + error_category: 'validation', + retryable: false, + }, + true, + ); + +/** A requested `stackone_account_id`, trimmed, or undefined when none was given. */ +const requestedAccount = (args: Record): string | undefined => + typeof args.stackone_account_id === 'string' && args.stackone_account_id.trim() !== '' + ? args.stackone_account_id.trim() + : undefined; + +/** + * A trio tool call, answered as the real session handlers answer it: search ranks one + * `_list_items` per provider in scope; execute routes an action to the one account on + * its provider, or asks for `stackone_account_id` when there are several. + */ +const callTrioTool = ( + name: string, + args: Record, + accounts: readonly SessionAccount[], +): CallToolResult | undefined => { + switch (name) { + case 'stackone_list_accounts': + return wrapped({ + accounts: accounts.map(({ id, provider }) => ({ id, name: id, connector: provider })), + }); + case 'stackone_search_actions': + return searchTrio(args, accounts); + case 'stackone_execute_action': + return executeTrio(args, accounts); + default: + return undefined; + } +}; + +/** The refusal of an account id outside the session. */ +const unknownAccount = (accounts: readonly SessionAccount[], id: string): CallToolResult => + invalid( + `Account "${id}" is not part of this session. Choose one of [${choices(accounts)}] (or call stackone_list_accounts to list them).`, + 'INVALID_ACCOUNT', + ); + +/** `stackone_search_actions`: one `_list_items` hit per provider searched. */ +const searchTrio = ( + args: Record, + accounts: readonly SessionAccount[], +): CallToolResult => { + const pinned = requestedAccount(args); + if (pinned !== undefined && !accounts.some(({ id }) => id === pinned)) { + return unknownAccount(accounts, pinned); + } + const searched = accounts.filter( + ({ id, provider }) => + (pinned === undefined || id === pinned) && + (typeof args.provider !== 'string' || provider === args.provider), + ); + const searchedProviders = sorted(new Set(searched.map(({ provider }) => provider))); + const topK = typeof args.top_k === 'number' ? args.top_k : 10; + const actions = searchedProviders.slice(0, topK).map((provider) => ({ + action_id: `${provider}_list_items`, + provider, + available_accounts: searched + .filter((account) => account.provider === provider) + .map(({ id }) => ({ id, name: id })), + description: 'List items', + similarity_score: 0.8, + example_request: { query: { page_size: 25 } }, + })); + return text({ + providers_searched: searchedProviders, + session_id: MOCK_SEARCH_SESSION_ID, + actions, + total_count: actions.length, + hint: 'To run an action, call stackone_execute_action with its action_id exactly as shown above.', + }); +}; + +/** `stackone_execute_action`: the action run on the account it routes to. */ +const executeTrio = ( + args: Record, + accounts: readonly SessionAccount[], +): CallToolResult => { + const actionId = typeof args.action_id === 'string' ? args.action_id.trim() : ''; + const providers = sorted(new Set(accounts.map(({ provider }) => provider))); + const provider = sorted( + providers.filter((candidate) => actionId.startsWith(`${candidate}_`)), + (left, right) => right.length - left.length, + )[0]; + if (provider === undefined) { + return invalid( + `Unknown action_id "${actionId}" — it does not match any integration connected to this session ([${providers.join(', ')}]). Call stackone_search_actions first and use an action_id exactly as returned.`, + 'INVALID_ACTION', + ); + } + const account = executionAccount(requestedAccount(args), actionId, provider, accounts); + if ('content' in account) { + return account; + } + // An unknown action must come back as isError — a normal response with the flag set — + // the way the real endpoint reports it. + if (actionId !== `${provider}_list_items`) { + return text({ error: `Unknown action ${actionId}`, status_code: 404 }, true); + } + return wrapped({ + data: { nodes: [] }, + account_id: account.id, + echoed_query: args.query ?? null, + echoed_session_id: args.session_id ?? null, + }); +}; + +/** + * The account an action runs on: the pinned one, or else the one account on its provider. When + * there is none, the refusal the real handler answers with. + */ +const executionAccount = ( + pinned: string | undefined, + actionId: string, + provider: string, + accounts: readonly SessionAccount[], +): SessionAccount | CallToolResult => { + if (pinned === undefined) { + const onProvider = accounts.filter((account) => account.provider === provider); + return onProvider.length === 1 + ? onProvider[0] + : invalid( + `stackone_execute_action runs against multiple accounts — pass "stackone_account_id", one of [${choices(onProvider)}] (or call stackone_list_accounts to list them).`, + 'INVALID_ACCOUNT', + ); + } + const account = accounts.find(({ id }) => id === pinned); + if (account === undefined) { + return unknownAccount(accounts, pinned); + } + if (account.provider !== provider) { + return invalid( + `Account "${pinned}" belongs to the ${account.provider} integration, but action "${actionId}" belongs to ${provider}. Pass a ${provider} account id (or call stackone_list_accounts to list the connected accounts).`, + 'INVALID_ACCOUNT', + ); + } + return account; +}; + +/** + * The pair the real endpoint serves for a single account under `search_execute`: named for the + * account's provider and id, each description naming the other tool, and with no + * `stackone_account_id` or `provider` argument. + */ +const pairTools = ({ id, provider }: SessionAccount): McpToolDefinition[] => [ + { + name: `${provider}_${id}_search_actions`, + description: `Search the ${provider} actions of account ${id}. Run one with ${provider}_${id}_execute_action.`, + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + top_k: { type: 'integer', minimum: 1, maximum: 50 }, + session_id: { type: 'string' }, + }, + required: ['query'], + }, + }, + { + name: `${provider}_${id}_execute_action`, + description: `Execute an action found by ${provider}_${id}_search_actions.`, + inputSchema: { + type: 'object', + properties: { + action_id: { + type: 'string', + description: `An action_id exactly as ${provider}_${id}_search_actions returned it.`, + }, + path: { type: 'object', additionalProperties: {} }, + query: { type: 'object', additionalProperties: {} }, + body: { type: 'object', additionalProperties: {} }, + headers: { type: 'object', additionalProperties: { type: 'string' } }, + session_id: { type: 'string' }, + }, + required: ['action_id'], + }, + }, +]; + +/** + * A pair tool call, answered as the real per-account handlers answer it: search returns the + * account's one action, without `provider` or `available_accounts` on the hit; execute runs it on + * the account. An argument the pair does not serve is refused, so a client that forwards the + * trio's `stackone_account_id` or `provider` fails here. + */ +const callPairTool = ( + name: string, + args: Record, + { id, provider }: SessionAccount, +): CallToolResult | undefined => { + const unserved = ['stackone_account_id', 'provider'].find((key) => Object.hasOwn(args, key)); + if (unserved !== undefined) { + return invalid(`Unexpected argument "${unserved}"`, 'INVALID_ARGUMENT'); + } + + if (name === `${provider}_${id}_search_actions`) { return text({ + provider, session_id: MOCK_SEARCH_SESSION_ID, actions: [ { - action_id: 'mock_list_items', + action_id: `${provider}_list_items`, description: 'List items', similarity_score: 0.8, example_request: { query: { page_size: 25 } }, }, ], + total_count: 1, + hint: `To run an action, call ${provider}_${id}_execute_action with its action_id exactly as shown above.`, }); } - if (name.endsWith('_execute_action')) { - // An unknown action must come back as isError — a normal response with the flag set — - // the way the real endpoint reports it. - if (args.action_id !== 'mock_list_items') { - return text({ error: `Unknown action ${String(args.action_id)}`, status_code: 404 }, true); - } - return wrapped({ - data: { nodes: [] }, - echoed_query: args.query ?? null, - echoed_session_id: args.session_id ?? null, - }); + + if (name === `${provider}_${id}_execute_action`) { + return executePair(args, { id, provider }); } return undefined; }; +/** A pair's `*_execute_action`: its one action run on its account. */ +const executePair = ( + args: Record, + { id, provider }: SessionAccount, +): CallToolResult => { + const actionId = typeof args.action_id === 'string' ? args.action_id.trim() : ''; + if (actionId !== `${provider}_list_items`) { + return text({ error: `Unknown action ${actionId}`, status_code: 404 }, true); + } + return wrapped({ + data: { nodes: [] }, + account_id: id, + echoed_query: args.query ?? null, + echoed_session_id: args.session_id ?? null, + }); +}; + +/** A `search_execute` tool call: the pair's for one account, the trio's for several. */ +const callSessionTool = ( + name: string, + args: Record, + accounts: readonly SessionAccount[], +): CallToolResult | undefined => + accounts.length === 1 + ? callPairTool(name, args, accounts[0]) + : callTrioTool(name, args, accounts); + +/** + * The accounts a request names: its `x-account-id` header, or else its `x-account-id` query + * parameters, each a comma-separated list. Deduplicated, in the order named. + */ +const requestedAccountIds = ( + header: string | undefined, + queries: string[] | undefined, +): string[] => [ + ...new Set( + (header !== undefined && header !== '' ? [header] : (queries ?? [])) + .flatMap((value) => value.split(',')) + .map((id) => id.trim()) + .filter((id) => id !== ''), + ), +]; + /** The feedback tool's answer: recorded, against the session it names. */ const feedbackRecorded = (args: Record): CallToolResult => wrapped({ @@ -210,10 +487,23 @@ const recordToolCalls = async ( * ``` */ export function createMcpApp(options: MockMcpServerOptions): HonoApp { - const { accountTools, submitFeedback = false, onToolCall, toolResults = {} } = options; - const listedTools = (accountId: string, toolMode: string | undefined): McpToolDefinition[] => [ + const { + accountTools, + accountProviders = {}, + submitFeedback = false, + onToolCall, + toolResults = {}, + } = options; + const listedTools = ( + sessionAccounts: readonly SessionAccount[], + toolMode: string | undefined, + ): McpToolDefinition[] => [ ...(submitFeedback ? [submitFeedbackTool] : []), - ...(toolMode === 'search_execute' ? metaTools(accountId) : accountTools[accountId]), + ...(toolMode === 'search_execute' + ? sessionAccounts.length === 1 + ? pairTools(sessionAccounts[0]) + : sessionTools + : sessionAccounts.flatMap(({ id }) => accountTools[id] ?? [])), ]; // Create a Hono app that handles MCP protocol @@ -232,8 +522,11 @@ export function createMcpApp(options: MockMcpServerOptions): HonoApp { // The real endpoint rejects a request with no account. Defaulting here made the mock more // permissive than production and hid a fatal SDK bug: an SDK that never sent the header // still got a full catalog. - const accountId = c.req.header('x-account-id') ?? c.req.query('x-account-id'); - if (accountId === undefined || accountId === '') { + const accountIds = requestedAccountIds( + c.req.header('x-account-id'), + c.req.queries('x-account-id'), + ); + if (accountIds.length === 0) { return c.json( { statusCode: 400, message: 'Missing x-account-id header or query parameter in request' }, 400, @@ -241,12 +534,21 @@ export function createMcpApp(options: MockMcpServerOptions): HonoApp { } // No fallback catalog either: serving one for an account that does not exist would hide // any bug that sends a wrong, stale or mangled account id. - if (!Object.hasOwn(accountTools, accountId)) { - return c.json({ statusCode: 404, message: `Unknown account ${accountId}` }, 404); + if (accountIds.length > 50) { + return c.json({ statusCode: 400, message: 'x-account-id names more than 50 accounts' }, 400); } + const unknown = accountIds.find((id) => !Object.hasOwn(accountTools, id)); + if (unknown !== undefined) { + return c.json({ statusCode: 404, message: `Unknown account ${unknown}` }, 404); + } + const accountId = accountIds.join(','); + const sessionAccounts = accountIds.map((id) => ({ + id, + provider: accountProviders[id] ?? 'mock', + })); const toolMode = c.req.query('tool-mode'); - const listed = listedTools(accountId, toolMode); + const listed = listedTools(sessionAccounts, toolMode); // A low-level Server rather than McpServer: McpServer.registerTool expects a Zod shape and // lists a plain JSON Schema as `properties: {}`. The real endpoint serves each schema @@ -273,10 +575,10 @@ export function createMcpApp(options: MockMcpServerOptions): HonoApp { return feedbackRecorded(args); } // An action tool echoes what reached it, so a test can see the arguments and account. - // Only search_execute serves meta tools: in individual mode a per-action tool whose - // name ends in `_execute_action` is still an action. return ( - (toolMode === 'search_execute' ? callMetaTool(name, args) : undefined) ?? + (toolMode === 'search_execute' + ? callSessionTool(name, args, sessionAccounts) + : undefined) ?? wrapped({ data: { action: name, account_id: accountId, arguments: args } }) ); }); diff --git a/src/account-pair.ts b/src/account-pair.ts new file mode 100644 index 00000000..87a511d6 --- /dev/null +++ b/src/account-pair.ts @@ -0,0 +1,180 @@ +/** + * The one-account pair, presented as the trio's search and execute tools. + * + * Asked for `search_execute` over a single account, the MCP endpoint serves + * `__search_actions` and `__execute_action` instead + * of the trio. The SDK presents them as `stackone_search_actions` and `stackone_execute_action`, + * so a model, and `search()` / `execute()`, see one pair of names however many accounts are in + * scope. + */ +import { EXECUTE_ACTION_TOOL_NAME, SEARCH_ACTIONS_TOOL_NAME } from './consts'; +import type { McpToolDefinition } from './mcp-client'; +import { StackOneMcpTool } from './tool'; +import type { StackOneMcpToolOptions } from './tool'; +import type { ExecuteOptions, JsonObject, JsonValue } from './types'; +import { StackOneAPIError } from './utils/error-stackone-api'; +import { ToolArgumentsError } from './utils/error-tool-arguments'; + +/** A served pair: the account and connector it runs on, and the name each tool is presented under. */ +export interface AccountPair { + accountId: string; + connector: string; + /** Each served name, and the trio's name it is presented under. */ + names: ReadonlyMap; +} + +/** The served suffix of each pair tool, and the trio's name for it. */ +const PAIR_SUFFIXES = [ + ['_search_actions', SEARCH_ACTIONS_TOOL_NAME], + ['_execute_action', EXECUTE_ACTION_TOOL_NAME], +] as const; + +/** + * The pair a one-account listing serves, read from its tool names, or `undefined` when it serves + * none. The connector is a served name minus `__search_actions` (or + * `_execute_action`). + */ +export function accountPairOf( + definitions: readonly McpToolDefinition[], + accountId: string, +): AccountPair | undefined { + for (const { name } of definitions) { + for (const [suffix] of PAIR_SUFFIXES) { + const ending = `_${accountId}${suffix}`; + if (name.length > ending.length && name.endsWith(ending)) { + const connector = name.slice(0, -ending.length); + const names = new Map( + PAIR_SUFFIXES.map(([served, trioName]) => [ + `${connector}_${accountId}${served}`, + trioName, + ]), + ); + return { accountId, connector, names }; + } + } + } + return undefined; +} + +/** + * A served pair tool under the trio's name. Its description, schema and results read the trio's + * names wherever the served ones appear; it takes the trio's `stackone_account_id` and `provider` + * when they name its own account and connector; and its search hits name the account and + * connector, as the trio's do. + */ +export class StackOneAccountPairTool extends StackOneMcpTool { + readonly #pair: AccountPair; + + constructor(options: StackOneMcpToolOptions, pair: AccountPair) { + super({ + ...options, + name: pair.names.get(options.name) ?? options.name, + servedName: options.name, + description: renameServedTools(options.description, pair), + parameters: renameServedTools(options.parameters, pair), + }); + this.#pair = pair; + } + + /** + * Call the served tool. + * + * @throws ToolArgumentsError If `stackone_account_id` or `provider` names an account or + * connector other than the pair's own, before any request. + * @throws StackOneAPIError As {@link StackOneMcpTool.execute}, its text in the trio's names. + */ + override async execute( + inputParams?: JsonObject | string, + options?: ExecuteOptions, + ): Promise { + const args = withoutScopeArguments(this.parseArguments(inputParams), this.name, this.#pair); + const result = await super.execute(args, options).catch((error: unknown) => { + throw error instanceof StackOneAPIError ? renamedError(error, this.#pair) : error; + }); + if (options?.dryRun === true) { + return result; + } + const renamed = renameServedTools(result, this.#pair); + return this.name === SEARCH_ACTIONS_TOOL_NAME ? withHitsScoped(renamed, this.#pair) : renamed; + } +} + +/** + * The arguments without `stackone_account_id` and `provider`, which the trio takes and the pair + * does not. Each is dropped when it names the pair's own account or connector (the connector + * case-insensitively), and refused otherwise: the pair cannot reach any other. `null` is not + * given. + */ +function withoutScopeArguments(args: JsonObject, tool: string, pair: AccountPair): JsonObject { + const { stackone_account_id: accountId, provider, ...rest } = args; + if (accountId != null && accountId !== pair.accountId) { + throw new ToolArgumentsError( + `stackone_account_id ${JSON.stringify(accountId)} is not an account ${JSON.stringify(tool)} can run on; it runs on ${JSON.stringify(pair.accountId)}.`, + ); + } + if ( + provider != null && + (typeof provider !== 'string' || provider.toLowerCase() !== pair.connector.toLowerCase()) + ) { + throw new ToolArgumentsError( + `provider ${JSON.stringify(provider)} is not linked in this account scope, which has ${JSON.stringify(pair.connector)}.`, + ); + } + return rest; +} + +/** A failed call's error, its message and body in the trio's names, caused by the served one. */ +function renamedError(error: StackOneAPIError, pair: AccountPair): StackOneAPIError { + return new StackOneAPIError( + renameServedTools(error.message, pair), + error.statusCode, + renameServedTools(error.responseBody, pair), + error.requestBody, + { cause: error }, + ); +} + +/** `value` with every served pair name in its strings replaced by the trio's name for it. */ +function renameServedTools(value: T, pair: AccountPair): T { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- renaming strings keeps every value's shape + return renamedValue(value, pair) as T; +} + +function renamedValue(value: unknown, pair: AccountPair): unknown { + if (typeof value === 'string') { + let renamed = value; + for (const [served, trioName] of pair.names) { + renamed = renamed.replaceAll(served, trioName); + } + return renamed; + } + if (Array.isArray(value)) { + return value.map((item: unknown) => renamedValue(item, pair)); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, renamedValue(item, pair)]), + ); + } + return value; +} + +/** A pair's search result, each hit naming its account and connector where it does not already. */ +function withHitsScoped(result: JsonObject, pair: AccountPair): JsonObject { + if (!Array.isArray(result.actions)) { + return result; + } + const actions = result.actions.map((hit) => + isJsonObject(hit) + ? { + ...hit, + provider: hit.provider ?? pair.connector, + available_accounts: hit.available_accounts ?? [{ id: pair.accountId }], + } + : hit, + ); + return { ...result, actions }; +} + +const isJsonObject = (value: JsonValue | undefined): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/consts.ts b/src/consts.ts index 6cc22d0a..a77f22a1 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -20,10 +20,21 @@ export const DEFAULT_TIMEOUT_MS = 60_000; */ export const SUBMIT_FEEDBACK_TOOL_NAME = 'stackone_submit_feedback'; -/** Upper bound on how many accounts' catalogs (or connectors' searches) run at once. */ +/** + * The account-spanning search and execute tools the MCP endpoint serves under + * `tool-mode=search_execute`, beside `stackone_list_accounts`. For one account the server serves + * a pair named for its connector and account instead, which the SDK presents under these names. + */ +export const SEARCH_ACTIONS_TOOL_NAME = 'stackone_search_actions'; +export const EXECUTE_ACTION_TOOL_NAME = 'stackone_execute_action'; + +/** The most accounts one `search_execute` request may name; the server refuses more with a 400. */ +export const MAX_SCOPE_ACCOUNTS = 50; + +/** Upper bound on how many accounts' catalogs are listed at once. */ export const MAX_CONCURRENCY = 10; -/** The `*_search_actions` meta tool's served schema caps `top_k` at 50. */ +/** `stackone_search_actions`'s served schema caps `top_k` at 50. */ export const MAX_TOP_K = 50; /** Retries after an HTTP 429, on top of the first attempt: four attempts in all. */ diff --git a/src/end-user-id.test.ts b/src/end-user-id.test.ts index bfc30fcc..3cc3aa04 100644 --- a/src/end-user-id.test.ts +++ b/src/end-user-id.test.ts @@ -35,6 +35,22 @@ const endUserMismatch = (accountId: string) => { status: 400 }, ); +/** + * Every value of a header or query parameter, as UCA's ActiveAccountGuard collects them: a + * search_execute listing names its accounts as one comma-separated query value, and its end users + * as repeated query parameters. + */ +const valuesOf = (request: Request, name: string): string[] => { + const header = request.headers.get(name); + return [ + ...(header !== null && header !== '' ? [header] : []), + ...new URL(request.url).searchParams.getAll(name), + ].flatMap((value) => (name === 'x-account-id' ? value.split(',') : [value])); +}; + +/** Several values as one, comma-joined, so an exchange reads as the scope it was sent for. */ +const joined = (values: string[]): string | null => (values.length > 0 ? values.join(',') : null); + /** * Serve `GET /accounts` and `/mcp`, recording every MCP message. `accounts` is read per request, * so a test can change what the next `GET /accounts` reports. `guard` maps accounts to the end @@ -66,15 +82,17 @@ const serve = ( for (const message of Array.isArray(body) ? body : [body]) { exchanges.push({ method: String(jsonRpcMethod(message)), - accountId: request.headers.get('x-account-id'), - endUserId: request.headers.get('x-end-user-id'), + accountId: joined(valuesOf(request, 'x-account-id')), + endUserId: joined(valuesOf(request, 'x-end-user-id')), }); } } - const accountId = request.headers.get('x-account-id') ?? ''; - const required = guard[accountId]; - if (required !== undefined && request.headers.get('x-end-user-id') !== required) { - return endUserMismatch(accountId); + const endUsers = valuesOf(request, 'x-end-user-id'); + for (const accountId of valuesOf(request, 'x-account-id')) { + const required = guard[accountId]; + if (required !== undefined && !endUsers.includes(required)) { + return endUserMismatch(accountId); + } } return app.fetch(request); }), @@ -147,14 +165,15 @@ describe('x-end-user-id after account discovery', () => { const calls = exchanges.filter((exchange) => exchange.method === 'tools/call'); calls.sort((left, right) => String(left.accountId).localeCompare(String(right.accountId))); + // One search over both accounts, then execute() and feedback on acc1 alone. expect(calls).toEqual([ { method: 'tools/call', accountId: 'acc1', endUserId: 'alice' }, { method: 'tools/call', accountId: 'acc1', endUserId: 'alice' }, - { method: 'tools/call', accountId: 'acc1', endUserId: 'alice' }, - { method: 'tools/call', accountId: 'acc2', endUserId: null }, + { method: 'tools/call', accountId: 'acc1,acc2', endUserId: 'alice' }, ]); for (const exchange of exchanges) { - expect(exchange.endUserId).toBe(exchange.accountId === 'acc1' ? 'alice' : null); + const scope = String(exchange.accountId).split(','); + expect(exchange.endUserId).toBe(scope.includes('acc1') ? 'alice' : null); } }); diff --git a/src/headers.ts b/src/headers.ts index e2a6dd05..a0cd7230 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -131,7 +131,7 @@ const FLAT_HEADER_PREFIX = 'headers_'; export interface DeclaredHeaders { /** * Names declared under the nested `headers` object, lower-cased, or `'any'` when `headers` is - * an open map, as on `*_execute_action`: `type: "object"` (or a type list that includes + * an open map, as on `stackone_execute_action`: `type: "object"` (or a type list that includes * `"object"`, such as `["object", "null"]`), no `properties` key, and `additionalProperties` * anything but `false`. A schema not typed as an object, or one that closes * `additionalProperties` without listing `properties`, declares no names. diff --git a/src/index.ts b/src/index.ts index 333a67a5..f0670876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export type { FeedbackSource, JsonObject, JsonValue, + SearchHitAccount, SearchResult, StackOneAccount, ToolDefinition, diff --git a/src/mcp-client.ts b/src/mcp-client.ts index b9f34aba..b2944752 100644 --- a/src/mcp-client.ts +++ b/src/mcp-client.ts @@ -135,29 +135,27 @@ export class HttpRateLimitError extends Error { } /** - * Whether an MCP request was refused by the API's end-user check on `accountId`: the 400 it - * answers a non-shared account's request with when `x-end-user-id` does not name its end user. + * The account an MCP request was refused for by the API's end-user check: the 400 it answers a + * non-shared account's request with when `x-end-user-id` does not name its end user. */ -function isEndUserMismatch(error: unknown, accountId: string): error is StackOneAPIError { - if (!(error instanceof StackOneAPIError) || error.statusCode !== 400) { - return false; +function endUserRefusalOf(error: StackOneAPIError): string | undefined { + if (error.statusCode !== 400) { + return undefined; } const message = bodyMessage(error.responseBody); - // The guard's fixed wording, ending with this account's id, quoted or not: not an exact string, + // The guard's fixed wording, ending with the account's id, quoted or not: not an exact string, // so a change to how the id is written does not silently turn the lookup off. if ( typeof message !== 'string' || !message.startsWith('x-end-user-id header does not match account end user id') ) { - return false; + return undefined; } - return ( - message - .trimEnd() - .split(' ') - .pop() - ?.replace(/^["']|["']$/g, '') === accountId - ); + return message + .trimEnd() + .split(' ') + .pop() + ?.replace(/^["']|["']$/g, ''); } /** The `message` of an error body, or the body itself when it has none. */ @@ -173,37 +171,48 @@ export interface EndUserSource { } /** - * Run an MCP request for `accountId` with its recorded end user, if any. - * - * An account id passed explicitly has no end user recorded until something calls - * `GET /accounts`, so a non-shared one is refused with a 400. When that happens to an account - * with nothing recorded, its end user is looked up once and, if there is one, the request is sent - * again with it. Otherwise the 400 is thrown as it came — with the lookup's error as its `cause`, - * if the lookup failed. + * Run an MCP request for `accountId` with its recorded end user, if any. See {@link withEndUsers}. */ export async function withEndUser( accountId: string | undefined, source: EndUserSource | undefined, request: (endUserId: string | undefined) => Promise, ): Promise { - if (!isTruthyString(accountId) || source === undefined) { - return request(undefined); - } - const recorded = source.recorded(accountId); - if (recorded !== undefined) { - return request(recorded); - } + return withEndUsers(isTruthyString(accountId) ? [accountId] : [], source, () => + request(isTruthyString(accountId) ? source?.recorded(accountId) : undefined), + ); +} + +/** + * Run an MCP request for `accountIds`, which reads their recorded end users itself. + * + * An account id passed explicitly has no end user recorded until something calls + * `GET /accounts`, so a non-shared one is refused with a 400. When that happens to one of these + * accounts with nothing recorded, its end user is looked up once and, if there is one, the request + * is sent again. Otherwise the 400 is thrown as it came — with the lookup's error as its `cause`, + * if the lookup failed. + */ +export async function withEndUsers( + accountIds: readonly string[], + source: EndUserSource | undefined, + request: () => Promise, +): Promise { + const unrecorded = new Set(accountIds.filter((id) => source?.recorded(id) === undefined)); try { - return await request(undefined); + return await request(); } catch (error) { - if (!isEndUserMismatch(error, accountId)) { + if (!(error instanceof StackOneAPIError) || source === undefined) { + throw error; + } + const refused = endUserRefusalOf(error); + if (refused === undefined || !unrecorded.has(refused)) { throw error; } - const endUserId = await lookUpAfterRefusal(error, accountId, source); + const endUserId = await lookUpAfterRefusal(error, refused, source); if (endUserId === undefined) { throw error; } - return request(endUserId); + return request(); } } diff --git a/src/rate-limit.test.ts b/src/rate-limit.test.ts index 1535c081..9e02e65d 100644 --- a/src/rate-limit.test.ts +++ b/src/rate-limit.test.ts @@ -228,8 +228,9 @@ describe('a 429 that outlasts its retries', () => { expect(error.responseBody).toBe(JSON.stringify(RATE_LIMITED)); }); - it('fails a search instead of skipping the throttled connector', async () => { - serveThrottled({ method: 'tools/call', accountId: 'acc2' }); + it('fails a search over several accounts with the 429', async () => { + // One search call spans both accounts, so the throttled call is the whole search. + serveThrottled({ method: 'tools/call' }); const error = await newToolSet() .search('list items', { accountIds: ['acc1', 'acc2'] }) diff --git a/src/tool.test.ts b/src/tool.test.ts index 083252c3..4b03243e 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -588,12 +588,11 @@ describe('StackOneMcpTool as an action tool', () => { const mcpTool = (properties: Record = {}) => new StackOneMcpTool({ - name: 'mock_acc1_execute_action', + name: 'stackone_execute_action', description: 'Execute', parameters: toolParametersFromInputSchema({ type: 'object', properties }), - endpoint: `${TEST_BASE_URL}/mcp?tool-mode=search_execute`, + endpoint: `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1%2Cacc2`, apiKey: 'test-key', - accountId: 'acc1', timeout: 5_000, }); @@ -601,8 +600,11 @@ describe('StackOneMcpTool', () => { const calls: RecordedToolCall[] = []; beforeEach(() => { calls.length = 0; + // Two accounts on different providers: the server serves the trio, and routes each action to + // the one account on its provider. const app = createMcpApp({ - accountTools: { acc1: [] }, + accountTools: { acc1: [], acc2: [] }, + accountProviders: { acc2: 'other' }, onToolCall: (recorded) => calls.push(recorded), }); server.use(http.all(`${TEST_BASE_URL}/mcp`, ({ request }) => app.fetch(request))); @@ -620,15 +622,15 @@ describe('StackOneMcpTool', () => { }); expect(calls).toEqual([ { - accountId: 'acc1', + accountId: 'acc1,acc2', toolMode: 'search_execute', - name: 'mock_acc1_execute_action', + name: 'stackone_execute_action', arguments: { action_id: 'mock_list_items', query: { page_size: 2 } }, }, ]); }); - // The meta tools take a `headers` object the server unpacks, and these arguments are + // stackone_execute_action takes a `headers` object the server unpacks, and these arguments are // model-controlled. This schema declares no headers at all, so nothing survives. it('drops every model-supplied header', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/src/tool.ts b/src/tool.ts index 840713c8..868c2916 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -361,12 +361,32 @@ export class StackOneTool extends BaseTool { } } +/** What a {@link StackOneMcpTool} is built from. */ +export interface StackOneMcpToolOptions { + name: string; + /** The name the server serves the tool under, when it is presented under another. */ + servedName?: string; + description: string; + parameters: ToolParameters; + endpoint: string; + apiKey: string; + accountId?: string; + timeout: number; + extraHeaders?: Record; + /** + * Where the end user of an account, sent as `x-end-user-id`, comes from. Read per call, so a + * tool rebound with `setAccountId` sends its new account's end user. + */ + endUsers?: EndUserSource; +} + /** * A tool executed over MCP `tools/call`, on the endpoint and account that listed it. Every tool a - * {@link StackOneToolSet} returns is one: per-action tools, the `search_execute` meta tools, and + * {@link StackOneToolSet} returns is one: per-action tools, the `search_execute` tools, and * `stackone_submit_feedback`. */ export class StackOneMcpTool extends StackOneTool { + readonly #servedName: string; readonly #endpoint: string; readonly #apiKey: string; readonly #extraHeaders: Record; @@ -374,28 +394,16 @@ export class StackOneMcpTool extends StackOneTool { readonly #timeout: number; readonly #declaredHeaders: DeclaredHeaders; - constructor(options: { - name: string; - description: string; - parameters: ToolParameters; - endpoint: string; - apiKey: string; - accountId?: string; - timeout: number; - extraHeaders?: Record; - /** - * Where the end user of an account, sent as `x-end-user-id`, comes from. Read per call, so - * a tool rebound with `setAccountId` sends its new account's end user. - */ - endUsers?: EndUserSource; - }) { + constructor(options: StackOneMcpToolOptions) { + const servedName = options.servedName ?? options.name; const executeConfig = { kind: 'mcp', url: options.endpoint, - toolName: options.name, + toolName: servedName, } as const satisfies McpExecuteConfig; super(options.name, options.description, options.parameters, executeConfig, options.accountId); this.setExposeExecutionMetadata(false); + this.#servedName = servedName; this.#endpoint = options.endpoint; this.#apiKey = options.apiKey; this.#extraHeaders = { ...options.extraHeaders }; @@ -411,13 +419,13 @@ export class StackOneMcpTool extends StackOneTool { * entries of a `headers` object, and `headers_` arguments — are the exception: these * arguments are model-controlled, so each is forwarded only if this tool's own schema declares * it, and `Authorization`, `x-account-id`, `User-Agent` and `x-end-user-id` never are. A - * `headers` property served as an object with no `properties`, as on `*_execute_action`, + * `headers` property served as an object with no `properties`, as on `stackone_execute_action`, * declares every name. * * @returns The result as the server wrote it: for an action tool, an `ActionResult`, * `{ isError: false, result, defenderMetadata?, policyMetadata? }`. A file action's `result` * is the server's single-use `download_url`, not the file. Typed `JsonObject` rather than - * `ActionResult`, since a `*_search_actions` meta tool returns bare JSON and `dryRun` the + * `ActionResult`, since `stackone_search_actions` returns bare JSON and `dryRun` the * call it would send: assert `ActionResult` on an action tool. * @throws ToolArgumentsError If the arguments are not a JSON object or cannot be encoded as * JSON. @@ -434,7 +442,7 @@ export class StackOneMcpTool extends StackOneTool { assertEncodable(this.name, args); if (options?.dryRun === true) { - return { url: this.#endpoint, method: 'tools/call', name: this.name, arguments: args }; + return { url: this.#endpoint, method: 'tools/call', name: this.#servedName, arguments: args }; } const accountId = this.getAccountId(); @@ -450,7 +458,7 @@ export class StackOneMcpTool extends StackOneTool { }), timeout: this.#timeout, }, - this.name, + this.#servedName, args, ), ); diff --git a/src/toolsets.search-execute.test.ts b/src/toolsets.search-execute.test.ts index 6b23f0ff..52eefead 100644 --- a/src/toolsets.search-execute.test.ts +++ b/src/toolsets.search-execute.test.ts @@ -1,10 +1,10 @@ /** - * search(), execute() and submitFeedback(): the recommended surface, over the per-connector - * `*_search_actions` / `*_execute_action` meta tools and the server's `stackone_submit_feedback`. + * search(), execute() and submitFeedback(): the recommended surface, over the account-spanning + * `stackone_search_actions` / `stackone_execute_action` and the server's `stackone_submit_feedback`. */ import { http, HttpResponse } from 'msw'; import { TEST_BASE_URL } from '../mocks/constants'; -import { MOCK_SEARCH_SESSION_ID, accountMcpTools, createMcpApp } from '../mocks/mcp-server'; +import { MOCK_SEARCH_SESSION_ID, createMcpApp } from '../mocks/mcp-server'; import type { RecordedToolCall } from '../mocks/mcp-server'; import { server } from '../mocks/node'; import { listMcpTools } from './mcp-client'; @@ -14,7 +14,7 @@ import { StackOneToolSet } from './toolsets'; import type { JsonObject } from './types'; import { StackOneAPIError } from './utils/error-stackone-api'; import { StackOneError } from './utils/error-stackone'; -import { retryTiming } from './utils/fetch-retry'; +import { ToolArgumentsError } from './utils/error-tool-arguments'; import { ToolSetConfigError, ToolSetLoadError } from './utils/error-toolset'; vi.mock('./mcp-client', async (importOriginal) => { @@ -25,21 +25,13 @@ const listMock = vi.mocked(listMcpTools); const { listMcpTools: realListMcpTools } = await vi.importActual('./mcp-client'); -/** The listing `listMock` serves now, for a test to wrap in its own. */ -const currentListing = (): typeof listMcpTools => { - const listing = listMock.getMockImplementation(); - assert(listing !== undefined, 'listMcpTools has no implementation'); - return listing; -}; - const newToolSet = (config: ConstructorParameters[0] = {}) => new StackOneToolSet({ apiKey: 'test-key', baseUrl: TEST_BASE_URL, ...config }); const silence = (): void => undefined; -let warnSpy: ReturnType; beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(silence); + vi.spyOn(console, 'warn').mockImplementation(silence); }); afterEach(() => { vi.unstubAllEnvs(); @@ -48,24 +40,24 @@ afterEach(() => { listMock.mockImplementation(realListMcpTools); }); +const SESSION_TOOL_NAMES = [ + 'stackone_list_accounts', + 'stackone_search_actions', + 'stackone_execute_action', +]; + /** - * Serve meta tools with these names, each on the account its name embeds, and replace the - * tools/call with `respond` — as the Python suite monkeypatches `StackOneMcpTool.execute`. + * Serve these tool names on every listing and replace the tools/call with `respond` — as the + * Python suite monkeypatches `StackOneMcpTool.execute`. */ -const fakeMetaTools = ( - names: string[], +const fakeSessionTools = ( respond: (tool: StackOneMcpTool, args: JsonObject) => JsonObject | Promise = () => ({ data: {}, }), + names: string[] = SESSION_TOOL_NAMES, ) => { - listMock.mockImplementation(async ({ headers }) => - names - .filter((name) => - name - .replace(/_(search_actions|execute_action)$/, '') - .endsWith(`_${headers['x-account-id']}`), - ) - .map((name): McpToolDefinition => ({ name, description: '', inputSchema: {} })), + listMock.mockImplementation(async () => + names.map((name): McpToolDefinition => ({ name, description: '', inputSchema: {} })), ); const calls: Array<{ tool: string; args: JsonObject }> = []; vi.spyOn(StackOneMcpTool.prototype, 'execute').mockImplementation(async function ( @@ -85,156 +77,508 @@ const fakeMetaTools = ( const serveMock = ( options: { accountTools?: Parameters[0]['accountTools']; + accountProviders?: Record; submitFeedback?: boolean; } = {}, ) => { const calls: RecordedToolCall[] = []; + const requests: Request[] = []; let rpcRequests = 0; const app = createMcpApp({ - accountTools: options.accountTools ?? { default: [], acc1: accountMcpTools.acc1 }, + accountTools: options.accountTools ?? { default: [], acc1: [] }, + accountProviders: options.accountProviders, submitFeedback: options.submitFeedback, onToolCall: (call) => calls.push(call), }); server.use( - http.all(`${TEST_BASE_URL}/mcp`, ({ request }) => app.fetch(request)), + http.all(`${TEST_BASE_URL}/mcp`, ({ request }) => { + requests.push(request.clone()); + return app.fetch(request); + }), http.post(`${TEST_BASE_URL}/actions/rpc`, () => { rpcRequests += 1; return HttpResponse.json({ data: {} }); }), ); - return { calls, rpcRequests: () => rpcRequests }; + return { calls, requests, rpcRequests: () => rpcRequests }; }; -describe('search()', () => { - it('finds actions with nothing but an API key', async () => { +/** Serve `accounts` from GET /accounts. */ +const serveAccounts = (accounts: JsonObject[]) => + server.use(http.get(`${TEST_BASE_URL}/accounts`, () => HttpResponse.json(accounts))); + +/** The endpoint of every listing so far. */ +const listedEndpoints = () => listMock.mock.calls.map(([request]) => request.endpoint); + +describe('the search_execute listing', () => { + it('lists the trio once for the whole scope, the accounts as one comma-separated query value', async () => { + const { requests } = serveMock({ accountTools: { acc1: [], acc2: [], acc3: [] } }); + + const tools = await newToolSet({ accountIds: ['acc3', 'acc1', 'acc3', 'acc2'] }).fetchTools({ + mode: 'search_execute', + }); + + expect(tools.toArray().map((tool) => tool.name)).toEqual(SESSION_TOOL_NAMES); + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1%2Cacc2%2Cacc3`, + ]); + expect(requests.every((request) => request.headers.get('x-account-id') === null)).toBe(true); + }); + + it('refuses more than 50 accounts before any request', async () => { + const accountIds = Array.from({ length: 51 }, (_, index) => `acc${index}`); + + const error = await newToolSet({ accountIds }) + .fetchTools({ mode: 'search_execute' }) + .catch((caught: unknown) => caught); + + expect(error).toEqual( + new ToolSetConfigError( + '51 accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.', + ), + ); + expect(error).toBeInstanceOf(ToolSetConfigError); + expect(listMock).not.toHaveBeenCalled(); + }); + + it('lists 50 accounts in one request', async () => { + fakeSessionTools(); + const accountIds = Array.from({ length: 50 }, (_, index) => `acc${index}`); + + await newToolSet({ accountIds }).search('x'); + + expect(listMock).toHaveBeenCalledOnce(); + }); + + it('binds its tools to no account, executing on the endpoint that listed them', async () => { + const { calls, requests } = serveMock({ accountTools: { acc1: [], acc2: [] } }); + const tools = await newToolSet({ accountIds: ['acc1', 'acc2'] }).fetchTools({ + mode: 'search_execute', + }); + const execute = tools + .getStackOneTools() + .find((tool) => tool.name === 'stackone_execute_action'); + requests.length = 0; + + await execute?.execute({ action_id: 'mock_list_items', stackone_account_id: 'acc2' }); + + expect(execute?.getAccountId()).toBeUndefined(); + expect(calls.map((call) => call.accountId)).toEqual(['acc1,acc2']); + expect(new Set(requests.map((request) => request.url))).toEqual( + new Set([listedEndpoints()[0]]), + ); + }); + + it('sends each recorded end user of a non-shared account in scope, once', async () => { + serveMock({ accountTools: { acc1: [], acc2: [], acc3: [] } }); + serveAccounts([ + { id: 'acc1', provider: 'mock', status: 'active', shared: false, origin_username: 'bob' }, + { id: 'acc2', provider: 'mock', status: 'active', shared: true, origin_username: 'zed' }, + { id: 'acc3', provider: 'mock', status: 'active', shared: false, origin_username: 'bob' }, + ]); + const toolset = newToolSet({ includeNonShared: true }); + + await toolset.fetchTools({ mode: 'search_execute' }); + + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1%2Cacc2%2Cacc3&x-end-user-id=bob`, + ]); + }); + + it('looks up an end user the server asks for, then lists again with it', async () => { + const app = createMcpApp({ accountTools: { acc1: [], acc2: [] } }); + let accountRequests = 0; + server.use( + http.get(`${TEST_BASE_URL}/accounts`, () => { + accountRequests += 1; + return HttpResponse.json([ + { id: 'acc1', provider: 'mock', status: 'active', shared: true }, + { id: 'acc2', provider: 'mock', status: 'active', shared: false, origin_username: 'eve' }, + ]); + }), + http.all(`${TEST_BASE_URL}/mcp`, ({ request }) => + new URL(request.url).searchParams.getAll('x-end-user-id').includes('eve') + ? app.fetch(request) + : HttpResponse.json( + { + statusCode: 400, + message: 'x-end-user-id header does not match account end user id for account acc2', + }, + { status: 400 }, + ), + ), + ); + + const tools = await newToolSet({ accountIds: ['acc1', 'acc2'] }).fetchTools({ + mode: 'search_execute', + }); + + expect(tools.toArray().map((tool) => tool.name)).toEqual(SESSION_TOOL_NAMES); + expect(accountRequests).toBe(1); + expect(listedEndpoints().at(-1)).toBe( + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1%2Cacc2&x-end-user-id=eve`, + ); + }); + + // One request lists the whole scope, so there is no account to skip. + it('fails the call when the listing fails', async () => { + serveMock({ accountTools: { acc1: [] } }); + + const error = await newToolSet({ accountIds: ['acc1', 'gone'] }) + .fetchTools({ mode: 'search_execute' }) + .catch((caught: unknown) => caught); + + assert.instanceOf(error, StackOneAPIError); + expect(error.statusCode).toBe(404); + }); + + it('is cached per scope', async () => { + serveMock({ accountTools: { acc1: [], acc2: [] } }); + const toolset = newToolSet({ accountIds: ['acc1', 'acc2'] }); + + await toolset.fetchTools({ mode: 'search_execute' }); + await toolset.fetchTools({ mode: 'search_execute', accountIds: ['acc2', 'acc1'] }); + await toolset.fetchTools({ mode: 'search_execute', accountIds: ['acc1'] }); + + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1%2Cacc2`, + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1`, + ]); + }); + + it('narrows the scope to the accounts on the given providers', async () => { + serveMock({ accountTools: { acc1: [], acc2: [], acc3: [] } }); + serveAccounts([ + { id: 'acc1', provider: 'linear', status: 'active' }, + { id: 'acc2', provider: 'HiBob', status: 'active' }, + { id: 'acc3', provider: 'jira', status: 'active' }, + ]); + + const tools = await newToolSet({ accountIds: ['acc1', 'acc2', 'acc3'] }).fetchTools({ + mode: 'search_execute', + providers: ['hibob', 'Linear'], + }); + + expect(tools.toArray().map((tool) => tool.name)).toEqual(SESSION_TOOL_NAMES); + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1%2Cacc2`, + ]); + }); + + it('lists nothing when no account in scope is on the given providers', async () => { + serveMock({ accountTools: { acc1: [] } }); + serveAccounts([{ id: 'acc1', provider: 'linear', status: 'active' }]); + + const tools = await newToolSet({ accountId: 'acc1' }).fetchTools({ + mode: 'search_execute', + providers: ['jira'], + }); + + expect(tools.length).toBe(0); + expect(listMock).not.toHaveBeenCalled(); + }); + + it('still filters tool names by actions', async () => { serveMock(); - const actions = await newToolSet().search('list items'); - expect(actions.map((action) => action.action_id)).toEqual(['mock_list_items']); + const tools = await newToolSet({ accountId: 'acc1' }).fetchTools({ + mode: 'search_execute', + actions: ['*_search_actions'], + }); + expect(tools.toArray().map((tool) => tool.name)).toEqual(['stackone_search_actions']); }); +}); + +/** A tool of the pair a toolset on the one account `accountId` lists. */ +const pairTool = async (name: string, accountId = 'acc1') => { + const tools = await newToolSet({ accountId }).fetchTools({ mode: 'search_execute' }); + return tools.getStackOneTool(name); +}; + +/** + * Contract v2 rule 3: for one account the server serves `__search_actions` and + * `_execute_action`, which the SDK presents under the trio's names. + */ +describe('one account: the served pair under the trio’s names', () => { + it('presents the pair as stackone_search_actions and stackone_execute_action', async () => { + serveMock({ submitFeedback: true }); - it('asks the search_execute catalog, passing top_k through', async () => { + const tools = await newToolSet({ accountId: 'acc1' }).fetchTools({ mode: 'search_execute' }); + + expect(tools.toArray().map((tool) => tool.name)).toEqual([ + 'stackone_submit_feedback', + 'stackone_search_actions', + 'stackone_execute_action', + ]); + }); + + it('shows a model the same tools for one account as for several, but stackone_list_accounts', async () => { + serveMock({ accountTools: { acc1: [], acc2: [] } }); + const toolset = newToolSet(); + + const one = await toolset.fetchTools({ mode: 'search_execute', accountIds: ['acc1'] }); + const many = await toolset.fetchTools({ mode: 'search_execute', accountIds: ['acc1', 'acc2'] }); + + expect(one.toArray().map((tool) => tool.name)).toEqual( + many + .toArray() + .map((tool) => tool.name) + .filter((name) => name !== 'stackone_list_accounts'), + ); + }); + + it('reads the trio’s names wherever its description and schema name the served tools', async () => { + serveMock(); + + const search = await pairTool('stackone_search_actions'); + const execute = await pairTool('stackone_execute_action'); + + expect(search.description).toBe( + 'Search the mock actions of account acc1. Run one with stackone_execute_action.', + ); + expect(execute.description).toBe('Execute an action found by stackone_search_actions.'); + expect(execute.toJsonSchema().properties?.action_id?.description).toBe( + 'An action_id exactly as stackone_search_actions returned it.', + ); + expect(JSON.stringify([search.toJsonSchema(), execute.toJsonSchema()])).not.toMatch( + /mock_acc1_/, + ); + }); + + it('calls the served tool on the same endpoint, and renames it in the result’s strings', async () => { const { calls } = serveMock(); + const search = await pairTool('stackone_search_actions'); - await newToolSet().search('list items', { topK: 7 }); + const result = await search.execute({ query: 'list items' }); + expect(calls.map((call) => [call.name, call.accountId])).toEqual([ + ['mock_acc1_search_actions', 'acc1'], + ]); + expect(result.hint).toBe( + 'To run an action, call stackone_execute_action with its action_id exactly as shown above.', + ); + }); + + it('gives each hit the account and connector it runs on', async () => { + serveMock(); + + const hits = await newToolSet({ accountId: 'acc1' }).search('list items'); + + expect(hits).toEqual([ + { + action_id: 'mock_list_items', + description: 'List items', + similarity_score: 0.8, + example_request: { query: { page_size: 25 } }, + provider: 'mock', + available_accounts: [{ id: 'acc1' }], + session_id: MOCK_SEARCH_SESSION_ID, + }, + ]); + }); + + it('keeps the account and connector a hit already names', async () => { + fakeSessionTools( + (): JsonObject => ({ + actions: [ + { + action_id: 'linear_a', + provider: 'Linear', + available_accounts: [{ id: 'acc1', name: 'A' }], + }, + { action_id: 'linear_b' }, + ], + }), + ['linear_acc1_search_actions', 'linear_acc1_execute_action'], + ); + + const hits = await newToolSet({ accountId: 'acc1' }).search('x'); + + expect(hits).toEqual([ + { + action_id: 'linear_a', + provider: 'Linear', + available_accounts: [{ id: 'acc1', name: 'A' }], + }, + { action_id: 'linear_b', provider: 'linear', available_accounts: [{ id: 'acc1' }] }, + ]); + }); + + it('runs execute() on the one account, dropping the stackone_account_id it names', async () => { + const { calls } = serveMock({ accountTools: { acc1: [], acc2: [] } }); + + const result = await newToolSet({ accountIds: ['acc1', 'acc2'] }).execute( + 'mock_list_items', + { query: { page_size: 1 } }, + { sessionId: MOCK_SEARCH_SESSION_ID, accountIds: ['acc2'] }, + ); + + expect(result.result).toHaveProperty('account_id', 'acc2'); expect(calls).toEqual([ { - accountId: 'default', + accountId: 'acc2', toolMode: 'search_execute', - name: 'mock_default_search_actions', - arguments: { query: 'list items', top_k: 7 }, + name: 'mock_acc2_execute_action', + arguments: { + query: { page_size: 1 }, + session_id: MOCK_SEARCH_SESSION_ID, + action_id: 'mock_list_items', + }, }, ]); }); - // Contract §4: each hit carries the session_id of the search that produced it. - it('copies the search session_id onto every hit', async () => { + it.each([ + ['stackone_search_actions', { query: 'x' }], + ['stackone_execute_action', { action_id: 'mock_list_items' }], + ])( + '%s drops a stackone_account_id and provider naming its own account and connector', + async (name, args) => { + const { calls } = serveMock(); + const tool = await pairTool(name); + + await tool.execute({ ...args, stackone_account_id: 'acc1', provider: 'MOCK' }); + await tool.execute({ ...args, stackone_account_id: null, provider: null }); + + expect(calls.map((call) => call.arguments)).toEqual([args, args]); + }, + ); + + it('reports a failed call in the trio’s names', async () => { serveMock(); - const [hit] = await newToolSet().search('list items'); - expect(hit?.session_id).toBe(MOCK_SEARCH_SESSION_ID); + const tool = await pairTool('stackone_execute_action'); + + const error = await tool + .execute({ action_id: 'mock_unknown' }) + .catch((caught: unknown) => caught); + + assert.instanceOf(error, StackOneAPIError); + const text = `${error.message} ${JSON.stringify(error.responseBody)}`; + expect(text).not.toContain('mock_acc1_'); + expect(error.message).toContain('"stackone_execute_action"'); + expect(error.statusCode).toBe(404); }); - it('omits session_id when the server issues none', async () => { - fakeMetaTools(['a_acc1_search_actions'], () => ({ - actions: [{ action_id: 'a_x' }], - session_id: '', - })); - const [hit] = await newToolSet({ accountId: 'acc1' }).search('x'); - expect(hit).toEqual({ action_id: 'a_x', account_id: 'acc1' }); + it('refuses a stackone_account_id outside the scope, before any call', async () => { + const { calls } = serveMock(); + const tool = await pairTool('stackone_execute_action'); + + const error = await tool + .execute({ action_id: 'mock_list_items', stackone_account_id: 'acc2' }) + .catch((caught: unknown) => caught); + + assert.instanceOf(error, ToolArgumentsError); + expect(error.message).toBe( + 'stackone_account_id "acc2" is not an account "stackone_execute_action" can run on; it runs on "acc1".', + ); + expect(calls).toEqual([]); }); - // Without it, a caller who finds the same action on two accounts cannot choose between them. - it('tags every hit with the account that found it, one hit per account', async () => { - fakeMetaTools(['linear_acc1_search_actions', 'linear_acc2_search_actions'], (tool) => ({ - actions: [ - { - action_id: 'linear_list_issues', - similarity_score: tool.name.includes('acc1') ? 0.5 : 0.9, - account_id: 'spoofed', - }, - ], - })); + it('refuses a provider outside the scope, before any call', async () => { + const { calls } = serveMock(); + const tool = await pairTool('stackone_search_actions'); - const hits = await newToolSet({ accountIds: ['acc1', 'acc2'] }).search('x'); + const error = await tool + .execute({ query: 'x', provider: 'jira' }) + .catch((caught: unknown) => caught); - expect(hits).toEqual([ - { action_id: 'linear_list_issues', similarity_score: 0.9, account_id: 'acc2' }, - { action_id: 'linear_list_issues', similarity_score: 0.5, account_id: 'acc1' }, - ]); + assert.instanceOf(error, ToolArgumentsError); + expect(error.message).toBe( + 'provider "jira" is not linked in this account scope, which has "mock".', + ); + expect(calls).toEqual([]); }); - // top_k is per connector on the wire; 2.x's topK capped the whole result, and so does this. - it('cuts the merged ranking to topK, keeping the best across connectors', async () => { - const perConnector: Record = { - a_acc1_search_actions: [ - { action_id: 'a_1', similarity_score: 0.8 }, - { action_id: 'a_2', similarity_score: 0.1 }, - ], - b_acc1_search_actions: [ - { action_id: 'b_1', similarity_score: 0.9 }, - { action_id: 'b_2', similarity_score: 0.2 }, - ], - c_acc1_search_actions: [{ action_id: 'c_1', similarity_score: 0.7 }], - }; - const calls = fakeMetaTools(Object.keys(perConnector), (tool) => ({ - actions: perConnector[tool.name] ?? [], - })); + it('reads the connector of a provider whose name holds underscores', async () => { + const { calls } = serveMock({ accountProviders: { acc1: 'browser_linkedin' } }); + const tool = await pairTool('stackone_search_actions'); - const hits = await newToolSet({ accountId: 'acc1' }).search('x', { topK: 2 }); + const result = await tool.execute({ query: 'x', provider: 'browser_linkedin' }); - expect(hits.map((hit) => hit.action_id)).toEqual(['b_1', 'a_1']); - expect(calls.map((call) => call.args.top_k)).toEqual([2, 2, 2]); - }); - - it('ranks results across connectors rather than concatenating them, tolerating bad scores', async () => { - const perConnector: Record = { - a_acc1_search_actions: [ - { action_id: 'a_low', similarity_score: 0.2 }, - { action_id: 'a_bad', similarity_score: '0.99' }, - ], - b_acc2_search_actions: [{ action_id: 'b_high', similarity_score: 0.9 }], - }; - fakeMetaTools(Object.keys(perConnector), (tool) => ({ - actions: perConnector[tool.name] ?? [], - session_id: `session-${tool.name}`, - })); + expect(calls.map((call) => call.name)).toEqual(['browser_linkedin_acc1_search_actions']); + expect(result.actions).toEqual([ + expect.objectContaining({ + provider: 'browser_linkedin', + available_accounts: [{ id: 'acc1' }], + }), + ]); + }); +}); + +describe('search()', () => { + it('finds actions with nothing but an API key', async () => { + serveMock(); + const actions = await newToolSet().search('list items'); + expect(actions.map((action) => action.action_id)).toEqual(['mock_list_items']); + }); + + it('makes one stackone_search_actions call, passing top_k through', async () => { + const { calls } = serveMock({ accountTools: { acc1: [], acc2: [] } }); - const actions = await newToolSet({ accountIds: ['acc1', 'acc2'] }).search('x'); + await newToolSet({ accountIds: ['acc2', 'acc1'] }).search('list items', { topK: 7 }); - expect(actions.map((action) => action.action_id)).toEqual(['b_high', 'a_low', 'a_bad']); - // Each hit keeps the session of the connector search that found it. - expect(actions[0]?.session_id).toBe('session-b_acc2_search_actions'); - expect(actions[1]?.session_id).toBe('session-a_acc1_search_actions'); + expect(calls).toEqual([ + { + accountId: 'acc1,acc2', + toolMode: 'search_execute', + name: 'stackone_search_actions', + arguments: { query: 'list items', top_k: 7 }, + }, + ]); }); - it('keeps the connectors that answer when one fails', async () => { - fakeMetaTools(['a_acc1_search_actions', 'b_acc1_search_actions'], (tool) => { - if (tool.name.startsWith('b_')) { - throw new ToolSetLoadError('b is down'); - } - return { actions: [{ action_id: 'a_x', similarity_score: 1 }] }; - }); + // Contract §4: each hit as served, plus the session_id of the search that produced it. + it('returns each hit as served, with the accounts that can run it and the session_id', async () => { + serveMock({ accountTools: { acc1: [], acc2: [] } }); - const actions = await newToolSet({ accountId: 'acc1' }).search('x'); + const hits = await newToolSet({ accountIds: ['acc1', 'acc2'] }).search('list items'); - expect(actions.map((action) => action.action_id)).toEqual(['a_x']); - expect(String(warnSpy.mock.calls[0]?.[0])).toContain( - 'Skipping connector that failed to search — b_acc1_search_actions: b is down', - ); + expect(hits).toEqual([ + { + action_id: 'mock_list_items', + provider: 'mock', + available_accounts: [ + { id: 'acc1', name: 'acc1' }, + { id: 'acc2', name: 'acc2' }, + ], + description: 'List items', + similarity_score: 0.8, + example_request: { query: { page_size: 25 } }, + session_id: MOCK_SEARCH_SESSION_ID, + }, + ]); }); - it('fails when every connector fails', async () => { - fakeMetaTools(['a_acc1_search_actions'], () => { - throw new Error('boom'); + it('omits session_id when the server issues none', async () => { + fakeSessionTools(() => ({ actions: [{ action_id: 'a_x' }], session_id: '' })); + const [hit] = await newToolSet({ accountId: 'acc1' }).search('x'); + expect(hit).toEqual({ action_id: 'a_x' }); + }); + + // The server lists a synced copy or a prerequisite beside the hits it ranked. + it('cuts the result to topK, in the order served', async () => { + fakeSessionTools(() => ({ + actions: [{ action_id: 'a_1' }, { action_id: 'b_1' }, { action_id: 'c_1' }], + })); + const hits = await newToolSet({ accountId: 'acc1' }).search('x', { topK: 2 }); + expect(hits.map((hit) => hit.action_id)).toEqual(['a_1', 'b_1']); + }); + + it('raises the server’s error rather than returning nothing', async () => { + fakeSessionTools(() => { + throw new StackOneAPIError('Tool "stackone_search_actions" failed: down', 503, {}); }); - await expect(newToolSet({ accountId: 'acc1' }).search('x')).rejects.toThrow( - new ToolSetLoadError('No connector returned results. a_acc1_search_actions: boom'), + await expect(newToolSet({ accountId: 'acc1' }).search('x')).rejects.toBeInstanceOf( + StackOneAPIError, ); }); - it('returns nothing when no connector is linked', async () => { - fakeMetaTools([]); - expect(await newToolSet({ accountId: 'acc1' }).search('x')).toEqual([]); + it('explains a listing without stackone_search_actions', async () => { + fakeSessionTools(undefined, ['stackone_list_accounts']); + await expect(newToolSet({ accountId: 'acc1' }).search('x')).rejects.toThrow( + new ToolSetLoadError( + 'The server did not serve stackone_search_actions for this account scope.', + ), + ); }); it.each([0, -1, 51, 1000, 1.5, Number.NaN, '10', null, true])( @@ -249,20 +593,20 @@ describe('search()', () => { ); it.each([1, 50])('accepts topK=%i', async (topK) => { - fakeMetaTools([]); + fakeSessionTools(() => ({ actions: [] })); await expect(newToolSet({ accountId: 'acc1' }).search('x', { topK })).resolves.toEqual([]); }); - it('uses the meta tools even when the toolset lists individual tools', async () => { + it('uses the search_execute listing even when the toolset lists individual tools', async () => { serveMock(); const toolset = newToolSet({ accountId: 'acc1' }); await toolset.search('x'); await toolset.fetchTools(); - expect(listMock.mock.calls.map(([request]) => request.endpoint.split('?')[1])).toEqual([ - 'tool-mode=search_execute', - undefined, + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1`, + `${TEST_BASE_URL}/mcp`, ]); }); }); @@ -290,25 +634,72 @@ describe('execute()', () => { expect(result.result).toHaveProperty('echoed_query', { page_size: 25 }); }); - // Contract §5: session_id is forwarded as a top-level argument when given, never otherwise, - // and action_id stays pinned last. - it('forwards sessionId as a top-level session_id, action_id last', async () => { - const { calls } = serveMock(); + // Contract §5: stackone_account_id for exactly one account, session_id when given, action_id last. + it('sends the one account as stackone_account_id, then session_id, then action_id', async () => { + const calls = fakeSessionTools(); + + await newToolSet({ accountIds: ['acc1', 'acc2'] }).execute( + 'mock_list_items', + { query: { page_size: 1 }, stackone_account_id: 'acc1' }, + { sessionId: MOCK_SEARCH_SESSION_ID, accountIds: ['acc2', 'acc2'] }, + ); + + expect(calls.map((call) => call.tool)).toEqual(['stackone_execute_action']); + expect(Object.entries(calls[0]?.args ?? {})).toEqual([ + ['query', { page_size: 1 }], + ['stackone_account_id', 'acc2'], + ['session_id', MOCK_SEARCH_SESSION_ID], + ['action_id', 'mock_list_items'], + ]); + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc2`, + ]); + }); + + it('leaves the account to the server, and the arguments alone, for several accounts', async () => { + const { calls } = serveMock({ + accountTools: { acc1: [], acc2: [] }, + accountProviders: { acc1: 'linear', acc2: 'mock' }, + }); await newToolSet().execute( 'mock_list_items', - { query: { page_size: 1 } }, - { sessionId: MOCK_SEARCH_SESSION_ID }, + { stackone_account_id: 'acc2' }, + { accountIds: ['acc1', 'acc2'] }, ); - const [call] = calls; - expect(call?.name).toBe('mock_default_execute_action'); - expect(call?.arguments).toEqual({ - query: { page_size: 1 }, - session_id: MOCK_SEARCH_SESSION_ID, + expect(calls[0]?.arguments).toEqual({ + stackone_account_id: 'acc2', action_id: 'mock_list_items', }); - expect(Object.keys(call?.arguments ?? {}).at(-1)).toBe('action_id'); + }); + + // Picking one would run the action on an account the caller never chose. + it('raises the server’s request for an account when the connector is linked twice', async () => { + serveMock({ accountTools: { acc1: [], acc2: [] } }); + + const error = await newToolSet({ accountIds: ['acc2', 'acc1'] }) + .execute('mock_list_items') + .catch((caught: unknown) => caught); + + assert.instanceOf(error, StackOneAPIError); + expect(error.statusCode).toBe(400); + expect(error.responseBody).toHaveProperty( + 'error', + 'stackone_execute_action runs against multiple accounts — pass "stackone_account_id", one of [acc1 (acc1), acc2 (acc2)] (or call stackone_list_accounts to list them).', + ); + }); + + it('runs on the account a search hit names', async () => { + serveMock({ accountTools: { acc1: [], acc2: [] } }); + const toolset = newToolSet({ accountIds: ['acc1', 'acc2'] }); + + const [hit] = await toolset.search('list items'); + const chosen = hit?.available_accounts?.[1]?.id; + assert(chosen !== undefined, 'the hit names no second account'); + const result = await toolset.execute(hit?.action_id ?? '', {}, { accountIds: [chosen] }); + + expect(result.result).toHaveProperty('account_id', 'acc2'); }); it('keeps an argument named __proto__ while pinning action_id and session_id', async () => { @@ -371,8 +762,8 @@ describe('execute()', () => { expect(calls[0]?.arguments.headers).toEqual({}); }); - // *_execute_action serves `headers` as an open object, so a host can pass any header the SDK - // does not own through to the action. + // stackone_execute_action serves `headers` as an open object, so a host can pass any header + // the SDK does not own through to the action. it('forwards host-set headers the SDK does not own', async () => { const { calls } = serveMock(); @@ -394,81 +785,28 @@ describe('execute()', () => { expect(error.statusCode).toBe(404); }); - it('routes an account id containing underscores by identity', async () => { - const calls = fakeMetaTools(['linear_acc_1_execute_action']); - await newToolSet({ accountId: 'acc_1' }).execute('linear_list_issues'); - expect(calls[0]?.tool).toBe('linear_acc_1_execute_action'); - }); - - it('routes to the longest matching connector', async () => { - const calls = fakeMetaTools([ - 'browser_acc1_execute_action', - 'browser_linkedin_acc2_execute_action', - ]); - await newToolSet({ accountIds: ['acc1', 'acc2'] }).execute('browser_linkedin_search_people'); - expect(calls[0]?.tool).toBe('browser_linkedin_acc2_execute_action'); - }); - - // Picking one would run the action on an account the caller never chose. - it('refuses to choose when the same connector is linked on two accounts', async () => { - const calls = fakeMetaTools(['linear_acc1_execute_action', 'linear_acc2_execute_action']); - - const error = await newToolSet({ accountIds: ['acc2', 'acc1'] }) - .execute('linear_list_issues') + it('raises the server’s error for an action no linked connector serves', async () => { + serveMock(); + const error = await newToolSet({ accountIds: ['default', 'acc1'] }) + .execute('jira_list_issues') .catch((caught: unknown) => caught); - assert.instanceOf(error, ToolSetConfigError); - expect(error.message).toMatch( - new RegExp( - '^"linear_list_issues" matches 2 connectors on different accounts \\(linear_acc1_execute_action on acc1, linear_acc2_execute_action on acc2\\)\\. Pass the account id to use', - ), - ); - expect(calls).toEqual([]); - }); - - it('routes to the account a search hit names', async () => { - const calls = fakeMetaTools( - [ - 'linear_acc1_search_actions', - 'linear_acc2_search_actions', - 'linear_acc1_execute_action', - 'linear_acc2_execute_action', - ], - (tool): JsonObject => - tool.name.endsWith('_search_actions') - ? { actions: [{ action_id: 'linear_list_issues' }] } - : { data: {} }, - ); - const toolset = newToolSet(); - server.use( - http.get(`${TEST_BASE_URL}/accounts`, () => - HttpResponse.json([ - { id: 'acc1', provider: 'linear', status: 'active' }, - { id: 'acc2', provider: 'linear', status: 'active' }, - ]), - ), + assert.instanceOf(error, StackOneAPIError); + expect(error.statusCode).toBe(400); + expect(error.responseBody).toHaveProperty( + 'error', + expect.stringMatching(/^Unknown action_id "jira_list_issues"/), ); - - const hit = (await toolset.search('x')).find((found) => found.account_id === 'acc2'); - assert(hit !== undefined, 'acc2 has no search hit'); - await toolset.execute('linear_list_issues', {}, { accountIds: [hit.account_id] }); - - expect(calls.at(-1)?.tool).toBe('linear_acc2_execute_action'); - }); - - it('matches the connector case-insensitively', async () => { - const calls = fakeMetaTools(['Linear_acc1_execute_action']); - await newToolSet({ accountId: 'acc1' }).execute('LINEAR_list_issues'); - expect(calls[0]?.tool).toBe('Linear_acc1_execute_action'); }); - it('explains an action no connector serves', async () => { - fakeMetaTools(['linear_acc1_execute_action']); - await expect(newToolSet({ accountId: 'acc1' }).execute('jira_list_issues')).rejects.toThrow( + it('explains a listing without stackone_execute_action', async () => { + const calls = fakeSessionTools(undefined, ['stackone_search_actions']); + await expect(newToolSet({ accountId: 'acc1' }).execute('linear_list_issues')).rejects.toThrow( new ToolSetLoadError( - 'No connector found for "jira_list_issues". Use search() to discover valid action ids.', + 'The server did not serve stackone_execute_action for this account scope.', ), ); + expect(calls).toEqual([]); }); it.each([ @@ -494,6 +832,11 @@ describe('execute()', () => { () => newToolSet({ accountId: 'acc1' }).execute('x_y', {}, { sessionId: 42 as never }), /sessionId must be a non-empty string/, ], + [ + 'an empty account id', + () => newToolSet({ accountId: 'acc1' }).execute('x_y', {}, { accountIds: [''] }), + /accountIds must not contain an empty account id/, + ], ])('rejects %s before any round trip', async (_name, act, message) => { await expect(act()).rejects.toThrow(message); await expect(act()).rejects.toBeInstanceOf(ToolSetConfigError); @@ -590,8 +933,8 @@ describe('submitFeedback()', () => { accountIds: ['acc2', 'acc1'], }); - expect(listMock.mock.calls.map(([request]) => request.endpoint)).toEqual([ - `${TEST_BASE_URL}/mcp?tool-mode=search_execute`, + expect(listedEndpoints()).toEqual([ + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1`, ]); expect(calls.map((call) => [call.name, call.accountId, call.toolMode])).toEqual([ ['stackone_submit_feedback', 'acc1', 'search_execute'], @@ -724,298 +1067,3 @@ it('keeps every search/execute error inside the StackOneError hierarchy', async serveMock(); await expect(newToolSet().execute('mock_nope')).rejects.toBeInstanceOf(StackOneError); }); - -describe('execute() while an account in scope has failed to list', () => { - const ACCOUNTS = [ - { id: 'acc1', provider: 'linear', status: 'active' }, - { id: 'acc2', provider: 'linear', status: 'active' }, - { id: 'acc3', provider: 'hibob', status: 'active' }, - ]; - const failing = new Set(); - const listed: string[] = []; - const listings = (accountId: string) => listed.filter((id) => id === accountId).length; - - /** Serve `accounts` from GET /accounts, and meta tools for each, failing those in `failing`. */ - const serve = (accounts: typeof ACCOUNTS) => { - server.use(http.get(`${TEST_BASE_URL}/accounts`, () => HttpResponse.json(accounts))); - const calls = fakeMetaTools( - accounts.map(({ id, provider }) => `${provider}_${id}_execute_action`), - ); - const serveListing = currentListing(); - listMock.mockImplementation(async (request) => { - const accountId = request.headers['x-account-id']; - listed.push(accountId); - if (failing.has(accountId)) { - throw new Error(`${accountId} is down`); - } - return serveListing(request); - }); - return calls; - }; - - beforeEach(() => { - failing.clear(); - listed.length = 0; - }); - - it('refuses when the failed account is on the action’s connector', async () => { - const calls = serve([ACCOUNTS[0], ACCOUNTS[1]] as typeof ACCOUNTS); - failing.add('acc1'); - - const error = await newToolSet() - .execute('linear_list_issues') - .catch((caught: unknown) => caught); - - assert.instanceOf(error, ToolSetLoadError); - expect(error.message).toBe( - `"linear_list_issues" may be served by an account that failed to list (acc1: acc1 is down). Pass the account id to use, such as a search hit's account_id.`, - ); - expect(calls).toEqual([]); - }); - - it('runs the action when the failed account is on another provider', async () => { - const calls = serve([ACCOUNTS[0], ACCOUNTS[2]] as typeof ACCOUNTS); - failing.add('acc3'); - - await newToolSet().execute('linear_list_issues'); - - expect(calls.map(({ tool }) => tool)).toEqual(['linear_acc1_execute_action']); - }); - - it('is not blocked by a failed account on a shorter provider prefix', async () => { - // acc2's provider `linear` prefixes `linear_beta_list_items`, but the action's connector is - // `linear_beta`, the longest prefix, so acc2 could not serve it. - const calls = serve([ - { id: 'acc1', provider: 'linear_beta', status: 'active' }, - { id: 'acc2', provider: 'linear', status: 'active' }, - ]); - failing.add('acc2'); - - await newToolSet().execute('linear_beta_list_items'); - - expect(calls.map(({ tool }) => tool)).toEqual(['linear_beta_acc1_execute_action']); - }); - - it('looks up the providers of failed accounts named explicitly, then runs the action', async () => { - const calls = serve([ACCOUNTS[0], ACCOUNTS[2]] as typeof ACCOUNTS); - failing.add('acc3'); - - // Explicit ids, so only the lookup can say acc3 is hibob, which cannot serve a linear action. - await newToolSet({ accountIds: ['acc1', 'acc3'] }).execute('linear_list_issues'); - - expect(calls.map(({ tool }) => tool)).toEqual(['linear_acc1_execute_action']); - }); - - it('refuses when the failed account’s provider is unknown, listing failures by account id', async () => { - const calls = serve(ACCOUNTS); - server.use( - http.get(`${TEST_BASE_URL}/accounts`, () => - HttpResponse.json({ error: 'forbidden' }, { status: 403 }), - ), - ); - failing.add('acc3'); - failing.add('acc2'); - - // Explicit ids, and GET /accounts fails, so neither failed account's provider is known. - const error = await newToolSet({ accountIds: ['acc3', 'acc1', 'acc2'] }) - .execute('linear_list_issues') - .catch((caught: unknown) => caught); - - expect(error).toHaveProperty( - 'message', - `"linear_list_issues" may be served by an account that failed to list (acc2: acc2 is down; acc3: acc3 is down). Pass the account id to use, such as a search hit's account_id.`, - ); - expect(calls).toEqual([]); - }); - - it('throws the 429 when the provider lookup is rate limited', async () => { - serve([ACCOUNTS[0], ACCOUNTS[1]] as typeof ACCOUNTS); - server.use( - http.get(`${TEST_BASE_URL}/accounts`, () => - HttpResponse.json( - { statusCode: 429, message: 'Too many requests' }, - { status: 429, headers: { 'Retry-After': '0' } }, - ), - ), - ); - failing.add('acc2'); - - const error = await newToolSet({ accountIds: ['acc1', 'acc2'] }) - .execute('linear_list_issues') - .catch((caught: unknown) => caught); - - assert.instanceOf(error, StackOneAPIError); - expect(error.statusCode).toBe(429); - }); - - describe('when the provider lookup cannot name a failed account', () => { - let now = 0; - let accountRequests = 0; - /** acc2 always fails to list, and GET /accounts always answers 403. */ - const serveUnnameable = () => { - serve([ACCOUNTS[0], ACCOUNTS[1]] as typeof ACCOUNTS); - server.use( - http.get(`${TEST_BASE_URL}/accounts`, () => { - accountRequests += 1; - return HttpResponse.json({ error: 'forbidden' }, { status: 403 }); - }), - ); - failing.add('acc2'); - return newToolSet({ accountIds: ['acc1', 'acc2'] }); - }; - /** A second after the last call, so its failures are no longer this call's own. */ - const refuses = async (toolset: StackOneToolSet) => { - now += 1_000; - const error = await toolset.execute('linear_x').catch((caught: unknown) => caught); - expect(error).toHaveProperty( - 'message', - expect.stringMatching(/may be served by an account that failed to list/), - ); - }; - - beforeEach(() => { - now = 0; - accountRequests = 0; - vi.spyOn(retryTiming, 'now').mockImplementation(() => now); - }); - - it('neither looks it up nor re-lists it again for 30 seconds', async () => { - const toolset = serveUnnameable(); - - await refuses(toolset); - await refuses(toolset); - await refuses(toolset); - - expect(accountRequests).toBe(1); - expect(listings('acc2')).toBe(1); - - now += 30_000; - await refuses(toolset); - - expect(accountRequests).toBe(2); - expect(listings('acc2')).toBe(2); - }); - - it('forgets the miss when the cache is cleared', async () => { - const toolset = serveUnnameable(); - await refuses(toolset); - - toolset.clearCatalogCache(); - await refuses(toolset); - - expect(accountRequests).toBe(2); - }); - - it('does not re-list it in the call whose lookup missed it', async () => { - const toolset = serveUnnameable(); - await toolset.fetchTools({ mode: 'search_execute' }); - - // Concurrent calls join the one lookup, and none of them waits out acc2 again. - await Promise.all([refuses(toolset), refuses(toolset)]); - - expect(accountRequests).toBe(1); - expect(listings('acc2')).toBe(1); - }); - - it('does not record a miss across a clear made during the lookup', async () => { - const toolset = serveUnnameable(); - let release: () => void = silence; - const held = new Promise((resolve) => { - release = resolve; - }); - server.use( - http.get(`${TEST_BASE_URL}/accounts`, async () => { - accountRequests += 1; - if (accountRequests === 1) { - await held; - } - return HttpResponse.json({ error: 'forbidden' }, { status: 403 }); - }), - ); - - const first = refuses(toolset); - await vi.waitFor(() => expect(accountRequests).toBe(1)); - toolset.clearCatalogCache(); - release(); - await first; - await refuses(toolset); - - expect(accountRequests).toBe(2); - }); - - it('forgets the miss once GET /accounts names the account', async () => { - const toolset = serveUnnameable(); - await refuses(toolset); - server.use( - http.get(`${TEST_BASE_URL}/accounts`, () => HttpResponse.json([ACCOUNTS[0], ACCOUNTS[1]])), - ); - await toolset.fetchAccounts(); - - await refuses(toolset); - - // acc2 is linear's, so it is listed again at once rather than after the window. - expect(listings('acc2')).toBe(2); - }); - }); - - it('lists a failed account again at once, and then applies to whatever it serves', async () => { - const calls = serve([ACCOUNTS[0], ACCOUNTS[1]] as typeof ACCOUNTS); - failing.add('acc1'); - const toolset = newToolSet(); - await toolset.fetchTools({ mode: 'search_execute' }); - expect(listed).toEqual(['acc1', 'acc2']); - - failing.clear(); - const error = await toolset.execute('linear_list_issues').catch((caught: unknown) => caught); - - // Long before the failed account would be due again. - expect(listed).toEqual(['acc1', 'acc2', 'acc1']); - assert.instanceOf(error, ToolSetConfigError); - expect(error.message).toMatch(/^"linear_list_issues" matches 2 connectors/); - expect(calls).toEqual([]); - }); - - it('does not re-list a failed account on another provider', async () => { - const calls = serve([ACCOUNTS[0], ACCOUNTS[2]] as typeof ACCOUNTS); - // acc3 never answers: listing it again would make every linear action wait out its timeout. - const listOnce = currentListing(); - listMock.mockImplementation(async (request) => { - if (request.headers['x-account-id'] === 'acc3' && listed.includes('acc3')) { - listed.push('acc3'); - return new Promise(silence); - } - return listOnce(request); - }); - failing.add('acc3'); - const toolset = newToolSet(); - await toolset.fetchTools({ mode: 'search_execute' }); - - await toolset.execute('linear_list_issues'); - - expect(listed).toEqual(['acc1', 'acc3']); - expect(calls.map(({ tool }) => tool)).toEqual(['linear_acc1_execute_action']); - }); - - it('does not re-list an account that failed during the same call', async () => { - serve([ACCOUNTS[0], ACCOUNTS[1]] as typeof ACCOUNTS); - failing.add('acc1'); - - // A fresh toolset: acc1 fails in this call's own listing, so listing it again at once - // would only double the wait. - await newToolSet() - .execute('linear_list_issues') - .catch(() => undefined); - - expect(listed).toEqual(['acc1', 'acc2']); - }); - - it('does not re-list when no account has failed', async () => { - serve([ACCOUNTS[0], ACCOUNTS[2]] as typeof ACCOUNTS); - const toolset = newToolSet(); - - await toolset.execute('linear_list_issues'); - await toolset.execute('hibob_list_employees'); - - expect(listed).toEqual(['acc1', 'acc3']); - }); -}); diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 705af3e2..61bc1d96 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -368,7 +368,9 @@ describe('account discovery', () => { fakeListing(() => []); const toolset = newToolSet(); - await Promise.all([toolset.search('x'), toolset.fetchTools(), toolset.fetchTools()]); + // The listing serves no search tool, so search() raises; only its discovery matters here. + const search = toolset.search('x').catch(() => undefined); + await Promise.all([search, toolset.fetchTools(), toolset.fetchTools()]); expect(requests).toBe(1); }); @@ -1028,13 +1030,13 @@ describe('tool mode', () => { ); }); - it('builds MCP tools under search_execute, and requests it on the URL', async () => { + it('builds MCP tools under search_execute, naming the scope on the URL', async () => { fakeListing(() => [def('t')]); const tools = await newToolSet({ accountId: 'acc1', toolMode: 'search_execute' }).fetchTools(); expect(tools.getTool('t')).toBeInstanceOf(StackOneMcpTool); expect(listMock.mock.calls[0]?.[0].endpoint).toBe( - `${TEST_BASE_URL}/mcp?tool-mode=search_execute`, + `${TEST_BASE_URL}/mcp?tool-mode=search_execute&x-account-id=acc1`, ); }); diff --git a/src/toolsets.ts b/src/toolsets.ts index 7f6dc12d..7f32a895 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1,14 +1,25 @@ import type { MergeExclusive, SimplifyDeep } from 'type-fest'; +import { StackOneAccountPairTool, accountPairOf } from './account-pair'; +import type { AccountPair } from './account-pair'; import { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, + EXECUTE_ACTION_TOOL_NAME, FAILED_ACCOUNT_RETRY_MS, MAX_CONCURRENCY, + MAX_SCOPE_ACCOUNTS, MAX_TOP_K, + SEARCH_ACTIONS_TOOL_NAME, SUBMIT_FEEDBACK_TOOL_NAME, } from './consts'; import { buildRequestHeaders, isSdkOwnedHeader } from './headers'; -import { HttpRateLimitError, isRateLimitFailure, listMcpTools, withEndUser } from './mcp-client'; +import { + HttpRateLimitError, + isRateLimitFailure, + listMcpTools, + withEndUser, + withEndUsers, +} from './mcp-client'; import type { EndUserSource, McpToolDefinition } from './mcp-client'; import { cloneJson, toolParametersFromInputSchema } from './schema'; import { StackOneMcpTool, Tools, warnOnDuplicateNames } from './tool'; @@ -104,9 +115,10 @@ interface StackOneToolSetBaseConfig { */ execute?: ExecuteToolsConfig; /** - * How the endpoint lists tools. `'search_execute'` returns two meta tools per connector - * instead of one tool per action, keeping the catalog small enough for a model's context. - * Defaults to the server's own default (`'individual'`). + * How the endpoint lists tools. `'search_execute'` returns one `stackone_search_actions` and + * one `stackone_execute_action` spanning every account in scope (with `stackone_list_accounts` + * when there are several) instead of one tool per action, keeping the catalog small enough for + * a model's context. Defaults to the server's own default (`'individual'`). */ toolMode?: ToolMode; /** @@ -136,8 +148,10 @@ export interface FetchToolsOptions { accountIds?: string[] | null; /** - * Filter tools by provider names (case-insensitive, matched as a full prefix of the tool - * name, so `browser_linkedin` matches `browser_linkedin_search_people`). + * Filter tools by provider names (case-insensitive). In individual mode a provider is matched + * as a full prefix of the tool name, so `browser_linkedin` matches + * `browser_linkedin_search_people`. In `search_execute` mode it narrows the account scope to + * the accounts `GET /accounts` reports on one of these providers. * @example ['hibob', 'bamboohr'] */ providers?: string[]; @@ -159,7 +173,7 @@ export interface FetchToolsOptions { * Options for {@link StackOneToolSet.search}. */ export interface SearchOptions { - /** Maximum results, 1–50, across every connector searched. Default: 10. */ + /** Maximum results, 1–50. Default: 10. */ topK?: number; /** * Restrict to these accounts. Defaults to the toolset's accounts, then every active shared @@ -178,8 +192,10 @@ export interface ExecuteActionOptions { */ sessionId?: string | null; /** - * Restrict routing to these accounts. Defaults as for {@link StackOneToolSet.search}; `null` is - * the same as leaving it unset. + * The accounts the call may run on. Defaults as for {@link StackOneToolSet.search}; `null` is + * the same as leaving it unset. Exactly one (distinct) id is sent as `stackone_account_id`, + * choosing the account; the server asks for one when the action's connector is linked on + * several accounts in scope and none is chosen. */ accountIds?: string[] | null; } @@ -212,11 +228,16 @@ export interface SubmitFeedbackOptions { accountIds?: string[] | null; } -/** One served tool, with the account it was listed for. What the catalog cache holds. */ +/** + * One served tool, with the endpoint and account it was listed for. A `search_execute` tool spans + * every account named in its endpoint, so it has no account of its own. A one-account + * `search_execute` listing carries the pair it serves, to present it under the trio's names. + */ interface CatalogEntry { definition: McpToolDefinition; - accountId: string; + accountId?: string; endpoint: string; + pair?: AccountPair; } /** A cached catalog: each healthy account's listing, and when and why each failing one failed. */ @@ -226,13 +247,6 @@ interface CachedCatalog { failed: ReadonlyMap; } -/** The catalog of an account scope: its tools, and each account left out because it failed. */ -interface ScopedCatalog { - entries: CatalogEntry[]; - /** In ascending account id order, with each failure's message and when it failed. */ - failed: [accountId: string, message: string, at: number][]; -} - const describeError = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -302,40 +316,6 @@ function characterClass(glob: string): string { const isPlainObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); -/** The longest of `connectors` that prefixes `actionId` (both lowercase), as execute() routes. */ -function longestConnector(actionId: string, connectors: string[]): string | undefined { - return connectors - .filter((connector) => actionId.startsWith(`${connector}_`)) - .reduce( - (longest, connector) => - longest === undefined || connector.length > longest.length ? connector : longest, - undefined, - ); -} - -/** - * The connector a meta tool belongs to: its name minus the account id and the suffix. - * - * The account id is stripped by identity, not by splitting on the last underscore. Account ids - * are nanoid-shaped and nanoid's alphabet includes `_`, so splitting turned - * `mock_acc_1_execute_action` into connector `mock_acc` and made every action on that account - * unroutable — with an error blaming the caller's action id. - */ -function connectorOf(tool: StackOneTool, suffix: string): string { - let stem = tool.name.endsWith(suffix) ? tool.name.slice(0, -suffix.length) : tool.name; - const account = tool.getAccountId(); - if (isTruthyString(account) && stem.endsWith(`_${account}`)) { - stem = stem.slice(0, -(account.length + 1)); - } - return stem.toLowerCase(); -} - -/** A search hit's score, or 0 for one without a numeric score, so it sorts last. */ -function scoreOf(action: SearchResult): number { - const score = action.similarity_score; - return typeof score === 'number' && !Number.isNaN(score) ? score : 0; -} - /** The JSON type of a value, as the messages shared with the Python SDK name it. */ const jsonType = (value: unknown): string => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; @@ -386,8 +366,8 @@ function assertSessionId(sessionId: unknown): void { /** * Refuse a search the server would reject. It rejects a `topK` outside 1..50, but only after a - * round trip per connector — and that reads like an outage rather than a typo. Fail here, where - * the caller can see why. + * round trip — and that reads like an outage rather than a typo. Fail here, where the caller can + * see why. */ function assertSearchArguments(query: unknown, topK: unknown): void { if (typeof topK !== 'number' || !Number.isInteger(topK) || topK < 1 || topK > MAX_TOP_K) { @@ -400,26 +380,6 @@ function assertSearchArguments(query: unknown, topK: unknown): void { } } -/** Search one connector, tagging each hit with the search's session and the connector's account. */ -async function searchConnector( - tool: StackOneTool, - query: string, - topK: number, -): Promise { - const found = await tool.execute({ query, top_k: topK }); - const actions = (Array.isArray(found.actions) ? found.actions : []).filter( - (action): action is SearchResult => isPlainObject(action), - ); - // The server returns session_id once per search, beside the actions. Results from every - // connector are merged and re-ranked, so this is the last point at which a hit can still be - // traced to the search, and the account, that produced it. - const sessionId = found.session_id; - const traced = typeof sessionId === 'string' && sessionId !== '' ? { session_id: sessionId } : {}; - const accountId = tool.getAccountId(); - const owner = isTruthyString(accountId) ? { account_id: accountId } : {}; - return actions.map((action) => Object.assign(action, traced, owner)); -} - /** * Whether a tool is one of the providers and matches one of the action patterns a fetch asked * for. An absent or empty list asks for every tool. @@ -435,24 +395,15 @@ function isAskedFor(toolName: string, { providers, actions }: FetchToolsOptions) ); } -/** A cached catalog narrowed to the accounts in `scope`, in scope order. */ -function catalogInScope( - { listings, failed }: CachedCatalog, - scope: readonly string[], -): ScopedCatalog { - return { - entries: scope.flatMap((accountId) => listings.get(accountId) ?? []), - failed: scope.flatMap((accountId): ScopedCatalog['failed'] => { - const failure = failed.get(accountId); - return failure === undefined ? [] : [[accountId, failure.message, failure.at]]; - }), - }; +/** A cached catalog's listings for the accounts in `scope`, in scope order. */ +function catalogInScope({ listings }: CachedCatalog, scope: readonly string[]): CatalogEntry[] { + return scope.flatMap((accountId) => listings.get(accountId) ?? []); } -/** The failed accounts of a cached catalog to list again: those in `retry`, and those due at `now`. */ -function dueAccounts(cached: CachedCatalog, retry: ReadonlySet, now: number): string[] { +/** The failed accounts of a cached catalog due to be listed again at `now`. */ +function dueAccounts(cached: CachedCatalog, now: number): string[] { return [...cached.failed] - .filter(([accountId, { at }]) => retry.has(accountId) || now - at >= FAILED_ACCOUNT_RETRY_MS) + .filter(([, { at }]) => now - at >= FAILED_ACCOUNT_RETRY_MS) .map(([accountId]) => accountId); } @@ -529,8 +480,8 @@ function warnOnUnreadAccountIdVariable( /** * The StackOne toolset: lists the served tool catalog and exposes it to agent frameworks. * - * A thin client over the MCP endpoint. Tools are listed from it, per account, and executed over - * its `tools/call`; the only other request is `GET /accounts`, to discover accounts. Schemas and + * A thin client over the MCP endpoint. Tools are listed from it — per account, or in + * `search_execute` mode once for every account in scope — and executed over its `tools/call`; the only other request is `GET /accounts`, to discover accounts. Schemas and * arguments are passed through as served, never rewritten, filtered or invented. * * An API key is enough: with no account configured, the toolset lists every active shared @@ -559,6 +510,13 @@ export class StackOneToolSet { * could hide a healthy account's tools behind a partial one. */ readonly #catalogInFlight = new Map>(); + /** + * The `search_execute` listing per account scope, and the listings in flight, keyed as + * {@link #catalogCache} is. One request lists the whole scope, so there is nothing to keep per + * account. + */ + readonly #sessionCache = new Map(); + readonly #sessionInFlight = new Map>(); #discoveredAccountIds: string[] | undefined; #discovering: Promise | undefined; /** The `GET /accounts` in flight, if any, so an end-user lookup can share it. */ @@ -573,13 +531,6 @@ export class StackOneToolSet { #endUserIds: ReadonlyMap = new Map(); /** The provider of each account, recorded with {@link #endUserIds}. */ #providers: ReadonlyMap = new Map(); - /** - * When {@link execute}'s provider lookup last failed to name each failed account, on - * {@link retryTiming}'s clock. For {@link FAILED_ACCOUNT_RETRY_MS} after, `execute()` neither - * looks the account up nor lists it again early, so a key that cannot name it does not cost - * every call a `GET /accounts` and a listing. Forgotten by {@link clearCatalogCache}. - */ - readonly #providerMisses = new Map(); /** * Bumped at the start of every {@link fetchAccounts} call. Overlapping calls (it is public, and * not deduplicated the way {@link #discoverAccountIds} is) can resolve out of order, so each @@ -659,9 +610,10 @@ export class StackOneToolSet { this.#cacheGeneration += 1; this.#catalogCache.clear(); this.#catalogInFlight.clear(); + this.#sessionCache.clear(); + this.#sessionInFlight.clear(); this.#discoveredAccountIds = undefined; this.#discovering = undefined; - this.#providerMisses.clear(); } /** @@ -791,10 +743,6 @@ export class StackOneToolSet { this.#accountsRecorded = started; this.#endUserIds = endUserIdsOf(accounts); this.#providers = providersOf(accounts); - // An account this response names is no longer one the lookup missed. - for (const accountId of this.#providers.keys()) { - this.#providerMisses.delete(accountId); - } } // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- served JSON: the accounts are read field by field where used return accounts as StackOneAccount[]; @@ -906,6 +854,24 @@ export class StackOneToolSet { return isTruthy(mode) ? `${endpoint}?tool-mode=${mode}` : endpoint; } + /** + * The `search_execute` endpoint for an account scope: the accounts as one comma-separated + * `x-account-id` query parameter, then the recorded end user of each non-shared one as a + * repeated `x-end-user-id`. End users go as query parameters rather than headers: the server + * matches each value exactly, and `fetch` joins repeated headers into one. + */ + #sessionEndpoint(scope: readonly string[]): string { + const params = new URLSearchParams({ + 'tool-mode': 'search_execute', + 'x-account-id': scope.join(','), + }); + const endUserIds = new Set(scope.flatMap((accountId) => this.#endUserIds.get(accountId) ?? [])); + for (const endUserId of endUserIds) { + params.append('x-end-user-id', endUserId); + } + return `${this.#baseUrl.replace(/\/+$/, '')}/mcp?${params.toString()}`; + } + /** * Keyed on what was fetched, not on how it is filtered: providers and actions narrow the list * in memory, so they must not force a refetch. The base URL and API key belong in it, so a @@ -916,7 +882,90 @@ export class StackOneToolSet { } /** - * The catalog of every scoped account, from the cache where it can be. + * The served catalog of an account scope, from the cache where it can be: one listing for the + * whole scope in `search_execute` mode, one per account otherwise. A scope with no accounts has + * an empty catalog. + */ + async #catalog( + scope: readonly string[], + mode: ToolMode | undefined, + generation: number, + ): Promise { + if (scope.length === 0) { + return []; + } + return mode === 'search_execute' + ? this.#sessionCatalog(scope, generation) + : this.#accountCatalog(scope, mode, generation); + } + + /** + * The `search_execute` listing of a scope: one request, whose tools span every account in it. + * There is no account to skip, so a failure fails the call. Concurrent calls share the listing + * in flight, as {@link #accountCatalog}'s do. + * + * @throws ToolSetConfigError If the scope holds more accounts than one request may name. + */ + async #sessionCatalog( + scope: readonly string[], + generation: number, + ): Promise { + if (scope.length > MAX_SCOPE_ACCOUNTS) { + throw new ToolSetConfigError( + `${scope.length} accounts are in scope, but search and execute span at most ${MAX_SCOPE_ACCOUNTS} in one request. Pass account ids to narrow the scope.`, + ); + } + const key = this.#cacheKey(scope, 'search_execute'); + const cached = this.#sessionCache.get(key); + if (cached !== undefined) { + return cached; + } + const inFlight = this.#sessionInFlight.get(key); + if (inFlight !== undefined && generation === this.#cacheGeneration) { + return inFlight; + } + + const listing = this.#listSession(scope).then((entries) => { + if (generation === this.#cacheGeneration) { + this.#sessionCache.set(key, entries); + } + return entries; + }); + if (generation === this.#cacheGeneration) { + this.#sessionInFlight.set(key, listing); + } + try { + return await listing; + } finally { + if (this.#sessionInFlight.get(key) === listing) { + this.#sessionInFlight.delete(key); + } + } + } + + /** + * List a scope's `search_execute` tools. The endpoint is built per attempt, so a retry after an + * end-user lookup names the end user it found; its tools execute on the endpoint that listed + * them. For one account the server serves that account's pair rather than the trio. + */ + async #listSession(scope: readonly string[]): Promise { + const { endpoint, definitions } = await withEndUsers(scope, this.#endUsers, async () => { + const attempted = this.#sessionEndpoint(scope); + return { + endpoint: attempted, + definitions: await listMcpTools({ + endpoint: attempted, + headers: buildRequestHeaders({ apiKey: this.#apiKey, extraHeaders: this.#headers }), + timeout: this.#timeout, + }), + }; + }); + const pair = scope.length === 1 ? accountPairOf(definitions, scope[0]) : undefined; + return definitions.map((definition) => ({ definition, endpoint, pair })); + } + + /** + * The catalog of every scoped account, listed one account at a time. * * One unusable account must not cost the caller every other account's tools, so a failing * account is skipped with a warning. The healthy accounts' listings are cached all the same, @@ -927,23 +976,15 @@ export class StackOneToolSet { * A 429 that outlasted its retries is not the account's fault but the key's, so it fails the * whole listing instead: skipping it would hand back a catalog missing whichever accounts * happened to be throttled. When every account fails, see {@link allAccountsFailed}. - * - * The failed accounts in `retry` are listed again now, due or not. A listing already in flight - * is joined either way, and its result stands even if it was not listing them. A scope with no - * accounts has an empty catalog. */ - async #catalog( + async #accountCatalog( scope: readonly string[], mode: ToolMode | undefined, generation: number, - retry: ReadonlySet = new Set(), - ): Promise { - if (scope.length === 0) { - return { entries: [], failed: [] }; - } + ): Promise { const key = this.#cacheKey(scope, mode); const cached = this.#catalogCache.get(key); - const due = cached === undefined ? scope : dueAccounts(cached, retry, retryTiming.now()); + const due = cached === undefined ? scope : dueAccounts(cached, retryTiming.now()); if (cached !== undefined && due.length === 0) { return catalogInScope(cached, scope); } @@ -1061,11 +1102,11 @@ export class StackOneToolSet { /** * Build an executable tool from a served catalog entry, on a deep copy of its schema. Every * tool — per-action, meta or feedback — executes over `tools/call` on the endpoint and account - * that listed it. + * that listed it. A one-account pair's tools are presented under the trio's names. */ #createTool(entry: CatalogEntry): StackOneTool { - const { definition, accountId, endpoint } = entry; - return new StackOneMcpTool({ + const { definition, accountId, endpoint, pair } = entry; + const options = { name: definition.name, description: definition.description ?? '', parameters: toolParametersFromInputSchema(cloneJson(definition.inputSchema)), @@ -1075,7 +1116,10 @@ export class StackOneToolSet { timeout: this.#timeout, extraHeaders: this.#headers, endUsers: this.#endUsers, - }); + }; + return pair !== undefined && pair.names.has(definition.name) + ? new StackOneAccountPairTool(options, pair) + : new StackOneMcpTool(options); } /** @@ -1084,41 +1128,40 @@ export class StackOneToolSet { * The listing is cached per account scope and mode; filters are applied in memory, and every * call builds fresh tool instances, so mutating one caller's tools never affects another's. * - * `stackone_submit_feedback` is served once per account listing; it is returned once. + * In `search_execute` mode one request lists `stackone_search_actions` and + * `stackone_execute_action` spanning every account in scope, with `stackone_list_accounts` when + * there are several; they are bound to no account and execute on that same endpoint. For one + * account the server serves a pair named for its connector and account, presented under the + * same two names, so a model sees the same tools either way. At most 50 accounts fit one request. `providers` narrows the scope to accounts on those + * providers, looking them up with `GET /accounts` if one is not yet known. In individual mode + * each account is listed on its own, one tool per action. + * + * `stackone_submit_feedback` is served once per listing; it is returned once. * * Rate limits: a request answered 429 is retried up to 3 times, after the server's * `Retry-After` (capped at 30s) or a 1s/2s/4s backoff, unless that wait would outlast the * `timeout`. One still rate limited after that — or that times out while waiting to retry — - * fails the whole call: an account that fails any other way is skipped with a warning, and - * left out for the next {@link FAILED_ACCOUNT_RETRY_MS}, but a 429 never yields a partial - * catalog. + * fails the whole call. In individual mode an account that fails any other way is skipped with + * a warning, and left out for the next {@link FAILED_ACCOUNT_RETRY_MS}, but a 429 never yields + * a partial catalog. In `search_execute` mode there is one request, so any failure fails the + * call. * - * @throws ToolSetConfigError If no account is configured and none can be discovered. - * @throws StackOneAPIError With status 429 if any account is still rate limited after the - * retries; or with the API's status when every account fails with that same status, so a - * caller can tell a revoked key's 401 from a 429. + * @throws ToolSetConfigError If no account is configured and none can be discovered, or a + * `search_execute` scope holds more than 50 accounts. + * @throws StackOneAPIError With status 429 if any request is still rate limited after the + * retries; or with the API's status when the listing fails (in individual mode: every + * account fails with that same status), so a caller can tell a revoked key's 401 from a 429. * @throws ToolSetLoadError If the catalog cannot be loaded. When every account fails for * differing reasons, its `cause` is an `AggregateError` of each account's error. */ async fetchTools(options: FetchToolsOptions = {}): Promise { - return (await this.#fetchTools(options)).tools; - } - - /** - * {@link fetchTools}, also returning the accounts left out because they failed to list. Those in - * `retry` are listed again first — see {@link #catalog}. - */ - async #fetchTools( - options: FetchToolsOptions, - retry?: ReadonlySet, - ): Promise<{ tools: Tools; failed: ScopedCatalog['failed'] }> { try { const mode = options.mode === undefined ? this.#toolMode : (options.mode ?? undefined); // Taken before discovery: a clear while GET /accounts is out must stop this call's // listing being cached, as it was scoped by accounts discovered before the clear. const generation = this.#cacheGeneration; - const scope = await this.#resolveAccountScope(options.accountIds); - const { entries, failed } = await this.#catalog(scope, mode, generation, retry); + const scope = await this.#fetchScope(options, mode); + const entries = await this.#catalog(scope, mode, generation); let seenFeedbackTool = false; const tools = entries @@ -1133,10 +1176,14 @@ export class StackOneToolSet { return first; }) .map((entry) => this.#createTool(entry)) - .filter((tool) => isAskedFor(tool.name, options)); + // search_execute narrows by provider in its scope instead: its tools are named for no + // provider. + .filter((tool) => + isAskedFor(tool.name, mode === 'search_execute' ? { actions: options.actions } : options), + ); warnOnDuplicateNames(tools); - return { tools: new Tools(tools), failed }; + return new Tools(tools); } catch (error) { // StackOneAPIError carries the HTTP status. Re-wrapping it would throw that away, so a // caller could not tell a 401 from a 429. @@ -1150,114 +1197,119 @@ export class StackOneToolSet { } /** - * The server's per-connector meta tools, whatever this toolset's own mode. + * The accounts a fetch lists: its account scope, narrowed in `search_execute` mode to the + * accounts on the providers it asks for. + */ + async #fetchScope(options: FetchToolsOptions, mode: ToolMode | undefined): Promise { + const scope = await this.#resolveAccountScope(options.accountIds); + const { providers } = options; + return mode === 'search_execute' && providers != null && providers.length > 0 + ? this.#accountsOnProviders(scope, providers) + : scope; + } + + /** + * The accounts in `scope` whose provider, as `GET /accounts` reports it, is one of `providers` + * (case-insensitive). Accounts are looked up — joining a lookup in flight — when one in scope + * has no provider recorded; one the lookup does not name is left out. + */ + async #accountsOnProviders( + scope: readonly string[], + providers: readonly string[], + ): Promise { + if (scope.some((accountId) => !this.#providers.has(accountId))) { + await (this.#fetchingAccounts ?? this.fetchAccounts()); + } + const wanted = new Set(providers.map((provider) => provider.toLowerCase())); + return scope.filter((accountId) => { + const provider = this.#providers.get(accountId); + return provider !== undefined && wanted.has(provider.toLowerCase()); + }); + } + + /** + * A tool of the `search_execute` listing for `accountIds`, whatever this toolset's own mode. + * + * The mode is passed down rather than switched on the instance, so a concurrent `fetchTools()` + * can never read the switched mode and cache these tools under the wrong key. * - * The mode is passed down rather than switched on the instance, so a concurrent - * `fetchTools()` can never read the switched mode and cache meta tools under the wrong key. + * @throws ToolSetLoadError If the server did not serve it. */ - async #metaTools( - suffix: string, - accountIds: string[] | null | undefined, - retry?: ReadonlySet, - ): Promise<{ tools: StackOneTool[]; failed: ScopedCatalog['failed'] }> { - const { tools, failed } = await this.#fetchTools({ accountIds, mode: 'search_execute' }, retry); - return { - tools: tools.getStackOneTools().filter((tool) => tool.name.endsWith(suffix)), - failed, - }; + async #sessionTool(name: string, accountIds: string[] | null | undefined): Promise { + const tools = await this.fetchTools({ accountIds, mode: 'search_execute' }); + const tool = tools.getStackOneTools().find((candidate) => candidate.name === name); + if (tool === undefined) { + throw new ToolSetLoadError(`The server did not serve ${name} for this account scope.`); + } + return tool; } /** * Find actions matching a natural-language query. * - * Searches every linked connector and ranks the results together, so a catalog of hundreds of - * tools never has to fit in a model's context. A connector that fails to search is skipped - * with a warning, unless they all fail. + * One `stackone_search_actions` call searches every connector linked on the accounts in scope, + * ranked by the server, so a catalog of hundreds of tools never has to fit in a model's context. * * Rate limits: a request answered 429 is retried up to 3 times, after the server's * `Retry-After` (capped at 30s) or a 1s/2s/4s backoff, unless that wait would outlast the - * `timeout`. One still rate limited after that fails the whole search rather than being - * skipped. + * `timeout`. * * @param query What you want to do, e.g. "list recent comments". - * @returns At most `topK` actions, best first across every connector, each carrying - * `action_id`, the `account_id` of the account whose connector found it, and the - * `session_id` of the search when the server issued one. The same action linked on two - * accounts is two hits. Pass `session_id` to {@link execute} and {@link submitFeedback} to - * link the calls, and `account_id` in `accountIds` to run the action on that account. - * @throws ToolSetConfigError If `topK` is not an integer between 1 and 50. - * @throws StackOneAPIError With status 429 if a request is still rate limited after retries. - * @throws ToolSetLoadError If every connector fails. + * @returns At most `topK` actions, best first, each as the server returned it — `action_id`, + * `provider`, the `available_accounts` that can run it — plus the `session_id` of the search + * when the server issued one. Pass `session_id` to {@link execute} and {@link submitFeedback} + * to link the calls, and one of `available_accounts` in `accountIds` to choose the account. + * @throws ToolSetConfigError If `topK` is not an integer between 1 and 50, or more than 50 + * accounts are in scope. + * @throws StackOneAPIError If the search fails, including a 429 that outlasted its retries. + * @throws ToolSetLoadError If the server did not serve `stackone_search_actions`. */ async search(query: string, options: SearchOptions = {}): Promise { const topK = options.topK === undefined ? 10 : options.topK; assertSearchArguments(query, topK); - const { tools } = await this.#metaTools('_search_actions', options.accountIds); - if (tools.length === 0) { - return []; - } - - // Fan out the way fetchTools() does: serially, a dozen connectors would cost the sum of - // their latencies on the headline call. - const settled = await settleWithConcurrency( - tools, - MAX_CONCURRENCY, - (tool) => searchConnector(tool, query, topK), - isRateLimitFailure, - ); - const results: SearchResult[] = []; - const failures: string[] = []; - settled.forEach((outcome, index) => { - if (outcome.status === 'fulfilled') { - results.push(...outcome.value); - } else { - failures.push(`${tools[index]?.name}: ${describeError(outcome.reason)}`); - } - }); - if (failures.length > 0 && results.length === 0) { - throw new ToolSetLoadError(`No connector returned results. ${failures.join(' | ')}`); - } - for (const failure of failures) { - warn(`Skipping connector that failed to search — ${failure}`); - } - - // Concatenating per-connector results would leave them grouped by connector, so results[0] - // would be the best hit of whichever connector answered first rather than the best hit - // overall. The server scores every action on the same scale, so rank globally, then cut to - // topK: each connector was asked for topK, so the merged list can hold many more. - return sorted(results, (left, right) => scoreOf(right) - scoreOf(left)).slice(0, topK); + const tool = await this.#sessionTool(SEARCH_ACTIONS_TOOL_NAME, options.accountIds); + const found = await tool.execute({ query, top_k: topK }); + // The server returns session_id once per search, beside the actions. Each hit carries it, so + // a caller holding one hit can still link its execute() to this search. + const sessionId = found.session_id; + const traced = + typeof sessionId === 'string' && sessionId !== '' ? { session_id: sessionId } : {}; + // Cut to topK: the server lists a synced copy, or a prerequisite, beside the hits it ranked. + return (Array.isArray(found.actions) ? found.actions : []) + .slice(0, topK) + .filter((action): action is SearchResult => isPlainObject(action)) + .map((action) => Object.assign(action, traced)); } /** * Execute an action by id, as returned by {@link search}. * - * Always runs through the connector's `*_execute_action` meta tool, so `args` is the nested - * envelope every action's `example_request` shows — `{ query: {...}, path: {...}, body: {...} }`. - * A `fetchTools()` tool takes the keys its own served schema names instead; routing by whether + * Always runs through `stackone_execute_action`, so `args` is the nested envelope every + * action's `example_request` shows — `{ query: {...}, path: {...}, body: {...} }`. A + * `fetchTools()` tool takes the keys its own served schema names instead; routing by whether * an id happened to be in the catalog would make the argument shape depend on something the - * caller cannot see. The connector is the longest one whose name prefixes `actionId`, and `actionId` is - * pinned last, so a model-supplied `action_id` in `args` cannot replace it. + * caller cannot see. `actionId` is pinned last, so a model-supplied `action_id` in `args` + * cannot replace it. + * + * The server routes the action to the account in scope on its connector. When several are, + * it answers with an error naming them: pass one in `accountIds`. * - * `args.headers` is forwarded to the action: `*_execute_action` serves `headers` as an open - * object, so any header name is declared — except `Authorization`, `x-account-id`, + * `args.headers` is forwarded to the action: `stackone_execute_action` serves `headers` as an + * open object, so any header name is declared — except `Authorization`, `x-account-id`, * `User-Agent` and `x-end-user-id`, which the SDK sets itself and drops here with a warning. * * @param actionId The action to run, e.g. `linear_list_issues`. * @param args The action's arguments. * @param options.sessionId The `session_id` a search hit carries, to link this call to it. + * @param options.accountIds The accounts the action may run on. Exactly one is sent as + * `stackone_account_id`, choosing it. * @returns The action's result as the server wrote it: `{ isError: false, result, … }`. - * @param options.accountIds Restrict routing to these accounts. Pass a search hit's - * `account_id` to run the action on the account that found it. - * @throws ToolSetConfigError If the arguments are malformed, or the action's connector is - * linked on more than one account and the call names none. - * @throws ToolSetLoadError If no linked connector serves the action, or an account in scope - * failed to list — after being listed again now, unless the provider lookup failed to name - * it in the last 30 seconds — and its provider is the action's connector or unknown. - * @throws StackOneAPIError If the action fails, or the lookup of a failed account's provider - * is rate limited. + * @throws ToolSetConfigError If the arguments are malformed, or more than 50 accounts are in + * scope. + * @throws ToolSetLoadError If the server did not serve `stackone_execute_action`. + * @throws StackOneAPIError If the action fails, or the server cannot choose its account. */ - // oxlint-disable-next-line eslint/complexity -- the routing rules for failed accounts read in order here; split, they would not async execute( actionId: string, args?: JsonObject, @@ -1267,116 +1319,27 @@ export class StackOneToolSet { const { sessionId } = options; assertSessionId(sessionId); - const suffix = '_execute_action'; - const lowered = actionId.toLowerCase(); - const started = retryTiming.now(); - let { tools, failed } = await this.#metaTools(suffix, options.accountIds); - - // An account that failed to list may serve this action too: its own connector's, or one we - // cannot tell. Running on whichever account did list would pick for the caller — possibly - // another end user's account — so refuse until it lists, or the caller names an account. - // The action's connector is the longest prefix among those listed and the providers - // `GET /accounts` named, as execute() routes: a failed `browser` account cannot serve - // `browser_linkedin_search`. - const inReach = (): ScopedCatalog['failed'] => { - const connector = longestConnector(lowered, [ - ...tools.map((tool) => connectorOf(tool, suffix)), - ...[...this.#providers.values()].map((provider) => provider.toLowerCase()), - ]); - return failed.filter(([accountId]) => { - const provider = this.#providers.get(accountId); - return provider === undefined || provider.toLowerCase() === connector; - }); - }; - // With explicit account ids no GET /accounts has named the failed accounts' providers, so - // one dead account would refuse every action. Ask once — joining a lookup in flight — and - // treat the provider as unknown still if that fails too, unless it was rate limited: the - // key's 429 is fatal, as it is to the end-user lookup. An account the lookup recently - // failed to name is not asked about again until its window is up. - const failedIds = failed.map(([accountId]) => accountId); - const missedRecently = new Set( - failedIds.filter( - (accountId) => - started - (this.#providerMisses.get(accountId) ?? -Infinity) < FAILED_ACCOUNT_RETRY_MS, - ), - ); - const unknown = failedIds.filter( - (accountId) => !this.#providers.has(accountId) && !missedRecently.has(accountId), - ); - if (unknown.length > 0) { - const generation = this.#cacheGeneration; - await (this.#fetchingAccounts ?? this.fetchAccounts()).catch((error: unknown) => { - if (isRateLimitFailure(error)) { - throw error; - } - }); - // A miss is this call's to act on whatever happens, but only recorded for later calls - // if no clearCatalogCache() came in between, which promises to forget misses. - const missedAt = retryTiming.now(); - for (const accountId of unknown) { - if (!this.#providers.has(accountId)) { - missedRecently.add(accountId); - if (generation === this.#cacheGeneration) { - this.#providerMisses.set(accountId, missedAt); - } - } - } - } - // Listed again now rather than when due, but only those that could serve the action, did - // not just fail for this call, and were not recently missed by the lookup: re-listing an - // account on another provider would make this call wait out its timeout, and re-listing one - // the lookup cannot name would make every call wait it out. - const retry = inReach().filter( - ([accountId, , at]) => at < started && !missedRecently.has(accountId), - ); - if (retry.length > 0) { - ({ tools, failed } = await this.#metaTools( - suffix, - options.accountIds, - new Set(retry.map(([accountId]) => accountId)), - )); - } - const unlisted = inReach(); - if (unlisted.length > 0) { - throw new ToolSetLoadError( - `${JSON.stringify(actionId)} may be served by an account that failed to list (${unlisted.map(([accountId, message]) => `${accountId}: ${message}`).join('; ')}). Pass the account id to use, such as a search hit's account_id.`, - ); - } - - const matches = tools.filter((tool) => lowered.startsWith(`${connectorOf(tool, suffix)}_`)); - if (matches.length === 0) { - throw new ToolSetLoadError( - `No connector found for ${JSON.stringify(actionId)}. Use search() to discover valid action ids.`, - ); - } + const tool = await this.#sessionTool(EXECUTE_ACTION_TOOL_NAME, options.accountIds); + const chosen = [...new Set(options.accountIds ?? [])]; - // Longest connector wins: with both `browser` and `browser_linkedin` linked, the first - // token alone would route every browser_linkedin action to browser. - const longest = Math.max(...matches.map((tool) => connectorOf(tool, suffix).length)); - const finalists = matches.filter((tool) => connectorOf(tool, suffix).length === longest); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- matches is non-empty, so finalists, its longest, is too - const [tool] = finalists as [StackOneTool, ...StackOneTool[]]; - if (finalists.length > 1) { - // The same provider linked twice, which discovery makes common. Picking one would run the - // action against an account the caller never chose — another end user's, possibly. - throw new ToolSetConfigError( - `${JSON.stringify(actionId)} matches ${finalists.length} connectors on different accounts (${finalists.map((t) => `${t.name} on ${t.getAccountId()}`).join(', ')}). Pass the account id to use, such as a search hit's account_id.`, - ); - } - - // action_id LAST, deleted first so it is last in key order too. Spreading the arguments - // over it would let a model-supplied "action_id" replace the action the caller pinned — the - // exact thing a host app pins it for. session_id only when given: the served schema makes - // it an optional string, so an absent key is valid and a null is not. + // Each option the caller set replaces the argument of its name, deleted first so it lands + // after the caller's keys, and action_id last of all: spreading the arguments over it would + // let a model-supplied "action_id" replace the action the caller pinned — the exact thing a + // host app pins it for. Optional ones only when given: the served schema makes them optional + // strings, so an absent key is valid and a null is not. const callArguments: JsonObject = { ...args }; - delete callArguments.action_id; + if (chosen.length === 1) { + delete callArguments.stackone_account_id; + callArguments.stackone_account_id = chosen[0]; + } if (sessionId != null) { delete callArguments.session_id; callArguments.session_id = sessionId; } + delete callArguments.action_id; callArguments.action_id = actionId; - // `*_execute_action` answers with the action's result wrapper; see ActionResult. + // stackone_execute_action answers with the action's result wrapper; see ActionResult. // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- served JSON in the documented ActionResult shape return (await tool.execute(callArguments)) as ActionResult; } @@ -1431,8 +1394,8 @@ export class StackOneToolSet { // One account, one tools/call: the tool is global, so every account's copy records the same // feedback, and calling each would record it once per account. The lowest id is the one a - // caller can predict, whatever order GET /accounts lists them in. search_execute lists two - // meta tools per connector where individual mode lists every action. + // caller can predict, whatever order GET /accounts lists them in. search_execute lists three + // tools where individual mode lists every action. const accountIds = (await this.#resolveAccountScope(options.accountIds)).slice(0, 1); const tool = (await this.fetchTools({ accountIds, mode: 'search_execute' })).getTool( SUBMIT_FEEDBACK_TOOL_NAME, diff --git a/src/types.ts b/src/types.ts index 208ae4f4..9c6eefdb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,10 +68,11 @@ export type JsonSchemaProperties = Record; * How the MCP endpoint lists tools. * * `'individual'` (the server default) lists one tool per action — hundreds per account. - * `'search_execute'` lists two meta tools per connector instead: a `*_search_actions` that ranks - * actions for a natural-language query and an `*_execute_action` that runs one by id. The - * catalog stays small however many accounts are linked, which is what keeps it inside a model's - * context. + * `'search_execute'` lists two tools spanning every account in scope instead: + * `stackone_search_actions`, which ranks actions for a natural-language query, and + * `stackone_execute_action`, which runs one by id, with `stackone_list_accounts` when there are + * several accounts. The catalog stays small however many accounts are linked, which is what keeps + * it inside a model's context. */ export type ToolMode = 'individual' | 'search_execute'; @@ -205,6 +206,13 @@ export type StackOneAccount = JsonObject & { */ export type SearchResult = JsonObject & { action_id: string; + /** The connector the action belongs to. */ + provider?: string; + /** + * The accounts in scope that can run the action. When there is more than one, pass the chosen + * one's `id` in `execute()`'s `accountIds`. + */ + available_accounts?: SearchHitAccount[]; /** What the action does. */ description?: string; /** How well the action matched the query; results are ranked on it, highest first. */ @@ -221,11 +229,16 @@ export type SearchResult = JsonObject & { * to `execute()` and `submitFeedback()` to link those calls to this search. */ session_id?: string; +}; + +/** An account a search hit can run on, as `stackone_search_actions` lists it. */ +export type SearchHitAccount = JsonObject & { + id: string; /** - * The account whose connector found this hit. The same action linked on two accounts is two - * hits; pass this in `execute()`'s `accountIds` to run the action on this one. + * The account's label, its owner's name, or its id. Absent when only one account is in scope: + * the server's one-account search names none, so the SDK fills in the id alone. */ - account_id: string; + name?: string; }; /** diff --git a/src/vectors.test.ts b/src/vectors.test.ts index 9dad7b39..5759f471 100644 --- a/src/vectors.test.ts +++ b/src/vectors.test.ts @@ -486,13 +486,17 @@ const listByAccount = (names: string[]) => { ); }; -/** Answer every tools/call with `respond`, without a server. */ -const respondToCalls = (respond: (tool: StackOneMcpTool) => JsonObject | Promise) => { - vi.spyOn(StackOneMcpTool.prototype, 'execute').mockImplementation(async function ( - this: StackOneMcpTool, - ) { - return respond(this); - }); +/** A tool of the pair the server serves for the one account `acc-1`, on connector `linear`. */ +const onePairTool = async (name: string) => { + listMock.mockResolvedValue( + ['linear_acc-1_search_actions', 'linear_acc-1_execute_action'].map((served) => ({ + name: served, + description: '', + inputSchema: {}, + })), + ); + const tools = await newToolSet({ accountId: 'acc-1' }).fetchTools({ mode: 'search_execute' }); + return tools.getStackOneTool(name); }; const errorOf = (act: () => unknown): Promise => @@ -574,60 +578,11 @@ const emitters: Record< await newToolSet().fetchTools({ accountIds: ['acc-1', 'acc-2'] }); return { emitted: { warnings: warnings() }, values: { account_id: 'acc-2', error: 'boom' } }; }, - 'skip-connector': async () => { - listByAccount(['a_acc-1_search_actions', 'b_acc-1_search_actions']); - respondToCalls((tool) => { - if (tool.name.startsWith('b_')) { - throw new Error('boom'); - } - return { actions: [{ action_id: 'a_x' }] }; - }); - await newToolSet({ accountId: 'acc-1' }).search('q'); - return { - emitted: { warnings: warnings() }, - values: { tool: 'b_acc-1_search_actions', error: 'boom' }, - }; - }, 'duplicate-tool-names': async () => { listMock.mockResolvedValue([{ name: 'dup', description: '', inputSchema: {} }]); await newToolSet().fetchTools({ accountIds: ['acc-1', 'acc-2'] }); return { emitted: { warnings: warnings() }, values: { count: 1, names: 'dup' } }; }, - 'ambiguous-connector': async () => { - listByAccount(['linear_acc1_execute_action', 'linear_acc2_execute_action']); - respondToCalls(() => ({ data: {} })); - return { - emitted: { - error: await errorOf(() => - newToolSet({ accountIds: ['acc2', 'acc1'] }).execute('linear_list_issues'), - ), - }, - values: { - action_id: 'linear_list_issues', - count: 2, - tools: 'linear_acc1_execute_action on acc1, linear_acc2_execute_action on acc2', - }, - }; - }, - 'connector-account-unavailable': async () => { - listMock.mockImplementation(async ({ headers }) => { - const account = headers['x-account-id']; - if (account === 'acc2') { - throw new Error('boom'); - } - return [{ name: `linear_${account}_execute_action`, description: '', inputSchema: {} }]; - }); - respondToCalls(() => ({ data: {} })); - // Explicit ids: no GET /accounts, so acc2's provider is unknown and it blocks. - return { - emitted: { - error: await errorOf(() => - newToolSet({ accountIds: ['acc1', 'acc2'] }).execute('linear_list_issues'), - ), - }, - values: { action_id: 'linear_list_issues', failures: 'acc2: boom' }, - }; - }, 'non-shared-accounts-skipped': async () => { server.use( http.get(`${TEST_BASE_URL}/accounts`, () => @@ -830,19 +785,6 @@ const emitters: Record< values: { failures: 'acc-1: down acc-1; acc-2: down acc-2' }, }; }, - 'no-connector-returned-results': async () => { - listByAccount(['a_acc-1_search_actions', 'b_acc-1_search_actions']); - respondToCalls((tool) => { - throw new Error(`down ${tool.name}`); - }); - return { - emitted: { error: await errorOf(() => newToolSet({ accountId: 'acc-1' }).search('q')) }, - values: { - failures: - 'a_acc-1_search_actions: down a_acc-1_search_actions | b_acc-1_search_actions: down b_acc-1_search_actions', - }, - }; - }, 'fetch-tools-failed': async () => { listMock.mockRejectedValue(new Error('boom')); return { @@ -927,13 +869,37 @@ const emitters: Record< values: {}, }; }, - 'no-connector-for-action': async () => { - listByAccount(['linear_acc1_execute_action']); + 'trio-tool-not-served': async () => { + // The policy-disabled shape: a search_execute request answered with individual tools. + listMock.mockResolvedValue([{ name: 'jira_list_issues', description: '', inputSchema: {} }]); + return { + emitted: { error: await errorOf(() => newToolSet({ accountId: 'acc-1' }).search('issues')) }, + values: { tool: 'stackone_search_actions' }, + }; + }, + 'too-many-accounts': async () => { + const accountIds = Array.from({ length: 51 }, (_, index) => `acc-${index}`); + return { + emitted: { error: await errorOf(() => newToolSet({ accountIds }).search('issues')) }, + values: { count: 51 }, + }; + }, + 'account-outside-scope': async () => { + const execute = await onePairTool('stackone_execute_action'); return { emitted: { - error: await errorOf(() => newToolSet({ accountId: 'acc1' }).execute('jira_list_issues')), + error: await errorOf(() => + execute.execute({ action_id: 'linear_list_issues', stackone_account_id: 'acc-2' }), + ), }, - values: { action_id: 'jira_list_issues' }, + values: { value: 'acc-2', tool: 'stackone_execute_action', account_id: 'acc-1' }, + }; + }, + 'provider-outside-scope': async () => { + const search = await onePairTool('stackone_search_actions'); + return { + emitted: { error: await errorOf(() => search.execute({ query: 'x', provider: 'jira' })) }, + values: { value: 'jira', provider: 'linear' }, }; }, 'arguments-not-finite': async () => ({ diff --git a/tests/examples/search-and-execute.test.ts b/tests/examples/search-and-execute.test.ts index 734eeea0..432abdad 100644 --- a/tests/examples/search-and-execute.test.ts +++ b/tests/examples/search-and-execute.test.ts @@ -38,12 +38,19 @@ describe('search-and-execute example e2e', () => { const printed = log.mock.calls.map((args) => String(args[0])).join('\n'); expect(printed).toContain('default testprovider active'); - expect(printed).toContain('0.800 mock_list_items'); + expect(printed).toContain('0.800 mock_list_items [default]'); expect(printed).toContain('Feedback recorded.'); - expect(calls.map((call) => [call.name, call.arguments.session_id])).toEqual([ - ['mock_default_search_actions', undefined], - ['mock_default_execute_action', MOCK_SEARCH_SESSION_ID], - ['stackone_submit_feedback', MOCK_SEARCH_SESSION_ID], + expect( + calls.map((call) => [ + call.name, + call.arguments.stackone_account_id, + call.arguments.session_id, + ]), + ).toEqual([ + // One account: the server's pair, its stackone_account_id dropped as the pair's own. + ['mock_default_search_actions', undefined, undefined], + ['mock_default_execute_action', undefined, MOCK_SEARCH_SESSION_ID], + ['stackone_submit_feedback', undefined, MOCK_SEARCH_SESSION_ID], ]); }); diff --git a/tests/vectors/messages.json b/tests/vectors/messages.json index 868235b1..b5a1d477 100644 --- a/tests/vectors/messages.json +++ b/tests/vectors/messages.json @@ -168,28 +168,7 @@ "python": true, "node": true }, - "notes": "The error part is whichever message the failure carries, so the whole text is the same in both SDKs only where that message is." - }, - { - "id": "skip-connector", - "kind": "warning", - "template": "Skipping connector that failed to search — {tool}: {error}", - "placeholders": { - "tool": { - "format": "text", - "description": "the *_search_actions tool's name" - }, - "error": { - "format": "text", - "description": "the failure's own message" - } - }, - "python": "Skipping connector that failed to search — {tool}: {error}", - "node": "Skipping connector that failed to search — {tool}: {error}", - "matches": { - "python": true, - "node": true - } + "notes": "Individual mode only: a search_execute listing is one request for the whole scope, and its failure raises. The error part is whichever message the failure carries, so the whole text is the same in both SDKs only where that message is." }, { "id": "duplicate-tool-names", @@ -213,36 +192,6 @@ }, "notes": "Language-neutral wording: neither SDK's method or parameter names. Emitted once when a fetched collection holds a clashing name, and again by every adapter that keeps the first tool per name and drops the rest: OpenAI, LangChain, Pydantic AI, AI SDK and Claude Agent SDK. Listing order is ascending account id, so the first is the lowest account id's." }, - { - "id": "ambiguous-connector", - "kind": "error", - "error": { - "python": "ToolsetConfigError", - "node": "ToolSetConfigError" - }, - "template": "{action_id} matches {count} connectors on different accounts ({tools}). Pass the account id to use, such as a search hit's account_id.", - "placeholders": { - "action_id": { - "format": "json", - "description": "the action id" - }, - "count": { - "format": "integer", - "description": "how many connectors match: the longest connector prefix, linked on more than one account" - }, - "tools": { - "format": "text", - "description": "each matching *_execute_action tool as \"{tool} on {account_id}\", in ascending account-id order, joined by \", \"" - } - }, - "python": "{action_id} matches {count} connectors on different accounts ({tools}). Pass the account id to use, such as a search hit's account_id.", - "node": "{action_id} matches {count} connectors on different accounts ({tools}). Pass the account id to use, such as a search hit's account_id.", - "matches": { - "python": true, - "node": true - }, - "notes": "Raised by execute() when the action's connector is linked on more than one account in scope — no account id passed, or several; nothing is called. Was a warning that ran the first match." - }, { "id": "mcp-timeout", "kind": "error", @@ -254,7 +203,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "timeout": { "format": "seconds", @@ -280,7 +229,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "timeout": { "format": "seconds", @@ -332,7 +281,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "status": { "format": "integer", @@ -366,7 +315,7 @@ "placeholders": { "endpoint": { "format": "text", - "description": "the MCP endpoint URL, with its ?tool-mode query when it has one" + "description": "the MCP endpoint URL with its whole query as sent: ?tool-mode when it has one and, for search_execute, each x-account-id then each x-end-user-id parameter" }, "error_type": { "format": "text", @@ -555,27 +504,6 @@ }, "notes": "Only when every account in scope failed to list, and only when the failures do not all share one HTTP status: if they do, the SDK rethrows the StackOneAPIError itself (e.g. two accounts both answering 401 raise StackOneAPIError 401). The failures are the error's cause: Python raises it `from ExceptionGroup(...)` of them, Node passes `{ cause: new AggregateError(...) }`. An account that listed zero tools has not failed. Both SDKs join the failures with \"; \", and also raise when some accounts failed and the rest listed zero tools. Replaces no-account-returned-tools." }, - { - "id": "no-connector-returned-results", - "kind": "error", - "error": { - "python": "ToolsetLoadError", - "node": "ToolSetLoadError" - }, - "template": "No connector returned results. {failures}", - "placeholders": { - "failures": { - "format": "text", - "description": "each as \"{tool}: {error}\", joined by \" | \"" - } - }, - "python": "No connector returned results. {failures}", - "node": "No connector returned results. {failures}", - "matches": { - "python": true, - "node": true - } - }, { "id": "fetch-tools-failed", "kind": "error", @@ -849,25 +777,104 @@ } }, { - "id": "no-connector-for-action", + "id": "trio-tool-not-served", "kind": "error", "error": { "python": "ToolsetLoadError", "node": "ToolSetLoadError" }, - "template": "No connector found for {action_id}. Use search() to discover valid action ids.", + "template": "The server did not serve {tool} for this account scope.", "placeholders": { - "action_id": { + "tool": { + "format": "text", + "description": "the tool the call needs and the search_execute listing lacks: stackone_search_actions for search(), stackone_execute_action for execute()" + } + }, + "python": "The server did not serve {tool} for this account scope.", + "node": "The server did not serve {tool} for this account scope.", + "matches": { + "python": true, + "node": true + }, + "notes": "Raised by search() and execute() before any tools/call, when the account scope's search_execute listing does not carry the tool — the project's mcp_search_execute policy is disabled, say, so individual tools are served." + }, + { + "id": "too-many-accounts", + "kind": "error", + "error": { + "python": "ToolsetConfigError", + "node": "ToolSetConfigError" + }, + "template": "{count} accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.", + "placeholders": { + "count": { + "format": "integer", + "description": "how many distinct accounts are in scope" + } + }, + "python": "{count} accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.", + "node": "{count} accounts are in scope, but search and execute span at most 50 in one request. Pass account ids to narrow the scope.", + "matches": { + "python": true, + "node": true + }, + "notes": "Raised before any request when a search_execute listing would name more than 50 accounts, which UCA refuses with a 400." + }, + { + "id": "account-outside-scope", + "kind": "error", + "error": { + "python": "ToolArgumentsError", + "node": "ToolArgumentsError" + }, + "template": "stackone_account_id {value} is not an account {tool} can run on; it runs on {account_id}.", + "placeholders": { + "value": { + "format": "json", + "description": "the stackone_account_id given" + }, + "tool": { + "format": "json", + "description": "the tool's name as exposed: stackone_search_actions or stackone_execute_action" + }, + "account_id": { "format": "json", - "description": "the action id" + "description": "the one account the served pair is bound to" } }, - "python": "No connector found for {action_id}. Use search() to discover valid action ids.", - "node": "No connector found for {action_id}. Use search() to discover valid action ids.", + "python": "stackone_account_id {value} is not an account {tool} can run on; it runs on {account_id}.", + "node": "stackone_account_id {value} is not an account {tool} can run on; it runs on {account_id}.", "matches": { "python": true, "node": true - } + }, + "notes": "Raised before any tools/call by a one-account scope's search or execute tool, which runs the account's own served pair: a stackone_account_id naming that account is dropped, any other raises this." + }, + { + "id": "provider-outside-scope", + "kind": "error", + "error": { + "python": "ToolArgumentsError", + "node": "ToolArgumentsError" + }, + "template": "provider {value} is not linked in this account scope, which has {provider}.", + "placeholders": { + "value": { + "format": "json", + "description": "the provider given" + }, + "provider": { + "format": "json", + "description": "the account's connector: the served search tool's name minus __search_actions" + } + }, + "python": "provider {value} is not linked in this account scope, which has {provider}.", + "node": "provider {value} is not linked in this account scope, which has {provider}.", + "matches": { + "python": true, + "node": true + }, + "notes": "Raised before any tools/call by a one-account scope's search tool: a provider equal to the account's connector, compared case-insensitively, is dropped, any other raises this." }, { "id": "arguments-not-finite", @@ -1050,32 +1057,6 @@ }, "notes": "Emitted once at construction when the toolset's headers option names Authorization, x-account-id or User-Agent (any case, surrounding whitespace ignored). x-end-user-id is allowed in this option and is not warned about. The option's other headers go on every request, GET /accounts included, beneath the SDK's own." }, - { - "id": "connector-account-unavailable", - "kind": "error", - "error": { - "python": "ToolsetLoadError", - "node": "ToolSetLoadError" - }, - "template": "{action_id} may be served by an account that failed to list ({failures}). Pass the account id to use, such as a search hit's account_id.", - "placeholders": { - "action_id": { - "format": "json", - "description": "the action id execute() was called with, JSON-quoted as in ambiguous-connector" - }, - "failures": { - "format": "text", - "description": "each failed account as \"{account_id}: {error}\", where {error} is the failure's own message (as in skip-account), in ascending account-id order, joined by \"; \" — same format as all-accounts-failed" - } - }, - "python": "{action_id} may be served by an account that failed to list ({failures}). Pass the account id to use, such as a search hit's account_id.", - "node": "{action_id} may be served by an account that failed to list ({failures}). Pass the account id to use, such as a search hit's account_id.", - "matches": { - "python": true, - "node": true - }, - "notes": "Raised by execute() when an account in scope is marked failed and, after an immediate re-list, still fails, and that account's provider (from GET /accounts) matches the action's connector or is unknown (no GET /accounts record). A failed account on a different known provider does not block routing to the remaining account." - }, { "id": "non-shared-accounts-skipped", "kind": "warning",