diff --git a/.changeset/completed-mcp-tool-name.md b/.changeset/completed-mcp-tool-name.md new file mode 100644 index 0000000000..1f97e79c55 --- /dev/null +++ b/.changeset/completed-mcp-tool-name.md @@ -0,0 +1,5 @@ +--- +"@executor-js/execution": patch +--- + +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. diff --git a/e2e/scenarios/tool-call-contract.test.ts b/e2e/scenarios/tool-call-contract.test.ts index 7cb5394e3b..d36b7e2a09 100644 --- a/e2e/scenarios/tool-call-contract.test.ts +++ b/e2e/scenarios/tool-call-contract.test.ts @@ -114,14 +114,14 @@ const executeApproved = (session: McpSession, code: string) => guard += 1; } expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); - return result.text; + return result; }); /** Invoke a dynamic tool by full address and parse the envelope it returns. */ const invokeEnvelope = (session: McpSession, address: string, args: unknown = {}) => Effect.map( executeApproved(session, invokeByAddressCode(address, args)), - (text) => JSON.parse(text) as ToolEnvelope, + (result) => JSON.parse(result.text) as ToolEnvelope, ); // --------------------------------------------------------------------------- @@ -184,7 +184,7 @@ scenario( }, }); const created = JSON.parse( - yield* executeApproved(session, createConnectionCode(slug)), + (yield* executeApproved(session, createConnectionCode(slug))).text, ) as ToolEnvelope; expect(created.ok, `the no-auth connection was created: ${JSON.stringify(created)}`).toBe( true, @@ -199,7 +199,8 @@ scenario( const path = address!.replace(/^tools\./, ""); // 1. A well-addressed call executes and carries the upstream's payload. - const success = yield* invokeEnvelope(session, address!); + const successfulCall = yield* executeApproved(session, invokeByAddressCode(address!, {})); + const success = JSON.parse(successfulCall.text) as ToolEnvelope; expect( success.ok, `the call succeeded (got: ${JSON.stringify(success.error ?? {}).slice(0, 400)})`, @@ -207,6 +208,15 @@ scenario( expect(JSON.stringify(success.data), "the upstream's payload comes back").toContain( "anvil", ); + const structured = (successfulCall.raw as { readonly structuredContent?: unknown }) + .structuredContent; + expect(structured, "the completed MCP result includes structured content").toMatchObject({ + status: "completed", + toolName: path, + }); + expect(structured, "the internal tool-call trace is not exposed").not.toHaveProperty( + "toolPaths", + ); expect(upstream.requests(), "the upstream served exactly one call").toBe(1); // 2a. A wrong TOOL name on a live connection: tool_not_found, and the diff --git a/packages/core/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index 06b9e20888..e1ca25eef6 100644 --- a/packages/core/execution/src/engine.test.ts +++ b/packages/core/execution/src/engine.test.ts @@ -309,6 +309,35 @@ describe("formatExecuteResult output identity", () => { expect(formatted.isError).toBe(false); }); + it("returns the sole distinct connected tool name without exposing the call trace", () => { + const result = { + result: { issues: [] }, + logs: [], + toolPaths: ["linear.org.work.issues.list", "linear.org.work.issues.list"], + } as ExecuteResult & { readonly toolPaths: readonly string[] }; + + const formatted = formatExecuteResult(result); + + expect(formatted.structured).toEqual({ + status: "completed", + result: { issues: [] }, + toolName: "linear.org.work.issues.list", + logs: [], + }); + }); + + it("omits a tool name when distinct connected tools were used", () => { + const result = { + result: { issues: [], projects: [] }, + logs: [], + toolPaths: ["linear.org.work.issues.list", "linear.org.work.projects.list"], + } as ExecuteResult & { readonly toolPaths: readonly string[] }; + + const formatted = formatExecuteResult(result); + + expect(formatted.structured).not.toHaveProperty("toolName"); + }); + it("truncates a long preview with the exact suffix and untouched structured value", () => { const value = { data: "é🎉".repeat(12_000) }; const pretty = JSON.stringify(value, null, 2); diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 8bb9bda071..7e4e06340b 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -138,6 +138,11 @@ const truncate = (value: string, max: number): string => ? `${value.slice(0, max)}\n... [truncated ${value.length - max} chars]` : value; +const soleConnectedToolName = (toolPaths: readonly string[] | undefined): string | undefined => { + const names = [...new Set(toolPaths ?? [])]; + return names.length === 1 ? names[0] : undefined; +}; + export const formatExecuteResult = ( result: ExecuteResult, ): { @@ -183,11 +188,13 @@ export const formatExecuteResult = ( ? `(no return value; ${emittedNote})` : "(no result)"; const parts = [resultPart, ...(logText ? [`\nLogs:\n${logText}`] : [])]; + const toolName = soleConnectedToolName(result.toolPaths); return { text: parts.join("\n"), structured: { status: "completed", result: result.result ?? null, + ...(toolName ? { toolName } : {}), ...emittedField, logs: result.logs ?? [], }, @@ -318,8 +325,9 @@ const makeFullInvoker = ( executor: Executor, invokeOptions: InvokeOptions, toolDiscoveryProvider: ToolDiscoveryProvider, + onConnectedToolCall?: (path: string) => void, ): SandboxToolInvoker => { - const base = makeExecutorToolInvoker(executor, { invokeOptions }); + const base = makeExecutorToolInvoker(executor, { invokeOptions, onConnectedToolCall }); return { invoke: ({ path, args }) => { if (path === "search") { @@ -694,13 +702,18 @@ export const createExecutionEngine = toolPaths.push(path), ); fiber = yield* Effect.forkDetach( - codeExecutor.execute(code, invoker).pipe(Effect.withSpan("executor.code.exec")), + codeExecutor.execute(code, invoker).pipe( + Effect.map((result) => (toolPaths.length === 0 ? result : { ...result, toolPaths })), + Effect.withSpan("executor.code.exec"), + ), ); liveSandboxFibers.add(fiber); @@ -825,16 +838,19 @@ export const createExecutionEngine = toolPaths.push(path), + ); + const result = yield* codeExecutor.execute(code, invoker).pipe( + Effect.map((result) => (toolPaths.length === 0 ? result : { ...result, toolPaths })), + Effect.withSpan("executor.code.exec"), ); - const result = yield* codeExecutor - .execute(code, invoker) - .pipe(Effect.withSpan("executor.code.exec")); yield* annotateExecuteOutcome(result); return result; }); diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 747bd23105..97beb69ed4 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -674,6 +674,25 @@ describe("tool discovery", () => { }), ); + it.effect("records only the connected tool resolved after discovery", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const engine = createExecutionEngine({ executor, codeExecutor }); + + const execution = yield* engine.execute( + [ + 'const search = await tools.search({ query: "repository details", namespace: "github", limit: 1 });', + 'const result = await tools[search.items[0].path]({ owner: "executor", repo: "executor" });', + "return result;", + ].join("\n"), + { onElicitation: acceptAll }, + ); + + expect(execution.error).toBeUndefined(); + expect(execution.toolPaths).toEqual(["github.org.main.getRepositoryDetails"]); + }), + ); + it.effect("lets execution hosts provide custom tool discovery", () => 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..6a09de027b 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -306,7 +306,10 @@ const extractNamespace = (path: string): string => { */ export const makeExecutorToolInvoker = ( executor: Executor, - options: { readonly invokeOptions: InvokeOptions }, + options: { + readonly invokeOptions: InvokeOptions; + readonly onConnectedToolCall?: (path: string) => void; + }, ): SandboxToolInvoker => ({ invoke: Effect.fn("mcp.tool.dispatch")(function* ({ path, args }) { yield* Effect.annotateCurrentSpan({ @@ -372,6 +375,12 @@ export const makeExecutorToolInvoker = ( // outcome annotation the dispatch span reads as healthy even when the // caller hit an upstream error or auth wall. yield* annotateToolResultOutcome(result); + const connectedToolPath = parseToolAddress(String(address)) + ? addressToPath(String(address)) + : undefined; + if (connectedToolPath && (!isToolResult(result) || result.ok)) { + options.onConnectedToolCall?.(connectedToolPath); + } if (isToolResult(result)) { return result; } diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 1b7bb8aa45..c83fdabb0f 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -1039,12 +1039,15 @@ describe("MCP host server — native form-only elicitation", () => { // --------------------------------------------------------------------------- describe("MCP host server — client without elicitation (pause/resume)", () => { - it("completed execution returns result directly", async () => { + it("completed execution returns result and connected-tool metadata directly", async () => { const engine = makeStubEngine({ executeWithPause: () => Effect.succeed({ status: "completed", - result: { result: "done" }, + result: { + result: "done", + toolPaths: ["linear.org.work.issues.list"], + }, }), }); @@ -1054,6 +1057,10 @@ describe("MCP host server — client without elicitation (pause/resume)", () => arguments: { code: "ok" }, }); expect(result.content).toEqual([{ type: "text", text: "done" }]); + expect(result.structuredContent).toMatchObject({ + status: "completed", + toolName: "linear.org.work.issues.list", + }); expect(result.isError).toBeFalsy(); }); }); diff --git a/packages/kernel/core/src/types.ts b/packages/kernel/core/src/types.ts index 1480b27b9a..d6dee10ad4 100644 --- a/packages/kernel/core/src/types.ts +++ b/packages/kernel/core/src/types.ts @@ -46,6 +46,8 @@ export type ExecuteResult = { /** Enumerable failure class for telemetry; never carries message content. */ errorKind?: ExecuteErrorKind; logs?: string[]; + /** Successful connected-tool paths observed during this execution. */ + toolPaths?: readonly string[]; }; /**