From f0400c97763b7a1042b7e80eaeac5bf69927e2d3 Mon Sep 17 00:00:00 2001 From: Davies Ayo Date: Sat, 12 Sep 2026 12:37:29 +1000 Subject: [PATCH 1/5] Return tool annotations from describe.tool `ToolSchemaView` carried everything about a tool except the annotations a plugin declares on it, so code written inside `execute` could not tell an approval-gated tool from a plain one except by reading its prose description. The data was already persisted on the tool row and already used by the executor's own approval copy. `tools.schema` now projects the three fields declared in `ToolAnnotations` (`requiresApproval`, `approvalDescription`, `mayElicit`), and `describe.tool` passes them through. The fields are picked explicitly rather than spread, because plugins keep private bookkeeping alongside the contract: the mcp plugin stores its upstream tool name and `_meta` map in the same column, and none of that should reach a caller. The key is omitted when a tool declares no annotations, so the describe payload does not grow for the tools that have nothing to say. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/describe-tool-annotations.md | 5 ++++ .../core/execution/src/tool-invoker.test.ts | 22 +++++++++++++++ packages/core/execution/src/tool-invoker.ts | 10 ++++++- packages/core/sdk/src/executor.ts | 28 ++++++++++++++++++- packages/core/sdk/src/index.ts | 2 +- packages/core/sdk/src/types.ts | 12 ++++++++ 6 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 .changeset/describe-tool-annotations.md diff --git a/.changeset/describe-tool-annotations.md b/.changeset/describe-tool-annotations.md new file mode 100644 index 0000000000..127b8f38af --- /dev/null +++ b/.changeset/describe-tool-annotations.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Return a tool's declared annotations from `tools.schema` and `describe.tool`. Code inside `execute` can now read `requiresApproval`, `approvalDescription` and `mayElicit` without parsing the tool's prose description. diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 747bd23105..531c7ff28a 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -18,6 +18,7 @@ import { type AnyPlugin, type CredentialProvider, type Elicit, + type ToolAnnotations, type ToolDef, } from "@executor-js/sdk"; import { @@ -127,6 +128,7 @@ type TestToolSpec = { readonly outputJsonSchema?: unknown; /** Standard-schema validator applied to args in `invokeTool`. */ readonly validator?: Validator; + readonly annotations?: ToolAnnotations; readonly handler: (input: ToolHandlerInput) => Effect.Effect; }; @@ -179,6 +181,7 @@ const makeTestPlugin = (config: { description: spec.description, inputSchema: spec.inputJsonSchema, outputSchema: spec.outputJsonSchema, + ...(spec.annotations ? { annotations: spec.annotations } : {}), }), ), }), @@ -238,6 +241,10 @@ const crmPlugin = makeTestPlugin({ description: "Create a CRM contact record", inputJsonSchema: ContactInputJson, validator: ContactValidator, + annotations: { + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + }, handler: () => Effect.succeed({ id: "contact_1" }), }, { @@ -878,6 +885,21 @@ describe("tool discovery", () => { }), ); + it.effect("describes a tool's declared annotations, and omits the key when it has none", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + + const annotated = yield* describeTool(executor, "crm.org.main.createContact"); + expect(annotated.annotations).toEqual({ + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + }); + + const plain = yield* describeTool(executor, "crm.org.main.listContacts"); + expect(plain.annotations).toBeUndefined(); + }), + ); + it.effect("serves an observed shape with a provenance note once a schemaless tool runs", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 2df47644ed..d2ef3db205 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -82,6 +82,13 @@ type DescribedTool = { readonly outputTypeScript?: string; readonly outputTypeScriptNote?: string; readonly typeScriptDefinitions?: Record; + /** The tool's declared annotations, when it carries any. Lets code inside + * `execute` branch on approval posture without parsing the description. */ + readonly annotations?: { + readonly requiresApproval?: boolean; + readonly approvalDescription?: string; + readonly mayElicit?: boolean; + }; /** Set when the path resolves to no tool — mirrors invoke's tool_not_found. */ readonly error?: { readonly code: "tool_not_found"; @@ -135,7 +142,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< outputTypeScript: "DescribedTool", typeScriptDefinitions: { DescribedTool: - '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', + '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; annotations?: { requiresApproval?: boolean; approvalDescription?: string; mayElicit?: boolean; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', }, }, ], @@ -883,6 +890,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( } : {}), typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), + ...(schema.annotations ? { annotations: schema.annotations } : {}), }; return described; }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index efaf119212..995234400b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -199,7 +199,7 @@ import { ORG_SUBJECT, type ExecutorOwnerPolicyContext, } from "./owner-policy"; -import { ToolSchemaView, type IntegrationDetectionResult } from "./types"; +import { ToolAnnotationsView, ToolSchemaView, type IntegrationDetectionResult } from "./types"; import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool"; import { buildToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; @@ -1219,6 +1219,30 @@ const rowToTool = ( }; }; +// Projects a tool's annotations onto the schema view. Plugins persist extra +// keys alongside the declared contract (the mcp plugin stores its upstream tool +// name and `_meta` there so they survive to invokeTool), so the three declared +// fields are picked explicitly rather than spread: a caller reading the view +// gets the contract in `tool.ts` and nothing a plugin keeps for itself. +const toolAnnotationsView = ( + annotations: ToolAnnotations | undefined, +): ToolAnnotationsView | undefined => { + if (!annotations) return undefined; + const view: { + requiresApproval?: boolean; + approvalDescription?: string; + mayElicit?: boolean; + } = {}; + if (typeof annotations.requiresApproval === "boolean") { + view.requiresApproval = annotations.requiresApproval; + } + if (typeof annotations.approvalDescription === "string") { + view.approvalDescription = annotations.approvalDescription; + } + if (typeof annotations.mayElicit === "boolean") view.mayElicit = annotations.mayElicit; + return Object.keys(view).length > 0 ? ToolAnnotationsView.make(view) : undefined; +}; + // --------------------------------------------------------------------------- // Condition builders // --------------------------------------------------------------------------- @@ -5650,6 +5674,7 @@ export const createExecutor = Date: Sat, 12 Sep 2026 10:12:06 -0700 Subject: [PATCH 2/5] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); From bd71fd01118eb51bdab7a77dfe79691ffb6c21b5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:44:07 -0700 Subject: [PATCH 3/5] Verify annotation projections and expose resume failure context --- .../src/worker.e2e.node.test.ts | 4 +- e2e/scenarios/tool-annotations.test.ts | 44 +++++++++++++++++++ .../core/execution/src/tool-invoker.test.ts | 12 ++++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 e2e/scenarios/tool-annotations.test.ts diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a358827..0a9bc35d1f 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -513,7 +513,9 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { }; }>(resume); expect(resumed.result?.structuredContent?.status).not.toBe("execution_not_found"); - expect(resumed.result?.structuredContent?.recovery).not.toBe("re_execute"); + expect(resumed.result?.structuredContent?.recovery, JSON.stringify(resumed.result)).not.toBe( + "re_execute", + ); expect(resumed.result?.isError).toBeFalsy(); expect(resumed.result?.structuredContent?.status).toBe("completed"); }, 60_000); diff --git a/e2e/scenarios/tool-annotations.test.ts b/e2e/scenarios/tool-annotations.test.ts new file mode 100644 index 0000000000..352750ad15 --- /dev/null +++ b/e2e/scenarios/tool-annotations.test.ts @@ -0,0 +1,44 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { ToolAddress } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); +const annotations = Schema.Struct({ requiresApproval: Schema.Boolean }); +const descriptions = Schema.Struct({ + gated: Schema.Struct({ annotations }), + plain: Schema.Record(Schema.String, Schema.Unknown), +}); + +scenario( + "Tool discovery · declared approval annotations survive API and sandbox descriptions", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const address = ToolAddress.make("tools.executor.coreTools.connections.create"); + const view = yield* client.tools.schema({ query: { address } }); + expect(view.annotations).toEqual({ requiresApproval: true }); + const described = yield* session.call("execute", { + code: ` + return JSON.stringify({ + gated: await tools.describe.tool({ path: "executor.coreTools.connections.create" }), + plain: await tools.describe.tool({ path: "executor.coreTools.connections.list" }), + }); + `, + }); + expect(described.ok, described.text).toBe(true); + const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(descriptions))( + described.text, + ); + expect(result.gated.annotations).toEqual(view.annotations); + expect(result.plain).not.toHaveProperty("annotations"); + }), +); diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 531c7ff28a..c56d40e6a1 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -181,7 +181,15 @@ const makeTestPlugin = (config: { description: spec.description, inputSchema: spec.inputJsonSchema, outputSchema: spec.outputJsonSchema, - ...(spec.annotations ? { annotations: spec.annotations } : {}), + ...(spec.annotations + ? { + annotations: { + ...spec.annotations, + upstreamToolName: "private-provider-tool", + _meta: { privateMarker: "not-public" }, + }, + } + : {}), }), ), }), @@ -244,6 +252,7 @@ const crmPlugin = makeTestPlugin({ annotations: { requiresApproval: true, approvalDescription: "Creates a contact record in the CRM", + mayElicit: false, }, handler: () => Effect.succeed({ id: "contact_1" }), }, @@ -893,6 +902,7 @@ describe("tool discovery", () => { expect(annotated.annotations).toEqual({ requiresApproval: true, approvalDescription: "Creates a contact record in the CRM", + mayElicit: false, }); const plain = yield* describeTool(executor, "crm.org.main.listContacts"); From 891288a1ce5f30dc1fe495ef82cdb3a9b91a5142 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:47:08 -0700 Subject: [PATCH 4/5] Model private annotation metadata through the test adapter --- packages/core/execution/src/tool-invoker.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index c56d40e6a1..c9e85459d8 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -162,6 +162,12 @@ const validateArgs = ( ); }; +const withPrivateAnnotations = (annotations: ToolAnnotations) => ({ + ...annotations, + upstreamToolName: "private-provider-tool", + _meta: { privateMarker: "not-public" }, +}); + const makeTestPlugin = (config: { readonly pluginId: string; readonly integration: string; @@ -183,11 +189,7 @@ const makeTestPlugin = (config: { outputSchema: spec.outputJsonSchema, ...(spec.annotations ? { - annotations: { - ...spec.annotations, - upstreamToolName: "private-provider-tool", - _meta: { privateMarker: "not-public" }, - }, + annotations: withPrivateAnnotations(spec.annotations), } : {}), }), From 82c2dd3254003f9ee047c4e8494fafaee5267ab0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:31:36 -0700 Subject: [PATCH 5/5] Use the static tool address for schema discovery --- e2e/scenarios/tool-annotations.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/scenarios/tool-annotations.test.ts b/e2e/scenarios/tool-annotations.test.ts index 352750ad15..398583db7f 100644 --- a/e2e/scenarios/tool-annotations.test.ts +++ b/e2e/scenarios/tool-annotations.test.ts @@ -23,7 +23,7 @@ scenario( const identity = yield* target.newIdentity(); const client = yield* makeClient(api, identity); const session = mcp.session(identity); - const address = ToolAddress.make("tools.executor.coreTools.connections.create"); + const address = ToolAddress.make("executor.coreTools.connections.create"); const view = yield* client.tools.schema({ query: { address } }); expect(view.annotations).toEqual({ requiresApproval: true }); const described = yield* session.call("execute", {