Open History to see the last commit
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/** Reports stopped sessions without starting them; app views must not spend provider turns. */
30export class McpAppRequests extends Context.Service<
31 McpAppRequests,
32 {
33 readonly callTool: (
34 input: McpAppCallToolInput,
35 ) => Effect.Effect<McpAppCallToolResult, McpAppRequestError>;
36 readonly toolInfo: (
37 input: McpAppToolInfoInput,
38 ) => Effect.Effect<McpAppToolInfo, McpAppRequestError>;
39 readonly readResource: (
40 input: McpAppReadResourceInput,
41 ) => Effect.Effect<McpAppReadResourceResult, McpAppRequestError>;
42 readonly updateModelContext: (
43 input: McpAppUpdateModelContextInput,
44 ) => Effect.Effect<void, McpAppRequestError>;
45 }
46>()("t3/mcpApps/McpAppRequests") {}
47
48const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
49const utf8 = new TextEncoder();
50
51const readOnlyHint = (tool: ProviderAdapter.ProviderAdapterV2McpTool) =>
52 Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true;
53
54const toolTitle = (tool: ProviderAdapter.ProviderAdapterV2McpTool) =>
55 Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string"
56 ? tool.annotations.title
57 : undefined;
58
59const make = Effect.gen(function* () {
60 const orchestrator = yield* Orchestrator.OrchestratorV2;
61 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
62 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
63 const modelContext = yield* McpAppModelContext.McpAppModelContext;
64
65 const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) =>
66 new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) });
67 const adapterFailure = (threadId: ThreadId) => (cause: ProviderAdapter.ProviderAdapterV2Error) =>
68 fail(
69 threadId,
70 cause._tag === "ProviderAdapterSessionStoppedError" ? "session-stopped" : "request-failed",
71 cause,
72 );
73
74 const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: {
75 readonly threadId: ThreadId;
76 readonly itemId: TurnItemId;
77 }) {
78 const item = yield* orchestrator
79 .getTurnItem({ threadId: input.threadId, itemId: input.itemId })
80 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
81 const app: McpAppReference | undefined =
82 item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
83 if (item === null || app === undefined || item.providerThreadId === null) {
84 return yield* fail(input.threadId, "not-an-app");
85 }
86 return { app, providerThreadId: item.providerThreadId };
87 });
88
89 const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: {
90 readonly threadId: ThreadId;
91 readonly itemId: TurnItemId;
92 }) {
93 const { app, providerThreadId } = yield* resolveApp(input);
94 const projection = yield* threadManagement
95 .getThreadRecords(input.threadId, ["providerThreads"])
96 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
97 const providerThread = projection.providerThreads.find(
98 (candidate) => candidate.id === providerThreadId,
99 );
100 if (providerThread?.providerSessionId == null) {
101 return yield* fail(input.threadId, "session-stopped");
102 }
103 const runtime = Option.getOrUndefined(
104 yield* providerSessions
105 .get(providerThread.providerSessionId)
106 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))),
107 );
108 if (runtime === undefined) return yield* fail(input.threadId, "session-stopped");
109 const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps | undefined = runtime.mcpApps;
110 if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported");
111 return { app, providerThread, mcpApps };
112 });
113
114 const findTool = Effect.fn("McpAppRequests.findTool")(function* (
115 threadId: ThreadId,
116 resolved: Effect.Success<ReturnType<typeof resolve>>,
117 name: string,
118 ) {
119 const tools = yield* resolved.mcpApps
120 .listTools({ providerThread: resolved.providerThread, server: resolved.app.server })
121 .pipe(Effect.mapError(adapterFailure(threadId)));
122 return tools.find((tool) => tool.name === name);
123 });
124
125 const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) {
126 const resolved = yield* resolve(input);
127 const tool = yield* findTool(input.threadId, resolved, input.name);
128 const title = tool === undefined ? undefined : toolTitle(tool);
129 return {
130 callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta),
131 readOnly: tool !== undefined && readOnlyHint(tool),
132 ...(title === undefined ? {} : { title }),
133 ...(tool === undefined ? {} : { tool }),
134 } satisfies McpAppToolInfo;
135 });
136
137 const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) {
138 const resolved = yield* resolve(input);
139 // Enforce hidden-tool restrictions server-side; client checks cannot enforce the spec.
140 const tool = yield* findTool(input.threadId, resolved, input.name);
141 if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) {
142 return yield* fail(input.threadId, "tool-not-callable");
143 }
144 return yield* resolved.mcpApps
145 .callTool({
146 providerThread: resolved.providerThread,
147 server: resolved.app.server,
148 tool: input.name,
149 arguments: input.arguments,
150 })
151 .pipe(Effect.mapError(adapterFailure(input.threadId)));
152 });
153
154 const readResource = Effect.fn("McpAppRequests.readResource")(function* (
155 input: McpAppReadResourceInput,
156 ) {
157 const resolved = yield* resolve(input);
158 return yield* resolved.mcpApps
159 .readResource({
160 providerThread: resolved.providerThread,
161 server: resolved.app.server,
162 uri: input.uri,
163 })
164 .pipe(Effect.mapError(adapterFailure(input.threadId)));
165 });
166
167 const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* (
168 threadId: ThreadId,
169 ancestorId: ThreadId,
170 ) {
171 const seen = new Set<ThreadId>();
172 let current: ThreadId | null = threadId;
173 while (current !== null && !seen.has(current)) {
174 if (current === ancestorId) return true;
175 seen.add(current);
176 const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement
177 .getThreadRecords(current, [])
178 .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
179 const { lineage } = records.thread;
180 current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null;
181 }
182 return false;
183 });
184
185 const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* (
186 input: McpAppUpdateModelContextInput,
187 ) {
188 const { app } = yield* resolveApp(input);
189 // Context belongs only to the app's thread or its descendants.
190 if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) {
191 return yield* fail(input.threadId, "not-an-app");
192 }
193 const texts: Array<string> = [];
194 for (const block of input.content ?? []) {
195 if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") {
196 return yield* fail(input.threadId, "unsupported-content");
197 }
198 texts.push(block.text);
199 }
200 if (input.structuredContent !== undefined) {
201 texts.push(encodeJson(input.structuredContent));
202 }
203 const text = texts.join("\n");
204 if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) {
205 return yield* fail(
206 input.threadId,
207 "request-failed",
208 new Error("Model context is too large."),
209 );
210 }
211 yield* modelContext
212 .set({
213 threadId: input.conversationThreadId,
214 itemId: input.itemId,
215 server: app.server,
216 tool: app.tool,
217 text,
218 })
219 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
220 });
221
222 return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext });
223});
224
225export const layer = Layer.effect(McpAppRequests, make);