1import {
2 McpAppRequestError,
3 type McpAppCallToolInput,
4 type McpAppCallToolResult,
5 type McpAppReadResourceInput,
6 type McpAppReadResourceResult,
7 type McpAppToolInfo,
8 type McpAppToolInfoInput,
9 type McpAppUpdateModelContextInput,
10 type OrchestrationV2AppThread,
11 type ThreadId,
12 type TurnItemId,
13} from "@t3tools/contracts";
14import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp";
15import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Predicate from "effect/Predicate";
21import * as Schema from "effect/Schema";
22
23import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
24import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
25import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
26import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
27import * as McpAppModelContext from "./McpAppModelContext.ts";
28
29/**
30 * Requests an MCP App makes of its own MCP server. The app is resolved from the
31 * tool call that produced it, so it reaches only that server, and each request
32 * runs through the thread's live provider session, whose MCP client owns the
33 * connection and its credentials. A stopped session is reported, never
34 * started: an app view must not spend a provider turn.
35 */
36export class McpAppRequests extends Context.Service<
37 McpAppRequests,
38 {
39 readonly callTool: (
40 input: McpAppCallToolInput,
41 ) => Effect.Effect<McpAppCallToolResult, McpAppRequestError>;
42 readonly toolInfo: (
43 input: McpAppToolInfoInput,
44 ) => Effect.Effect<McpAppToolInfo, McpAppRequestError>;
45 readonly readResource: (
46 input: McpAppReadResourceInput,
47 ) => Effect.Effect<McpAppReadResourceResult, McpAppRequestError>;
48 readonly updateModelContext: (
49 input: McpAppUpdateModelContextInput,
50 ) => Effect.Effect<void, McpAppRequestError>;
51 }
52>()("t3/mcpApps/McpAppRequests") {}
53
54const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
55const utf8 = new TextEncoder();
56
57const readOnlyHint = (tool: ProviderAdapter.ProviderAdapterV2McpTool) =>
58 Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true;
59
60const toolTitle = (tool: ProviderAdapter.ProviderAdapterV2McpTool) =>
61 Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string"
62 ? tool.annotations.title
63 : undefined;
64
65const make = Effect.gen(function* () {
66 const orchestrator = yield* Orchestrator.OrchestratorV2;
67 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
68 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
69 const modelContext = yield* McpAppModelContext.McpAppModelContext;
70
71 const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) =>
72 new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) });
73
74 /** The app a tool call produced, read from the stored item. */
75 const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: {
76 readonly threadId: ThreadId;
77 readonly itemId: TurnItemId;
78 }) {
79 // The stored item, not the wire projection, so the reference is intact.
80 const item = yield* orchestrator
81 .getTurnItem({ threadId: input.threadId, itemId: input.itemId })
82 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
83 const app: McpAppReference | undefined =
84 item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
85 if (item === null || app === undefined || item.providerThreadId === null) {
86 return yield* fail(input.threadId, "not-an-app");
87 }
88 return { app, providerThreadId: item.providerThreadId };
89 });
90
91 /** The app, its provider thread, and the live session's MCP Apps operations. */
92 const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: {
93 readonly threadId: ThreadId;
94 readonly itemId: TurnItemId;
95 }) {
96 const { app, providerThreadId } = yield* resolveApp(input);
97 const projection = yield* threadManagement
98 .getThreadRecords(input.threadId, ["providerThreads"])
99 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
100 const providerThread = projection.providerThreads.find(
101 (candidate) => candidate.id === providerThreadId,
102 );
103 if (providerThread?.providerSessionId == null) {
104 return yield* fail(input.threadId, "session-stopped");
105 }
106 const runtime = Option.getOrUndefined(
107 yield* providerSessions
108 .get(providerThread.providerSessionId)
109 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))),
110 );
111 if (runtime === undefined) return yield* fail(input.threadId, "session-stopped");
112 const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps | undefined = runtime.mcpApps;
113 if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported");
114 return { app, providerThread, mcpApps };
115 });
116
117 const findTool = Effect.fn("McpAppRequests.findTool")(function* (
118 threadId: ThreadId,
119 resolved: Effect.Success<ReturnType<typeof resolve>>,
120 name: string,
121 ) {
122 const tools = yield* resolved.mcpApps
123 .listTools({ providerThread: resolved.providerThread, server: resolved.app.server })
124 .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
125 return tools.find((tool) => tool.name === name);
126 });
127
128 const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) {
129 const resolved = yield* resolve(input);
130 const tool = yield* findTool(input.threadId, resolved, input.name);
131 const title = tool === undefined ? undefined : toolTitle(tool);
132 return {
133 callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta),
134 readOnly: tool !== undefined && readOnlyHint(tool),
135 ...(title === undefined ? {} : { title }),
136 ...(tool === undefined ? {} : { tool }),
137 } satisfies McpAppToolInfo;
138 });
139
140 const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) {
141 const resolved = yield* resolve(input);
142 // The spec forbids apps calling tools hidden from them; checked here too,
143 // since a client's own check is only a courtesy to its user.
144 const tool = yield* findTool(input.threadId, resolved, input.name);
145 if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) {
146 return yield* fail(input.threadId, "tool-not-callable");
147 }
148 return yield* resolved.mcpApps
149 .callTool({
150 providerThread: resolved.providerThread,
151 server: resolved.app.server,
152 tool: input.name,
153 arguments: input.arguments,
154 })
155 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
156 });
157
158 const readResource = Effect.fn("McpAppRequests.readResource")(function* (
159 input: McpAppReadResourceInput,
160 ) {
161 const resolved = yield* resolve(input);
162 return yield* resolved.mcpApps
163 .readResource({
164 providerThread: resolved.providerThread,
165 server: resolved.app.server,
166 uri: input.uri,
167 })
168 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
169 });
170
171 // Context is state for the agent's next turn, so it needs the app but not a
172 // live session. Text blocks are kept as written and structured content as
173 // JSON; other content kinds are not declared by the host.
174 /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */
175 const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* (
176 threadId: ThreadId,
177 ancestorId: ThreadId,
178 ) {
179 // Walks fork links until the chain ends; a thread seen twice means a
180 // corrupt cycle, which proves nothing.
181 const seen = new Set<ThreadId>();
182 let current: ThreadId | null = threadId;
183 while (current !== null && !seen.has(current)) {
184 if (current === ancestorId) return true;
185 seen.add(current);
186 const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement
187 .getThreadRecords(current, [])
188 .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
189 const { lineage } = records.thread;
190 current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null;
191 }
192 return false;
193 });
194
195 const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* (
196 input: McpAppUpdateModelContextInput,
197 ) {
198 const { app } = yield* resolveApp(input);
199 // Context belongs to the conversation on screen, which only shows this app
200 // when it is the app's own thread or one forked from it.
201 if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) {
202 return yield* fail(input.threadId, "not-an-app");
203 }
204 const texts: Array<string> = [];
205 for (const block of input.content ?? []) {
206 if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") {
207 return yield* fail(input.threadId, "unsupported-content");
208 }
209 texts.push(block.text);
210 }
211 if (input.structuredContent !== undefined) {
212 texts.push(encodeJson(input.structuredContent));
213 }
214 // Kept as sent; blank text clears the app's context in the store.
215 const text = texts.join("\n");
216 if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) {
217 return yield* fail(
218 input.threadId,
219 "request-failed",
220 new Error("Model context is too large."),
221 );
222 }
223 yield* modelContext
224 .set({
225 threadId: input.conversationThreadId,
226 itemId: input.itemId,
227 server: app.server,
228 tool: app.tool,
229 text,
230 })
231 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
232 });
233
234 return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext });
235});
236
237export const layer = Layer.effect(McpAppRequests, make);