* fix(mcp): report completed connected tool * Test queue timeout with a controlled clock * Verify connected tool provenance over MCP * Exclude upstream tool failures from completion metadata ---------
Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"@executor-js/execution": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Completed MCP execute results now include `toolName` when a script successfully uses exactly one connected tool. Executions that use distinct tools remain unlabeled, and internal call provenance is not exposed in the MCP response. | |
| @@ -21,7 +21,7 @@ import { randomBytes } from "node:crypto"; · 20 unmodified lines | ||
| 21 | 21 | import { createServer } from "node:http"; |
| 22 | 22 | |
| 23 | 23 | import { expect } from "@effect/vitest"; |
| 24 | −import { Effect } from "effect"; | |
| 24 | +import { Effect, Schema } from "effect"; | |
| 25 | 25 | import { composePluginApi } from "@executor-js/api/server"; |
| 26 | 26 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 27 | 27 | import { |
| @@ -38,6 +38,13 @@ import type { McpSession } from "../src/surfaces/mcp"; · 10 unmodified lines | ||
| 38 | 38 | |
| 39 | 39 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 40 | 40 | |
| 41 | +const completion = Schema.Struct({ | |
| 42 | + structuredContent: Schema.Struct({ | |
| 43 | + status: Schema.String, | |
| 44 | + toolName: Schema.optional(Schema.String), | |
| 45 | + }), | |
| 46 | +}); | |
| 47 | + | |
| 41 | 48 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 42 | 49 | |
| 43 | 50 | type UpstreamHandle = { |
| @@ -114,14 +121,14 @@ const executeApproved = (session: McpSession, code: string) => · 70 unmodified lines | ||
| 114 | 121 | guard += 1; |
| 115 | 122 | } |
| 116 | 123 | expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); |
| 117 | − return result.text; | |
| 124 | + return result; | |
| 118 | 125 | }); |
| 119 | 126 | |
| 120 | 127 | /** Invoke a dynamic tool by full address and parse the envelope it returns. */ |
| 121 | 128 | const invokeEnvelope = (session: McpSession, address: string, args: unknown = {}) => |
| 122 | 129 | Effect.map( |
| 123 | 130 | executeApproved(session, invokeByAddressCode(address, args)), |
| 124 | − (text) => JSON.parse(text) as ToolEnvelope, | |
| 131 | + (result) => JSON.parse(result.text) as ToolEnvelope, | |
| 125 | 132 | ); |
| 126 | 133 | |
| 127 | 134 | // --------------------------------------------------------------------------- |
| @@ -136,6 +143,20 @@ const widgetsSpec = (baseUrl: string): string => · 8 unmodified lines | ||
| 136 | 143 | info: { title: "Widgets API", version: "1.0.0" }, |
| 137 | 144 | servers: [{ url: baseUrl }], |
| 138 | 145 | paths: { |
| 146 | + "/unavailable": { | |
| 147 | + get: { | |
| 148 | + operationId: "unavailableWidgets", | |
| 149 | + summary: "Unavailable widgets", | |
| 150 | + responses: { "200": { description: "widgets" } }, | |
| 151 | + }, | |
| 152 | + }, | |
| 153 | + "/widgets/count": { | |
| 154 | + get: { | |
| 155 | + operationId: "countWidgets", | |
| 156 | + summary: "Count widgets", | |
| 157 | + responses: { "200": { description: "widget count" } }, | |
| 158 | + }, | |
| 159 | + }, | |
| 139 | 160 | "/widgets": { |
| 140 | 161 | get: { |
| 141 | 162 | operationId: "listWidgets", |
| @@ -184,7 +205,7 @@ scenario( · 42 unmodified lines | ||
| 184 | 205 | }, |
| 185 | 206 | }); |
| 186 | 207 | const created = JSON.parse( |
| 187 | − yield* executeApproved(session, createConnectionCode(slug)), | |
| 208 | + (yield* executeApproved(session, createConnectionCode(slug))).text, | |
| 188 | 209 | ) as ToolEnvelope; |
| 189 | 210 | expect(created.ok, `the no-auth connection was created: ${JSON.stringify(created)}`).toBe( |
| 190 | 211 | true, |
| @@ -199,7 +220,8 @@ scenario( · 8 unmodified lines | ||
| 199 | 220 | const path = address!.replace(/^tools\./, ""); |
| 200 | 221 | |
| 201 | 222 | // 1. A well-addressed call executes and carries the upstream's payload. |
| 202 | − const success = yield* invokeEnvelope(session, address!); | |
| 223 | + const successfulCall = yield* executeApproved(session, invokeByAddressCode(address!, {})); | |
| 224 | + const success = JSON.parse(successfulCall.text) as ToolEnvelope; | |
| 203 | 225 | expect( |
| 204 | 226 | success.ok, |
| 205 | 227 | `the call succeeded (got: ${JSON.stringify(success.error ?? {}).slice(0, 400)})`, |
| @@ -207,7 +229,73 @@ scenario( · 1 unmodified line | ||
| 207 | 229 | expect(JSON.stringify(success.data), "the upstream's payload comes back").toContain( |
| 208 | 230 | "anvil", |
| 209 | 231 | ); |
| 232 | + const structured = (successfulCall.raw as { readonly structuredContent?: unknown }) | |
| 233 | + .structuredContent; | |
| 234 | + expect(structured, "the completed MCP result includes structured content").toMatchObject({ | |
| 235 | + status: "completed", | |
| 236 | + toolName: path, | |
| 237 | + }); | |
| 238 | + expect(structured, "the internal tool-call trace is not exposed").not.toHaveProperty( | |
| 239 | + "toolPaths", | |
| 240 | + ); | |
| 210 | 241 | expect(upstream.requests(), "the upstream served exactly one call").toBe(1); |
| 242 | + const discovery = yield* executeApproved( | |
| 243 | + session, | |
| 244 | + 'return await tools.search({ query: "widgets" });', | |
| 245 | + ); | |
| 246 | + const discoveryResult = yield* Schema.decodeUnknownEffect(completion)(discovery.raw); | |
| 247 | + expect(discoveryResult.structuredContent.toolName).toBeUndefined(); | |
| 248 | + expect(discovery.raw).not.toHaveProperty("structuredContent.toolPaths"); | |
| 249 | + | |
| 250 | + const failed = yield* executeApproved( | |
| 251 | + session, | |
| 252 | + invokeByAddressCode(address!.replace(/listWidgets$/, "missingWidget"), {}), | |
| 253 | + ); | |
| 254 | + const failedResult = yield* Schema.decodeUnknownEffect(completion)(failed.raw); | |
| 255 | + expect(failedResult.structuredContent.toolName).toBeUndefined(); | |
| 256 | + expect(failed.raw).not.toHaveProperty("structuredContent.toolPaths"); | |
| 257 | + const unavailableAddress = yield* Schema.decodeUnknownEffect(Schema.String)( | |
| 258 | + tools | |
| 259 | + .filter((tool) => String(tool.integration) === slug) | |
| 260 | + .map((tool) => String(tool.address)) | |
| 261 | + .find((candidate) => candidate.endsWith("unavailableWidgets")), | |
| 262 | + ); | |
| 263 | + const upstreamFailure = yield* executeApproved( | |
| 264 | + session, | |
| 265 | + invokeByAddressCode(unavailableAddress, {}), | |
| 266 | + ); | |
| 267 | + const upstreamFailureResult = yield* Schema.decodeUnknownEffect(completion)( | |
| 268 | + upstreamFailure.raw, | |
| 269 | + ); | |
| 270 | + expect(upstreamFailure.text).toContain('"ok":false'); | |
| 271 | + expect(upstreamFailureResult.structuredContent.toolName).toBeUndefined(); | |
| 272 | + expect(upstreamFailure.raw).not.toHaveProperty("structuredContent.toolPaths"); | |
| 273 | + | |
| 274 | + const repeated = yield* executeApproved( | |
| 275 | + session, | |
| 276 | + ` | |
| 277 | + await tools[${JSON.stringify(path)}]({}); | |
| 278 | + return await tools[${JSON.stringify(path)}]({}); | |
| 279 | + `, | |
| 280 | + ); | |
| 281 | + const repeatedResult = yield* Schema.decodeUnknownEffect(completion)(repeated.raw); | |
| 282 | + expect(repeatedResult.structuredContent.toolName).toBe(path); | |
| 283 | + expect(repeated.raw).not.toHaveProperty("structuredContent.toolPaths"); | |
| 284 | + const anotherAddress = tools | |
| 285 | + .filter((tool) => String(tool.integration) === slug) | |
| 286 | + .map((tool) => String(tool.address)) | |
| 287 | + .find((candidate) => candidate.endsWith("countWidgets")); | |
| 288 | + const anotherPath = yield* Schema.decodeUnknownEffect(Schema.String)(anotherAddress); | |
| 289 | + const multiple = yield* executeApproved( | |
| 290 | + session, | |
| 291 | + ` | |
| 292 | + await tools[${JSON.stringify(path)}]({}); | |
| 293 | + return await tools[${JSON.stringify(anotherPath.replace(/^tools\./, ""))}]({}); | |
| 294 | + `, | |
| 295 | + ); | |
| 296 | + const multipleResult = yield* Schema.decodeUnknownEffect(completion)(multiple.raw); | |
| 297 | + expect(multipleResult.structuredContent.toolName).toBeUndefined(); | |
| 298 | + expect(multiple.raw).not.toHaveProperty("structuredContent.toolPaths"); | |
| 211 | 299 | |
| 212 | 300 | // 2a. A wrong TOOL name on a live connection: tool_not_found, and the |
| 213 | 301 | // suggestions name the connection's real tools so the agent can |
| @@ -257,7 +345,9 @@ scenario( · 43 unmodified lines | ||
| 257 | 345 | "the defect mask never surfaces for a missing connection", |
| 258 | 346 | ).not.toContain("Internal tool error"); |
| 259 | 347 | |
| 260 | − expect(upstream.requests(), "no misaddressed call ever reached the upstream").toBe(1); | |
| 348 | + expect(upstream.requests(), "only the five successful calls reached the upstream").toBe( | |
| 349 | + 5, | |
| 350 | + ); | |
| 261 | 351 | }), |
| 262 | 352 | // Selfhost shares one workspace identity — leaked resources fail other |
| 263 | 353 | // scenarios' zero-state assertions. |
| @@ -309,6 +309,35 @@ describe("formatExecuteResult output identity", () => { · 308 unmodified lines | ||
| 309 | 309 | expect(formatted.isError).toBe(false); |
| 310 | 310 | }); |
| 311 | 311 | |
| 312 | + it("returns the sole distinct connected tool name without exposing the call trace", () => { | |
| 313 | + const result = { | |
| 314 | + result: { issues: [] }, | |
| 315 | + logs: [], | |
| 316 | + toolPaths: ["linear.org.work.issues.list", "linear.org.work.issues.list"], | |
| 317 | + } as ExecuteResult & { readonly toolPaths: readonly string[] }; | |
| 318 | + | |
| 319 | + const formatted = formatExecuteResult(result); | |
| 320 | + | |
| 321 | + expect(formatted.structured).toEqual({ | |
| 322 | + status: "completed", | |
| 323 | + result: { issues: [] }, | |
| 324 | + toolName: "linear.org.work.issues.list", | |
| 325 | + logs: [], | |
| 326 | + }); | |
| 327 | + }); | |
| 328 | + | |
| 329 | + it("omits a tool name when distinct connected tools were used", () => { | |
| 330 | + const result = { | |
| 331 | + result: { issues: [], projects: [] }, | |
| 332 | + logs: [], | |
| 333 | + toolPaths: ["linear.org.work.issues.list", "linear.org.work.projects.list"], | |
| 334 | + } as ExecuteResult & { readonly toolPaths: readonly string[] }; | |
| 335 | + | |
| 336 | + const formatted = formatExecuteResult(result); | |
| 337 | + | |
| 338 | + expect(formatted.structured).not.toHaveProperty("toolName"); | |
| 339 | + }); | |
| 340 | + | |
| 312 | 341 | it("truncates a long preview with the exact suffix and untouched structured value", () => { |
| 313 | 342 | const value = { data: "é🎉".repeat(12_000) }; |
| 314 | 343 | const pretty = JSON.stringify(value, null, 2); |
| @@ -138,6 +138,11 @@ const truncate = (value: string, max: number): string => · 137 unmodified lines | ||
| 138 | 138 | ? `${value.slice(0, max)}\n... [truncated ${value.length - max} chars]` |
| 139 | 139 | : value; |
| 140 | 140 | |
| 141 | +const soleConnectedToolName = (toolPaths: readonly string[] | undefined): string | undefined => { | |
| 142 | + const names = [...new Set(toolPaths ?? [])]; | |
| 143 | + return names.length === 1 ? names[0] : undefined; | |
| 144 | +}; | |
| 145 | + | |
| 141 | 146 | export const formatExecuteResult = ( |
| 142 | 147 | result: ExecuteResult, |
| 143 | 148 | ): { |
| @@ -183,11 +188,13 @@ export const formatExecuteResult = ( · 39 unmodified lines | ||
| 183 | 188 | ? `(no return value; ${emittedNote})` |
| 184 | 189 | : "(no result)"; |
| 185 | 190 | const parts = [resultPart, ...(logText ? [`\nLogs:\n${logText}`] : [])]; |
| 191 | + const toolName = soleConnectedToolName(result.toolPaths); | |
| 186 | 192 | return { |
| 187 | 193 | text: parts.join("\n"), |
| 188 | 194 | structured: { |
| 189 | 195 | status: "completed", |
| 190 | 196 | result: result.result ?? null, |
| 197 | + ...(toolName ? { toolName } : {}), | |
| 191 | 198 | ...emittedField, |
| 192 | 199 | logs: result.logs ?? [], |
| 193 | 200 | }, |
| @@ -318,8 +325,9 @@ const makeFullInvoker = ( · 124 unmodified lines | ||
| 318 | 325 | executor: Executor, |
| 319 | 326 | invokeOptions: InvokeOptions, |
| 320 | 327 | toolDiscoveryProvider: ToolDiscoveryProvider, |
| 328 | + onConnectedToolCall?: (path: string) => void, | |
| 321 | 329 | ): SandboxToolInvoker => { |
| 322 | − const base = makeExecutorToolInvoker(executor, { invokeOptions }); | |
| 330 | + const base = makeExecutorToolInvoker(executor, { invokeOptions, onConnectedToolCall }); | |
| 323 | 331 | return { |
| 324 | 332 | invoke: ({ path, args }) => { |
| 325 | 333 | if (path === "search") { |
| @@ -694,13 +702,18 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu · 368 unmodified lines | ||
| 694 | 702 | return yield* Deferred.await(responseDeferred); |
| 695 | 703 | }); |
| 696 | 704 | |
| 705 | + const toolPaths: string[] = []; | |
| 697 | 706 | const invoker = makeFullInvoker( |
| 698 | 707 | executor, |
| 699 | 708 | { onElicitation: elicitationHandler }, |
| 700 | 709 | toolDiscoveryProvider, |
| 710 | + (path) => toolPaths.push(path), | |
| 701 | 711 | ); |
| 702 | 712 | fiber = yield* Effect.forkDetach( |
| 703 | − codeExecutor.execute(code, invoker).pipe(Effect.withSpan("executor.code.exec")), | |
| 713 | + codeExecutor.execute(code, invoker).pipe( | |
| 714 | + Effect.map((result) => (toolPaths.length === 0 ? result : { ...result, toolPaths })), | |
| 715 | + Effect.withSpan("executor.code.exec"), | |
| 716 | + ), | |
| 704 | 717 | ); |
| 705 | 718 | liveSandboxFibers.add(fiber); |
| 706 | 719 | |
| @@ -825,16 +838,19 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu · 118 unmodified lines | ||
| 825 | 838 | "mcp.execute.mode": "inline", |
| 826 | 839 | "mcp.execute.code_length": code.length, |
| 827 | 840 | }); |
| 841 | + const toolPaths: string[] = []; | |
| 828 | 842 | const invoker = makeFullInvoker( |
| 829 | 843 | executor, |
| 830 | 844 | { |
| 831 | 845 | onElicitation: options.onElicitation, |
| 832 | 846 | }, |
| 833 | 847 | toolDiscoveryProvider, |
| 848 | + (path) => toolPaths.push(path), | |
| 849 | + ); | |
| 850 | + const result = yield* codeExecutor.execute(code, invoker).pipe( | |
| 851 | + Effect.map((result) => (toolPaths.length === 0 ? result : { ...result, toolPaths })), | |
| 852 | + Effect.withSpan("executor.code.exec"), | |
| 834 | 853 | ); |
| 835 | − const result = yield* codeExecutor | |
| 836 | − .execute(code, invoker) | |
| 837 | − .pipe(Effect.withSpan("executor.code.exec")); | |
| 838 | 854 | yield* annotateExecuteOutcome(result); |
| 839 | 855 | return result; |
| 840 | 856 | }); |
| @@ -674,6 +674,25 @@ describe("tool discovery", () => { · 673 unmodified lines | ||
| 674 | 674 | }), |
| 675 | 675 | ); |
| 676 | 676 | |
| 677 | + it.effect("records only the connected tool resolved after discovery", () => | |
| 678 | + Effect.gen(function* () { | |
| 679 | + const executor = yield* makeSearchExecutor(); | |
| 680 | + const engine = createExecutionEngine({ executor, codeExecutor }); | |
| 681 | + | |
| 682 | + const execution = yield* engine.execute( | |
| 683 | + [ | |
| 684 | + 'const search = await tools.search({ query: "repository details", namespace: "github", limit: 1 });', | |
| 685 | + 'const result = await tools[search.items[0].path]({ owner: "executor", repo: "executor" });', | |
| 686 | + "return result;", | |
| 687 | + ].join("\n"), | |
| 688 | + { onElicitation: acceptAll }, | |
| 689 | + ); | |
| 690 | + | |
| 691 | + expect(execution.error).toBeUndefined(); | |
| 692 | + expect(execution.toolPaths).toEqual(["github.org.main.getRepositoryDetails"]); | |
| 693 | + }), | |
| 694 | + ); | |
| 695 | + | |
| 677 | 696 | it.effect("lets execution hosts provide custom tool discovery", () => |
| 678 | 697 | Effect.gen(function* () { |
| 679 | 698 | const executor = yield* makeSearchExecutor(); |
| @@ -306,7 +306,10 @@ const extractNamespace = (path: string): string => { · 305 unmodified lines | ||
| 306 | 306 | */ |
| 307 | 307 | export const makeExecutorToolInvoker = ( |
| 308 | 308 | executor: Executor, |
| 309 | − options: { readonly invokeOptions: InvokeOptions }, | |
| 309 | + options: { | |
| 310 | + readonly invokeOptions: InvokeOptions; | |
| 311 | + readonly onConnectedToolCall?: (path: string) => void; | |
| 312 | + }, | |
| 310 | 313 | ): SandboxToolInvoker => ({ |
| 311 | 314 | invoke: Effect.fn("mcp.tool.dispatch")(function* ({ path, args }) { |
| 312 | 315 | yield* Effect.annotateCurrentSpan({ |
| @@ -372,6 +375,12 @@ export const makeExecutorToolInvoker = ( · 59 unmodified lines | ||
| 372 | 375 | // outcome annotation the dispatch span reads as healthy even when the |
| 373 | 376 | // caller hit an upstream error or auth wall. |
| 374 | 377 | yield* annotateToolResultOutcome(result); |
| 378 | + const connectedToolPath = parseToolAddress(String(address)) | |
| 379 | + ? addressToPath(String(address)) | |
| 380 | + : undefined; | |
| 381 | + if (connectedToolPath && (!isToolResult(result) || result.ok)) { | |
| 382 | + options.onConnectedToolCall?.(connectedToolPath); | |
| 383 | + } | |
| 375 | 384 | if (isToolResult(result)) { |
| 376 | 385 | return result; |
| 377 | 386 | } |
| @@ -1039,12 +1039,15 @@ describe("MCP host server — native form-only elicitation", () => { · 1038 unmodified lines | ||
| 1039 | 1039 | // --------------------------------------------------------------------------- |
| 1040 | 1040 | |
| 1041 | 1041 | describe("MCP host server — client without elicitation (pause/resume)", () => { |
| 1042 | − it("completed execution returns result directly", async () => { | |
| 1042 | + it("completed execution returns result and connected-tool metadata directly", async () => { | |
| 1043 | 1043 | const engine = makeStubEngine({ |
| 1044 | 1044 | executeWithPause: () => |
| 1045 | 1045 | Effect.succeed({ |
| 1046 | 1046 | status: "completed", |
| 1047 | − result: { result: "done" }, | |
| 1047 | + result: { | |
| 1048 | + result: "done", | |
| 1049 | + toolPaths: ["linear.org.work.issues.list"], | |
| 1050 | + }, | |
| 1048 | 1051 | }), |
| 1049 | 1052 | }); |
| 1050 | 1053 | |
| @@ -1054,6 +1057,10 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 3 unmodified lines | ||
| 1054 | 1057 | arguments: { code: "ok" }, |
| 1055 | 1058 | }); |
| 1056 | 1059 | expect(result.content).toEqual([{ type: "text", text: "done" }]); |
| 1060 | + expect(result.structuredContent).toMatchObject({ | |
| 1061 | + status: "completed", | |
| 1062 | + toolName: "linear.org.work.issues.list", | |
| 1063 | + }); | |
| 1057 | 1064 | expect(result.isError).toBeFalsy(); |
| 1058 | 1065 | }); |
| 1059 | 1066 | }); |
| @@ -46,6 +46,8 @@ export type ExecuteResult = { · 45 unmodified lines | ||
| 46 | 46 | /** Enumerable failure class for telemetry; never carries message content. */ |
| 47 | 47 | errorKind?: ExecuteErrorKind; |
| 48 | 48 | logs?: string[]; |
| 49 | + /** Successful connected-tool paths observed during this execution. */ | |
| 50 | + toolPaths?: readonly string[]; | |
| 49 | 51 | }; |
| 50 | 52 | |
| 51 | 53 | /** |