Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/completed-mcp-tool-name.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 14 additions & 4 deletions e2e/scenarios/tool-call-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand All @@ -199,14 +199,24 @@ 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)})`,
).toBe(true);
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
Expand Down
29 changes: 29 additions & 0 deletions packages/core/execution/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 21 additions & 5 deletions packages/core/execution/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
): {
Expand Down Expand Up @@ -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 ?? [],
},
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -694,13 +702,18 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu
return yield* Deferred.await(responseDeferred);
});

const toolPaths: string[] = [];
const invoker = makeFullInvoker(
executor,
{ onElicitation: elicitationHandler },
toolDiscoveryProvider,
(path) => 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);

Expand Down Expand Up @@ -825,16 +838,19 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu
"mcp.execute.mode": "inline",
"mcp.execute.code_length": code.length,
});
const toolPaths: string[] = [];
const invoker = makeFullInvoker(
executor,
{
onElicitation: options.onElicitation,
},
toolDiscoveryProvider,
(path) => 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;
});
Expand Down
19 changes: 19 additions & 0 deletions packages/core/execution/src/tool-invoker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion packages/core/execution/src/tool-invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 9 additions & 2 deletions packages/hosts/mcp/src/tool-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}),
});

Expand All @@ -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();
});
});
Expand Down
2 changes: 2 additions & 0 deletions packages/kernel/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};

/**
Expand Down
Loading