1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
6import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
7import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
8import * as Project from "../../../project/ProjectService.ts";
9import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
10import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
11import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
12import * as McpToolAccess from "../../McpToolAccess.ts";
13import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts";
14import { ProjectToolkit } from "./tools.ts";
15
16function projectFailure(error: Project.ProjectServiceError) {
17 if (error._tag === "ProjectOperationError") return unavailable();
18 const message =
19 error._tag === "ProjectNotFoundError"
20 ? "The project was not found."
21 : error._tag === "ProjectConflictError"
22 ? "The workspace is already registered to a project."
23 : "The project is not empty; force=true is required to delete it.";
24 return new OrchestratorMcpFailure({ code: "invalid_request", message });
25}
26
27/**
28 * An existing checkout a launch may bind: one of the project's own git
29 * worktrees. Without this check a launch could point an agent at any directory
30 * on the machine.
31 */
32const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* (
33 workspaceRoot: string,
34 worktreePath: string,
35) {
36 const git = yield* GitVcsDriver.GitVcsDriver;
37 const fileSystem = yield* FileSystem.FileSystem;
38 const real = (path: string) => fileSystem.realPath(path).pipe(Effect.orElseSucceed(() => path));
39 const worktrees = yield* git.listWorktreePaths(workspaceRoot).pipe(
40 Effect.flatMap((paths) => Effect.forEach(paths, real)),
41 Effect.orElseSucceed((): ReadonlyArray<string> => []),
42 );
43 if (!worktrees.includes(yield* real(worktreePath)))
44 return yield* new OrchestratorMcpFailure({
45 code: "invalid_request",
46 message:
47 "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.",
48 });
49});
50
51const access = Effect.gen(function* () {
52 yield* readCaller();
53 return yield* Project.ProjectService;
54});
55export const layer = McpToolAccess.toLayer(ProjectToolkit, {
56 t3_thread_launch: McpToolAccess.startsThreads(
57 (input) => input,
58 (input, { runtimeMode, interactionMode }) =>
59 Effect.gen(function* () {
60 const context = yield* readCaller();
61 const { caller } = context;
62 const commandId = yield* newCommandId();
63 const threadId = ThreadId.make(commandId);
64 const messageId = MessageId.make(commandId);
65 const attachments = input.attachments ?? [];
66 if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
67 return yield* new OrchestratorMcpFailure({
68 code: "invalid_request",
69 message: "A new thread accepts only pending attachment uploads.",
70 });
71 if (
72 input.scratch === true &&
73 (input.projectId !== undefined || input.workspaceStrategy !== undefined)
74 )
75 return yield* new OrchestratorMcpFailure({
76 code: "invalid_request",
77 message:
78 "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.",
79 });
80 const projectId =
81 input.scratch === true
82 ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe(
83 Effect.flatMap((folders) => folders.ensureScratchProject),
84 Effect.mapError(
85 (error) =>
86 new OrchestratorMcpFailure({
87 code: "orchestration_error",
88 message: error.message,
89 }),
90 ),
91 )).projectId
92 : yield* resolveProjectId(context, input.projectId);
93 const readProject = Project.ProjectService.pipe(
94 Effect.flatMap((projects) => projects.getById(projectId)),
95 Effect.mapError(unavailable),
96 Effect.map(Option.getOrUndefined),
97 );
98 if (input.workspaceStrategy?.type === "existing_worktree") {
99 const project = yield* readProject;
100 if (project === undefined)
101 return yield* new OrchestratorMcpFailure({
102 code: "invalid_request",
103 message: "The project was not found.",
104 });
105 yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath);
106 }
107 // A launch is accepted before its worktree is provisioned, so a base
108 // ref that cannot resolve is refused while the agent can still act on it.
109 const strategy = input.workspaceStrategy;
110 if (strategy?.type === "worktree")
111 yield* ThreadLaunch.ThreadLaunchService.pipe(
112 Effect.flatMap((launches) =>
113 launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }),
114 ),
115 Effect.mapError(
116 (error) =>
117 new OrchestratorMcpFailure({
118 code: "invalid_request",
119 message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${
120 strategy.startFromOrigin === true
121 ? "; the project has no origin remote to fetch it from."
122 : ", or set startFromOrigin:true if it exists only on origin."
123 }`,
124 }),
125 ),
126 );
127 const modelSelection =
128 input.modelSelection ??
129 caller?.modelSelection ??
130 (yield* readProject)?.defaultModelSelection ??
131 undefined;
132 if (modelSelection === undefined)
133 return yield* new OrchestratorMcpFailure({
134 code: "invalid_request",
135 message:
136 "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.",
137 });
138 const result = yield* ThreadMessageIntake.launchThread({
139 commandId,
140 threadId,
141 projectId,
142 title: input.title,
143 modelSelection,
144 runtimeMode,
145 interactionMode,
146 workspaceStrategy: input.workspaceStrategy ?? { type: "root" },
147 ...(input.message === undefined && attachments.length === 0
148 ? {}
149 : {
150 initialMessage: {
151 messageId,
152 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
153 text: input.message ?? "",
154 attachments,
155 },
156 }),
157 createdBy: "agent",
158 creationSource: "mcp",
159 }).pipe(
160 Effect.mapError((error) => {
161 if (error._tag === "AttachmentClaimError") {
162 return new OrchestratorMcpFailure({
163 code: "orchestration_error",
164 message: error.message,
165 });
166 }
167 if (error._tag === "ThreadManagementProviderInstanceDisabledError") {
168 return new OrchestratorMcpFailure({
169 code: "invalid_request",
170 message: error.message,
171 });
172 }
173 return unavailable();
174 }),
175 );
176 const thread = result.projection.thread;
177 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
178 return {
179 threadId: thread.id,
180 projectId: thread.projectId,
181 modelSelection: thread.modelSelection,
182 runId: run?.id ?? null,
183 status: run?.status ?? null,
184 };
185 }),
186 ),
187 t3_project_list: McpToolAccess.reads((input) =>
188 Effect.gen(function* () {
189 const projects = yield* access;
190 const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));
191 const rows = snapshot.projects.filter((project) => project.deletedAt === null);
192 const start = input.cursor ?? 0,
193 end = start + (input.limit ?? 20);
194 return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };
195 }),
196 ),
197 t3_project_read: McpToolAccess.reads((input) =>
198 Effect.gen(function* () {
199 const projects = yield* access;
200 const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable));
201 if (Option.isNone(result))
202 return yield* new OrchestratorMcpFailure({
203 code: "invalid_request",
204 message: "The project was not found.",
205 });
206 return result.value;
207 }),
208 ),
209 t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) =>
210 Effect.gen(function* () {
211 const projects = yield* Project.ProjectService;
212 if (workspaceRoot === undefined) {
213 // Project creation records no model default (only an update does), so
214 // reject what this mode would otherwise drop silently.
215 if (
216 input.scripts !== undefined ||
217 input.createWorkspaceRootIfMissing !== undefined ||
218 input.defaultModelSelection !== undefined
219 )
220 return yield* new OrchestratorMcpFailure({
221 code: "invalid_request",
222 message:
223 "A project started from its title takes only a title; set scripts or defaultModelSelection afterwards with t3_project_update.",
224 });
225 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
226 const created = yield* folders
227 .createNamedProject({ name: input.title })
228 .pipe(
229 Effect.mapError(
230 (error) =>
231 new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }),
232 ),
233 );
234 const project = yield* projects
235 .getById(created.projectId)
236 .pipe(
237 Effect.mapError(unavailable),
238 Effect.flatMap(
239 Option.match({ onNone: () => Effect.fail(unavailable()), onSome: Effect.succeed }),
240 ),
241 );
242 return {
243 ...project,
244 ...(created.commitError === undefined ? {} : { commitError: created.commitError }),
245 };
246 }
247 const commandId = yield* newCommandId();
248 return yield* projects
249 .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) })
250 .pipe(Effect.mapError(projectFailure));
251 }),
252 ),
253 t3_project_update: McpToolAccess.writesEnvironment((input) =>
254 Effect.gen(function* () {
255 const projects = yield* Project.ProjectService;
256 return yield* projects
257 .update({ ...input, commandId: yield* newCommandId() })
258 .pipe(Effect.mapError(projectFailure));
259 }),
260 ),
261 t3_project_delete: McpToolAccess.writesEnvironment((input) =>
262 Effect.gen(function* () {
263 const projects = yield* Project.ProjectService;
264 return yield* projects
265 .delete({ ...input, commandId: yield* newCommandId() })
266 .pipe(Effect.mapError(projectFailure));
267 }),
268 ),
269 t3_project_clone: McpToolAccess.writesEnvironment((input) =>
270 Effect.gen(function* () {
271 const repositories = yield* Repositories.SourceControlRepositoryService;
272 return yield* repositories.cloneRepository(input).pipe(
273 Effect.mapError(
274 (error) =>
275 new OrchestratorMcpFailure({
276 code: "orchestration_error",
277 message: error.detail,
278 }),
279 ),
280 );
281 }),
282 ),
283});