diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 473177f706849..980e4df8b75fe 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -18,6 +18,7 @@ import { mcpAppAllowAttribute, mcpAppFileName, mcpAppReferencesEqual, + mcpAppToolCall, type McpAppReference, } from "@t3tools/shared/mcpApp"; import * as Predicate from "effect/Predicate"; @@ -95,10 +96,9 @@ function storedToolCall( item: { readonly type: string; readonly input?: unknown; readonly output?: unknown } | null, ) { if (item?.type !== "dynamic_tool") return undefined; - const output = item.output as { readonly result?: unknown } | undefined; - return { - arguments: item.input, - result: output?.result as McpAppCallToolResult | undefined, + return mcpAppToolCall({ input: item.input, output: item.output }) as { + readonly arguments: unknown; + readonly result: McpAppCallToolResult | undefined; }; } diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index 1fde091c06423..1651095964abd 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -1,4 +1,5 @@ import { + type OrchestrationV2ServerCommand, ProviderSessionId, ProviderThreadId, ThreadId, @@ -7,6 +8,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, describe, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -68,10 +70,13 @@ function makeLayer(input: { readonly item: OrchestrationV2TurnItem | null; readonly mcpApps?: ProviderAdapter.ProviderAdapterV2McpApps; readonly live?: boolean; + readonly recordFails?: boolean; + readonly dispatched?: Array; }) { return McpAppRequests.layer.pipe( Layer.provide( Layer.mergeAll( + NodeCrypto.layer, Layer.mock(McpAppModelContext.McpAppModelContext)({ set: (entry) => Effect.sync(() => { @@ -84,6 +89,13 @@ function makeLayer(input: { getTurnItem: () => Effect.succeed(input.item), }), Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: (command) => + input.recordFails === true + ? Effect.fail(new Orchestrator.OrchestratorProjectionError({ threadId })) + : Effect.sync(() => { + input.dispatched?.push(command); + return {} as never; + }), getThreadRecords: (id: ThreadId) => Effect.succeed({ thread: { @@ -139,11 +151,15 @@ describe("McpAppRequests", () => { Effect.succeed([ { name: "refresh", annotations: { readOnlyHint: true } }, { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, + { name: "get_weather" }, + { name: "set_alert" }, ]), callTool: (input) => Effect.sync(() => { calls.push({ server: input.server, tool: input.tool }); - return { content: [{ type: "text", text: "ok" }] }; + return input.arguments.fail === true + ? { content: [{ type: "text", text: "no" }], isError: true } + : { content: [{ type: "text", text: "ok" }] }; }), readResource: () => Effect.succeed({ contents: [] }), }; @@ -167,6 +183,70 @@ describe("McpAppRequests", () => { }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), ); + it.effect("keeps the app's writes through its own tool on the tool call", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const call = (name: string, args: Record) => + requests.callTool({ threadId, itemId, name, arguments: args }); + yield* call("get_weather", { city: "Paris", _ui_submitted: true }); + // Other tools and failed writes leave the call as it was. + yield* call("refresh", {}); + yield* call("set_alert", {}); + yield* call("get_weather", { fail: true }); + assert.deepEqual( + dispatched.map((command) => + command.type === "mcp_app.call.record" + ? { + threadId: command.threadId, + turnItemId: command.turnItemId, + arguments: command.arguments, + result: command.result, + } + : command.type, + ), + [ + { + threadId, + turnItemId: itemId, + arguments: { city: "Paris", _ui_submitted: true }, + result: { content: [{ type: "text", text: "ok" }] }, + }, + ], + ); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, dispatched }))); + }); + + it.effect("doesn't keep reads through the app's own tool", () => { + const dispatched: Array = []; + const readOnlyApp: ProviderAdapter.ProviderAdapterV2McpApps = { + ...mcpApps, + listTools: () => + Effect.succeed([{ name: "get_weather", annotations: { readOnlyHint: true } }]), + }; + return Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + yield* requests.callTool({ threadId, itemId, name: "get_weather", arguments: {} }); + assert.deepEqual(dispatched, []); + }).pipe( + Effect.provide( + makeLayer({ item: appItem({ t3McpApp: app }), mcpApps: readOnlyApp, dispatched }), + ), + ); + }); + + it.effect("returns a write's result even when it can't be recorded", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + assert.deepEqual( + yield* requests.callTool({ threadId, itemId, name: "get_weather", arguments: {} }), + { content: [{ type: "text", text: "ok" }] }, + ); + }).pipe( + Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, recordFails: true })), + ), + ); + it.effect("refuses tools hidden from apps or missing from the server", () => Effect.gen(function* () { const requests = yield* McpAppRequests.McpAppRequests; diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index 1387cc6b20c7a..2713842ab1ff6 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -1,4 +1,5 @@ import { + CommandId, McpAppRequestError, type McpAppCallToolInput, type McpAppCallToolResult, @@ -14,6 +15,7 @@ import { import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -61,6 +63,7 @@ const make = Effect.gen(function* () { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const modelContext = yield* McpAppModelContext.McpAppModelContext; + const crypto = yield* Crypto.Crypto; const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); @@ -135,7 +138,7 @@ const make = Effect.gen(function* () { if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { return yield* fail(input.threadId, "tool-not-callable"); } - return yield* resolved.mcpApps + const result = yield* resolved.mcpApps .callTool({ providerThread: resolved.providerThread, server: resolved.app.server, @@ -143,6 +146,32 @@ const make = Effect.gen(function* () { arguments: input.arguments, }) .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + // A write through the app's own tool, such as a deferred form's submit, is + // kept so the app opens on its outcome. Reads are left out: an app can + // repeat those unprompted. + if (input.name === resolved.app.tool && !readOnlyHint(tool) && result.isError !== true) { + const uuid = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + yield* threadManagement + .dispatch({ + type: "mcp_app.call.record", + commandId: CommandId.make(`server:mcp-app-call:${input.itemId}:${uuid}`), + threadId: input.threadId, + turnItemId: input.itemId, + arguments: input.arguments, + result, + }) + .pipe( + // The write already happened; failing the call would offer it again. + Effect.catch((error) => + Effect.logWarning("Could not record an MCP App's tool call", { + threadId: input.threadId, + itemId: input.itemId, + error: error._tag, + }), + ), + ); + } + return result; }); const readResource = Effect.fn("McpAppRequests.readResource")(function* ( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c20994423f3e9..efcf0cc1d1bdf 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5142,6 +5142,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { content: [{ type: "text", text: "An interactive form is being shown to the user." }], structuredContent: { status: "awaiting_user_submission" }, }; + // Codex reports a tool's `isError` result, which a write that defers to its + // form returns, as a failed call. const mcpAppDeferredTranscript = makeCodexReplayTranscript({ scenario: MCP_APP_SCENARIO, entries: mcpAppTranscript.entries.map((entry) => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 51a686e270389..ff32866d2939f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3743,6 +3743,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( // Only reads and storage are cancellable; remove partial writes. Once stored, attach the app or delete it if the item settled. Effect.uninterruptibleMask((restore) => Effect.gen(function* () { + // Codex reports an `isError` result as failed. Apps still render + // for it: a write tool that defers to its form returns one. const reference = item.result != null && item.error == null ? yield* restore( diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index b210d3c9840ae..6aa0295a4a846 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -3,6 +3,7 @@ import { type OrchestrationV2Run, OrchestrationV2DomainEvent, type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, OrchestrationV2StoredEvent, ProviderThreadId, RunAttemptId, @@ -17,15 +18,17 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as PubSub from "effect/PubSub"; import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; +import { MCP_APP_CALL_OUTPUT_KEY, MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; -import { projectDomainEventForWire } from "./WireProjection.ts"; +import { projectDomainEventForWire, toolOutputFitsDetail } from "./WireProjection.ts"; import { makeRunOutcomeRecorder, type RunOutcome } from "./RunMetrics.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; @@ -329,7 +332,35 @@ const layerBase: Layer.Layer< }); }); - const normalizeEvents = (events: ReadonlyArray) => { + // Merge against the latest item inside the write transaction; either writer may hold a stale copy. + const mergeMcpAppCall = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (item: OrchestrationV2TurnItem, recorded: boolean) { + if (item.type !== "dynamic_tool") return item; + const output = Predicate.isObject(item.output) ? item.output : undefined; + if (output === undefined || !(MCP_APP_OUTPUT_KEY in output)) return item; + const stored = yield* projectionStore.getTurnItem({ + threadId: item.threadId, + itemId: item.id, + }); + if (stored?.type !== "dynamic_tool" || !Predicate.isObject(stored.output)) return item; + const call = (recorded ? output : stored.output)[MCP_APP_CALL_OUTPUT_KEY]; + if (call === undefined) return item; + const base = recorded ? { ...stored, updatedAt: item.updatedAt } : item; + const merged = { + ...(Predicate.isObject(base.output) ? base.output : {}), + [MCP_APP_CALL_OUTPUT_KEY]: call, + }; + // Past the detail limit clients would read both results as truncated + // text, so the provider's output wins over the call. + if (!toolOutputFitsDetail(merged)) return recorded ? stored : item; + return { ...base, output: merged }; + }); + + const normalizeEvents = ( + events: ReadonlyArray, + recorded = false, + ) => { const runOrdinals = new Map( events.flatMap((event) => event.type === "run.created" || event.type === "run.updated" @@ -346,7 +377,10 @@ const layerBase: Layer.Layer< event.payload, event.payload.runId === null ? undefined : runOrdinals.get(event.payload.runId), ) - .pipe(Effect.map((payload) => ({ ...event, payload }))) + .pipe( + Effect.flatMap((item) => mergeMcpAppCall(item, recorded)), + Effect.map((payload) => ({ ...event, payload })), + ) : Effect.succeed(event), { concurrency: 1 }, ); @@ -656,6 +690,7 @@ const layerBase: Layer.Layer< runId === undefined ? input.events : yield* updateDelegatedCompletionOnly(input.events, (id) => id === runId), + input.commandType === "mcp_app.call.record", ); const storedEvents = yield* eventStore.append({ commandId: input.commandId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 61515a053ac36..c5ee9d0392103 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1488,6 +1488,103 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r }).pipe(Effect.provide(layerTest)), ); +it.effect("keeps an MCP App's recorded call beside the tool's own result", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:mcp-app-call"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-mcp-app-call"), + threadId, + projectId: ProjectId.make("project:mcp-app-call"), + title: "MCP App call", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const deferred = { content: [{ type: "text", text: "Form shown" }], isError: true }; + const toolItem = (id: TurnItemId, output: unknown) => ({ + id, + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "failed" as const, + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "dynamic_tool" as const, + toolName: "github.issue_write", + input: { title: "Draft" }, + output, + }); + const appItemId = TurnItemId.make("turn-item:mcp-app-call:app"); + const plainItemId = TurnItemId.make("turn-item:mcp-app-call:plain"); + for (const item of [ + toolItem(appItemId, { + t3McpApp: { + attachmentId: "thread-mcp-app-call-html", + server: "github", + tool: "issue_write", + resourceUri: "ui://github/issue-write", + }, + result: deferred, + }), + toolItem(plainItemId, { result: deferred }), + ]) { + yield* projections.apply({ + id: EventId.make(`mcp-app-call:${item.id}`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: item, + }); + } + const submitted = { + arguments: { title: "Shipped", _ui_submitted: true }, + result: { content: [{ type: "text", text: "Created issue #1" }] }, + }; + const record = (turnItemId: TurnItemId, call = submitted) => + orchestrator.dispatch({ + type: "mcp_app.call.record", + commandId: CommandId.make(`record:${turnItemId}:${call.arguments.title}`), + threadId, + turnItemId, + ...call, + }); + + // Clients refetch an item's detail when its update time moves. + yield* TestClock.adjust("1 second"); + yield* record(appItemId); + const recorded = yield* projections.getTurnItem({ threadId, itemId: appItemId }); + assert(recorded?.type === "dynamic_tool"); + assert.deepInclude(recorded.output, { result: deferred, t3McpAppCall: submitted }); + assert.isTrue(DateTime.isGreaterThan(recorded.updatedAt, now)); + // Only a call that shows an app has an app to keep a call for. + assert.isTrue(Result.isFailure(yield* record(plainItemId).pipe(Effect.result))); + // Clients would read an output this large as truncated text, losing both results. + const oversized = { + arguments: { title: "Oversized", _ui_submitted: true }, + result: { content: [{ type: "text", text: "x".repeat(300 * 1024) }] }, + }; + assert.isTrue(Result.isFailure(yield* record(appItemId, oversized).pipe(Effect.result))); + const kept = yield* projections.getTurnItem({ threadId, itemId: appItemId }); + assert(kept?.type === "dynamic_tool"); + assert.deepInclude(kept.output, { t3McpAppCall: submitted }); + }).pipe(Effect.provide(layerTest)), +); + it.effect("dispatch records the command outcome and its duration", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c464a8743f3c2..e0b152068ec5c 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -71,7 +71,9 @@ import { TurnItemId, TrimmedNonEmptyString, } from "@t3tools/contracts"; +import { MCP_APP_CALL_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; import { derivePendingBackgroundWork, @@ -83,6 +85,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -167,6 +170,7 @@ import { ThreadForkServiceV2, } from "./ThreadForkService.ts"; import { planThreadDeletion } from "./ThreadDeletion.ts"; +import { toolOutputFitsDetail } from "./WireProjection.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ServerSettings from "../serverSettings.ts"; @@ -547,6 +551,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.created.record": return command.parentThreadId; case "secret_request.record": + case "mcp_app.call.record": case "link_request.record": return command.threadId; case "thread.fork": @@ -8307,6 +8312,53 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + const dispatchMcpAppCallRecord = Effect.fn("orchestrationV2.dispatch.mcpAppCallRecord")( + function* ( + command: Extract, + events: Ref.Ref>, + ) { + const item = yield* projectionStore + .getTurnItem({ threadId: command.threadId, itemId: command.turnItemId }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (item?.type !== "dynamic_tool" || mcpAppFromToolItem(item) === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Turn item ${command.turnItemId} does not show an MCP App.`, + }); + } + const output = { + ...(Predicate.isObject(item.output) ? item.output : {}), + [MCP_APP_CALL_OUTPUT_KEY]: { arguments: command.arguments, result: command.result }, + }; + // Clients read an oversized output as truncated text, which would leave + // the app with neither result. + if (!toolOutputFitsDetail(output)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `The MCP App call on turn item ${command.turnItemId} is too large to keep.`, + }); + } + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + ...(item.runId === null ? {} : { runId: item.runId }), + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + occurredAt: now, + payload: { ...item, output, updatedAt: now }, + }); + }, + ); + /** * Records or updates the card for a link an agent asked the user to make. * The pairing code goes straight to the link and never through orchestration. @@ -12807,6 +12859,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "secret_request.record": yield* dispatchSecretRequestRecord(command, events); break; + case "mcp_app.call.record": + yield* dispatchMcpAppCallRecord(command, events); + break; case "link_request.record": yield* dispatchLinkRequestRecord(command, events, effects); break; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 7f1006f1c5ec5..10a34b9b1e10a 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2,8 +2,10 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CheckpointId, + CommandId, CheckpointRef, CheckpointScopeId, + EventId, MessageId, GitCommandError, type ModelSelection, @@ -1648,6 +1650,156 @@ layer("ProviderEventIngestorV2", (it) => { }), ); + it.effect("merges an MCP App's recorded call with the provider's latest tool call", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + }); + const providerOutput = { + t3McpApp: { + attachmentId: "thread-app-html", + server: "github", + tool: "issue_write", + resourceUri: "ui://github/issue-write", + }, + result: { content: [{ type: "text", text: "Form shown" }], isError: true }, + }; + // The recorded result's image must survive the provider's image stripping. + const call = { + arguments: { title: "Shipped", _ui_submitted: true }, + result: { + content: [ + { type: "text", text: "Created issue #1" }, + { type: "image", mimeType: "image/png", data: Buffer.alloc(300, 7).toString("base64") }, + ], + }, + }; + const turnItem: OrchestrationV2TurnItem = { + id: TurnItemId.make("turn-item:mcp-app"), + threadId: threadEvent.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "github.issue_write", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "dynamic_tool", + toolName: "github.issue_write", + input: { title: "Draft" }, + output: providerOutput, + }; + const ingest = (item = turnItem) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + event: { type: "turn_item.updated", driver: CODEX_DRIVER, turnItem: item }, + }); + + yield* eventSink.write({ events: [threadEvent] }); + yield* ingest(); + yield* projectionStore.apply({ + id: EventId.make("event:mcp-app-call-recorded"), + type: "turn-item.updated", + threadId: threadEvent.threadId, + occurredAt: now, + payload: { ...turnItem, output: { ...providerOutput, t3McpAppCall: call } }, + }); + yield* ingest(); + + const projected = yield* projectionStore.getTurnItem({ + threadId: threadEvent.threadId, + itemId: turnItem.id, + }); + assert(projected?.type === "dynamic_tool"); + assert.deepEqual(projected.output, { ...providerOutput, t3McpAppCall: call }); + // The resent item's event carries the call too, so a replay keeps it. + const stored = yield* eventStore + .read({ threadId: threadEvent.threadId, eventType: "turn-item.updated" }) + .pipe(Stream.runCollect); + const last = Array.from(stored).at(-1)?.event; + assert(last?.type === "turn-item.updated" && last.payload.type === "dynamic_tool"); + assert.deepEqual(last.payload.output, { ...providerOutput, t3McpAppCall: call }); + + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:mcp-app-status-update"), + type: "turn-item.updated", + threadId: threadEvent.threadId, + occurredAt: now, + payload: { ...projected, status: "completed", title: "Provider completed" }, + }, + ], + }); + const updated = yield* projectionStore.getTurnItem({ + threadId: threadEvent.threadId, + itemId: turnItem.id, + }); + assert.equal(updated?.status, "completed"); + assert.equal(updated?.title, "Provider completed"); + + // An output that clients read whole on its own, but not with the call + // beside it, wins over the call. + const largeOutput = { + ...providerOutput, + result: { content: [{ type: "text", text: "x".repeat(256 * 1024 - 500) }] }, + }; + yield* ingest({ ...turnItem, output: largeOutput }); + const resent = yield* projectionStore.getTurnItem({ + threadId: threadEvent.threadId, + itemId: turnItem.id, + }); + assert(resent?.type === "dynamic_tool"); + assert.deepEqual(resent.output, largeOutput); + + // A call recorded from a copy read before the provider's latest update + // lands on that update instead of restoring the copy. + const finalOutput = { + ...providerOutput, + result: { content: [{ type: "text", text: "Provider final" }] }, + }; + yield* ingest({ ...turnItem, status: "completed", output: finalOutput }); + yield* eventSink.commitCommand({ + commandId: CommandId.make("command:mcp-app-call"), + commandType: "mcp_app.call.record", + threadId: threadEvent.threadId, + acceptedAt: now, + effects: [], + events: [ + { + id: EventId.make("event:mcp-app-call-from-stale-copy"), + type: "turn-item.updated", + threadId: threadEvent.threadId, + occurredAt: now, + payload: { ...turnItem, output: { ...providerOutput, t3McpAppCall: call } }, + }, + ], + }); + const latest = yield* projectionStore.getTurnItem({ + threadId: threadEvent.threadId, + itemId: turnItem.id, + }); + assert(latest?.type === "dynamic_tool"); + assert.equal(latest.status, "completed"); + assert.deepEqual(latest.output, { ...finalOutput, t3McpAppCall: call }); + }), + ); + it.effect("stores a bounded copy of a large file-change diff in both SQLite tables", () => Effect.gen(function* () { const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 2181b7feec5fa..4399551921578 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -217,6 +217,11 @@ function boundDynamicValue(value: unknown): unknown { : truncateDetail(json, MAX_ON_DEMAND_BYTES); } +export function toolOutputFitsDetail(output: unknown): boolean { + const omitted = omitToolOutputImageData(output); + return boundDynamicValue(omitted) === omitted; +} + /** Preserve ordered image markers in truncated output so clients can load images by index. */ function boundToolOutput(output: unknown): unknown { const omitted = omitToolOutputImageData(output); diff --git a/apps/web/src/components/chat/McpAppFrame.tsx b/apps/web/src/components/chat/McpAppFrame.tsx index d65758f08d45d..d8594dd8a08f0 100644 --- a/apps/web/src/components/chat/McpAppFrame.tsx +++ b/apps/web/src/components/chat/McpAppFrame.tsx @@ -16,6 +16,8 @@ import { MCP_APP_MAX_HEIGHT, mcpAppAllowAttribute, mcpAppFileName, + mcpAppReferencesEqual, + mcpAppToolCall, type McpAppReference, } from "@t3tools/shared/mcpApp"; import { Minimize2Icon } from "lucide-react"; @@ -86,7 +88,8 @@ export function McpAppFrame(props: { readonly awaitingUser?: boolean; readonly onFullscreenChange?: (fullscreen: boolean) => void; }) { - const { app } = props; + const [app, setApp] = useState(props.app); + if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app); const theme = useHtmlRenderTheme(); const frameRef = useRef(null); const boxRef = useRef(null); @@ -136,12 +139,10 @@ export function McpAppFrame(props: { // Keep tool-call identity stable for the host's delivery effect. const toolCall = storedItem?.type === "dynamic_tool" - ? { - arguments: storedItem.input, - result: (storedItem.output as { readonly result?: unknown } | undefined)?.result as - | McpAppCallToolResult - | undefined, - } + ? (mcpAppToolCall(storedItem) as { + readonly arguments: unknown; + readonly result: McpAppCallToolResult | undefined; + }) : undefined; const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 026e74831233d..c2a5383dec40c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -43,6 +43,7 @@ import { } from "./baseSchemas.ts"; import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; +import { McpAppCallToolResult } from "./mcpApps.ts"; import { ThreadGuestShare } from "./guestShare.ts"; import { OrchestrationSearchThreadsInput, @@ -3471,6 +3472,18 @@ const OrchestrationV2InternalCommand = Schema.Union([ delivery: Schema.optional(OrchestrationV2SecretRequestDelivery), secretStatus: OrchestrationV2SecretRequestStatus, }), + /** + * An MCP App's write through its own tool, kept on the tool call that shows + * the app. Internal so no client can rewrite what a tool returned. + */ + Schema.Struct({ + type: Schema.Literal("mcp_app.call.record"), + commandId: CommandId, + threadId: ThreadId, + turnItemId: TurnItemId, + arguments: Schema.Record(Schema.String, Schema.Unknown), + result: McpAppCallToolResult, + }), /** * Records or updates a link an agent asked the user to make. Internal so no * client can mark a request linked without the link being made. diff --git a/packages/shared/src/mcpApp.test.ts b/packages/shared/src/mcpApp.test.ts index 7e2ee8cbd20e1..32000571eb96f 100644 --- a/packages/shared/src/mcpApp.test.ts +++ b/packages/shared/src/mcpApp.test.ts @@ -6,6 +6,7 @@ import { injectMcpAppThemeBootstrap, injectMcpAppCookieBootstrap, mcpAppContentSecurityPolicy, + mcpAppToolCall, mcpAppToolCallableByApp, readMcpAppCsp, readMcpAppReference, @@ -257,3 +258,24 @@ describe("MCP App cookie bootstrap", () => { expect(() => Reflect.get(document, "cookie")).toThrow(failure); }); }); + +describe("mcpAppToolCall", () => { + const deferred = { content: [], isError: true }; + const submitted = { arguments: { title: "Shipped" }, result: { content: [] } }; + + it("opens the app on its recorded write, or else on the call that showed it", () => { + expect( + mcpAppToolCall({ + input: { title: "Draft" }, + output: { result: deferred, t3McpAppCall: submitted }, + }), + ).toEqual(submitted); + expect(mcpAppToolCall({ input: { title: "Draft" }, output: { result: deferred } })).toEqual({ + arguments: { title: "Draft" }, + result: deferred, + }); + expect( + mcpAppToolCall({ input: {}, output: { result: deferred, t3McpAppCall: { arguments: {} } } }), + ).toEqual({ arguments: {}, result: deferred }); + }); +}); diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts index d83368eef4802..862fb9e5b6c41 100644 --- a/packages/shared/src/mcpApp.ts +++ b/packages/shared/src/mcpApp.ts @@ -5,6 +5,7 @@ export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; export const MCP_APP_RESOURCE_SCHEME = "ui://"; export const MCP_APP_OUTPUT_KEY = "t3McpApp"; +export const MCP_APP_CALL_OUTPUT_KEY = "t3McpAppCall"; const MCP_APP_MIN_HEIGHT = 80; export const MCP_APP_DEFAULT_HEIGHT = 320; @@ -122,6 +123,22 @@ export function readMcpAppReference(value: unknown): McpAppReference | undefined }; } +/** + * The call an app is handed when it opens: its last write through its own + * tool, or else the tool call that showed it. A form that deferred a write + * then shows what the write did instead of asking again. + */ +export function mcpAppToolCall(item: { readonly input: unknown; readonly output?: unknown }): { + readonly arguments: unknown; + readonly result: unknown; +} { + const output = Predicate.isObject(item.output) ? item.output : undefined; + const call = output?.[MCP_APP_CALL_OUTPUT_KEY]; + return Predicate.isObject(call) && Predicate.isObject(call.result) + ? { arguments: call.arguments, result: call.result } + : { arguments: item.input, result: output?.result }; +} + export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppReference): boolean { return ( left.attachmentId === right.attachmentId &&