| 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; |
| 6 | import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; |
| 7 | import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; |
| 8 | import * as Project from "../../../project/ProjectService.ts"; |
| 9 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 10 | import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; |
| 11 | import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; |
| 12 | import * as McpToolAccess from "../../McpToolAccess.ts"; |
| 13 | import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; |
| 14 | import { ProjectToolkit } from "./tools.ts"; |
| 15 | |
| 16 | function 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 | */ |
| 32 | const 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 | |
| 51 | const access = Effect.gen(function* () { |
| 52 | yield* readCaller(); |
| 53 | return yield* Project.ProjectService; |
| 54 | }); |
| 55 | export 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 | }); |