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