| 1 | import { |
| 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"; |
| 14 | import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; |
| 15 | import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; |
| 16 | import * as Context from "effect/Context"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Layer from "effect/Layer"; |
| 19 | import * as Option from "effect/Option"; |
| 20 | import * as Predicate from "effect/Predicate"; |
| 21 | import * as Schema from "effect/Schema"; |
| 22 | |
| 23 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 24 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 25 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; |
| 26 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 27 | import * 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 | */ |
| 36 | export 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 | |
| 54 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 55 | const utf8 = new TextEncoder(); |
| 56 | |
| 57 | const readOnlyHint = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => |
| 58 | Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; |
| 59 | |
| 60 | const toolTitle = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => |
| 61 | Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" |
| 62 | ? tool.annotations.title |
| 63 | : undefined; |
| 64 | |
| 65 | const 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 | |
| 237 | export const layer = Layer.effect(McpAppRequests, make); |