1import {
2 CommandId,
3 OrchestratorMcpFailure,
4 type OrchestrationV2AppThread,
5 type OrchestrationV2Command,
6 type ThreadMetadataMcpAction,
7 type ThreadMetadataMcpUpdateInput,
8 type ThreadMetadataMcpUpdateResult,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16
17import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
18import type { McpInvocationScope } from "./McpInvocationContext.ts";
19
20export class ThreadMetadataMcpService extends Context.Service<
21 ThreadMetadataMcpService,
22 {
23 readonly update: (
24 scope: McpInvocationScope,
25 input: ThreadMetadataMcpUpdateInput,
26 ) => Effect.Effect<ThreadMetadataMcpUpdateResult, OrchestratorMcpFailure>;
27 }
28>()("t3/mcp/ThreadMetadataMcpService") {}
29
30function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure {
31 return new OrchestratorMcpFailure({ code, message });
32}
33
34function errorMessage(error: unknown): string {
35 return error instanceof Error ? error.message : String(error);
36}
37
38function threadLookupFailure(
39 error: ThreadManagementService.ThreadManagementError,
40): OrchestratorMcpFailure {
41 return error._tag === "ThreadManagementThreadNotFoundError"
42 ? failure("thread_not_found", error.message)
43 : failure("orchestration_error", error.message);
44}
45
46function stablePart(value: string): string {
47 return encodeURIComponent(value);
48}
49
50function commandId(input: {
51 readonly scope: McpInvocationScope;
52 readonly threadId: ThreadId;
53 readonly action: ThreadMetadataMcpAction;
54 readonly requestKey: string;
55}): CommandId {
56 return CommandId.make(
57 [
58 "command",
59 "mcp",
60 stablePart(input.scope.requestNamespace),
61 "thread-update",
62 stablePart(input.threadId),
63 stablePart(input.action),
64 stablePart(input.requestKey),
65 ].join(":"),
66 );
67}
68
69function metadataCommand(input: {
70 readonly commandId: CommandId;
71 readonly threadId: ThreadId;
72 readonly projectId: OrchestrationV2AppThread["projectId"];
73 readonly update: ThreadMetadataMcpUpdateInput;
74}): Extract<OrchestrationV2Command, { readonly type: "thread.metadata.update" }> {
75 switch (input.update.action) {
76 case "rename":
77 return {
78 type: "thread.metadata.update",
79 commandId: input.commandId,
80 threadId: input.threadId,
81 title: input.update.title!,
82 };
83 case "regenerate_title":
84 return {
85 type: "thread.metadata.update",
86 commandId: input.commandId,
87 threadId: input.threadId,
88 regenerateTitle: true,
89 };
90 case "link_pull_request":
91 return {
92 type: "thread.metadata.update",
93 commandId: input.commandId,
94 threadId: input.threadId,
95 linkedPullRequest: {
96 projectId: input.projectId,
97 ...input.update.pullRequest!,
98 },
99 };
100 case "unlink_pull_request":
101 return {
102 type: "thread.metadata.update",
103 commandId: input.commandId,
104 threadId: input.threadId,
105 linkedPullRequest: null,
106 };
107 }
108}
109
110function resultFromThread(input: {
111 readonly action: ThreadMetadataMcpAction;
112 readonly commandId: CommandId;
113 readonly sequence: number;
114 readonly thread: OrchestrationV2AppThread;
115}): ThreadMetadataMcpUpdateResult {
116 return {
117 threadId: input.thread.id,
118 action: input.action,
119 commandId: input.commandId,
120 sequence: input.sequence,
121 title: input.thread.title,
122 titleRegeneration:
123 input.thread.titleRegeneration === undefined || input.thread.titleRegeneration === null
124 ? null
125 : {
126 requestId: input.thread.titleRegeneration.requestId,
127 startedAt: DateTime.formatIso(input.thread.titleRegeneration.startedAt),
128 },
129 linkedPullRequest: input.thread.linkedPullRequest ?? null,
130 updatedAt: DateTime.formatIso(input.thread.updatedAt),
131 };
132}
133
134const make = Effect.gen(function* () {
135 const crypto = yield* Crypto.Crypto;
136 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
137
138 const update = Effect.fn("ThreadMetadataMcpService.update")(function* (
139 scope: McpInvocationScope,
140 input: ThreadMetadataMcpUpdateInput,
141 ) {
142 if (!scope.capabilities.has("orchestration")) {
143 return yield* failure(
144 "capability_denied",
145 "This MCP credential does not grant orchestration capabilities.",
146 );
147 }
148
149 const threadId = input.threadId ?? scope.thread?.threadId;
150 if (threadId === undefined) {
151 return yield* failure(
152 "target_required",
153 "Pass threadId: this MCP client is not running inside a T3 thread.",
154 );
155 }
156 const shell = yield* threadManagement
157 .getThreadShell(threadId)
158 .pipe(
159 Effect.mapError((error) =>
160 failure(
161 "orchestration_error",
162 `Unable to locate thread ${threadId}: ${errorMessage(error)}`,
163 ),
164 ),
165 );
166 if (shell === null || shell.deletedAt !== null) {
167 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
168 }
169 const target = yield* threadManagement
170 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])
171 .pipe(Effect.mapError(threadLookupFailure));
172 const requestKey =
173 input.clientRequestId === undefined
174 ? yield* crypto.randomUUIDv4().pipe(Effect.orDie)
175 : input.clientRequestId;
176 const updateCommandId = commandId({
177 scope,
178 threadId,
179 action: input.action,
180 requestKey,
181 });
182 const dispatched = yield* threadManagement
183 .dispatch(
184 metadataCommand({
185 commandId: updateCommandId,
186 threadId,
187 projectId: target.thread.projectId,
188 update: input,
189 }),
190 )
191 .pipe(
192 Effect.mapError((error) =>
193 failure(
194 "orchestration_error",
195 `Unable to ${input.action} for thread ${threadId}: ${errorMessage(error)}`,
196 ),
197 ),
198 );
199 const metadataEvent = dispatched.storedEvents.find(
200 (stored) => stored.event.type === "thread.metadata-updated",
201 );
202 if (metadataEvent === undefined || metadataEvent.event.type !== "thread.metadata-updated") {
203 return yield* failure(
204 "orchestration_error",
205 `Thread ${threadId} metadata update completed without a resultant state.`,
206 );
207 }
208 return resultFromThread({
209 action: input.action,
210 commandId: updateCommandId,
211 sequence: dispatched.sequence,
212 thread: metadataEvent.event.payload,
213 });
214 });
215
216 return ThreadMetadataMcpService.of({ update });
217});
218
219export const layer: Layer.Layer<
220 ThreadMetadataMcpService,
221 never,
222 Crypto.Crypto | ThreadManagementService.ThreadManagementService
223> = Layer.effect(ThreadMetadataMcpService, make);