1import type { ThreadId, TurnItemId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import * as SqlClient from "effect/sql/SqlClient";
8
9/** Largest context one app may keep for the agent, in UTF-8 bytes. */
10export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024;
11
12export class McpAppModelContextError extends Schema.TaggedError<McpAppModelContextError>()(
13 "McpAppModelContextError",
14 { cause: Schema.optional(Schema.Defect()) },
15) {}
16
17export interface McpAppModelContextEntry {
18 readonly itemId: string;
19 readonly server: string;
20 readonly tool: string;
21 readonly text: string;
22}
23
24/**
25 * What each MCP App told the agent through `ui/update-model-context`. The spec
26 * replaces an app's context on every update, so there is one entry per app (its
27 * tool call); the thread's next turn carries the entries to the provider.
28 */
29export class McpAppModelContext extends Context.Service<
30 McpAppModelContext,
31 {
32 /** Replaces the app's context; empty text removes it. */
33 readonly set: (input: {
34 readonly threadId: ThreadId;
35 readonly itemId: TurnItemId;
36 readonly server: string;
37 readonly tool: string;
38 readonly text: string;
39 }) => Effect.Effect<void, McpAppModelContextError>;
40 readonly forThread: (
41 threadId: ThreadId,
42 ) => Effect.Effect<ReadonlyArray<McpAppModelContextEntry>, McpAppModelContextError>;
43 }
44>()("t3/mcpApps/McpAppModelContext") {}
45
46export const layer = Layer.effect(
47 McpAppModelContext,
48 Effect.gen(function* () {
49 const sql = yield* SqlClient.SqlClient;
50 const fail = (cause: unknown) => new McpAppModelContextError({ cause });
51
52 const set = Effect.fn("McpAppModelContext.set")(function* (input: {
53 readonly threadId: ThreadId;
54 readonly itemId: TurnItemId;
55 readonly server: string;
56 readonly tool: string;
57 readonly text: string;
58 }) {
59 if (input.text.trim() === "") {
60 yield* sql`
61 DELETE FROM mcp_app_model_context
62 WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId}
63 `.pipe(Effect.mapError(fail));
64 return;
65 }
66 const updatedAt = DateTime.formatIso(yield* DateTime.now);
67 yield* sql`
68 INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at)
69 VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt})
70 ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at
71 `.pipe(Effect.mapError(fail));
72 });
73
74 const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) {
75 // Only apps still in the thread's history count: an app whose item is
76 // gone, or whose run this thread rolled back, stops informing the agent.
77 // The item can live in a thread this one was forked from, so it is
78 // matched by id alone; that thread rolling the run back later leaves the
79 // fork's copy in place.
80 return yield* sql<McpAppModelContextEntry>`
81 SELECT context.item_id AS "itemId", context.server, context.tool, context.text
82 FROM mcp_app_model_context AS context
83 JOIN orchestration_v2_projection_turn_items AS item
84 ON item.turn_item_id = context.item_id
85 LEFT JOIN orchestration_v2_projection_runs AS run
86 ON run.run_id = item.run_id
87 WHERE context.thread_id = ${threadId}
88 AND (
89 run.status IS NULL
90 OR run.status <> 'rolled_back'
91 OR run.thread_id <> context.thread_id
92 )
93 ORDER BY context.updated_at, context.item_id
94 `.pipe(Effect.mapError(fail));
95 });
96
97 return McpAppModelContext.of({ set, forThread });
98 }),
99);
100
101/** No app context: for code paths and tests with no MCP Apps. */
102export const layerEmpty = Layer.succeed(
103 McpAppModelContext,
104 McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }),
105);