Skip to content
Closed
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
} from "./toolkits/preview/tools.ts";
import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts";
import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import { ReviewToolkitHandlersLive } from "./toolkits/review/handlers.ts";
import { ReviewToolkit } from "./toolkits/review/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
Expand Down Expand Up @@ -675,6 +677,10 @@ const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe(
Layer.provide(WorktreeMcpService.layer),
);

const ReviewToolkitRegistrationLive = McpServer.toolkit(ReviewToolkit).pipe(
Layer.provide(ReviewToolkitHandlersLive),
);

const PreviewControlsRegistrationLive = McpServer.toolkit(PreviewControlsToolkit).pipe(
Layer.provide(PreviewControlsHandlersLive),
);
Expand Down Expand Up @@ -724,6 +730,7 @@ export const layer = Layer.mergeAll(
EnvironmentRegistrationLive,
PreviewControlsRegistrationLive,
WorktreeToolkitRegistrationLive,
ReviewToolkitRegistrationLive,
PullRequestsToolkitRegistrationLive,
DeviceToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,14 @@ describe("orchestrator MCP toolkit", () => {
expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull();
yield* invoke("t3_thread_organize", { action: "unpin" });
expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).toBeNull();
yield* invoke("t3_thread_organize", { action: "auto_settle_off" });
expect(
(yield* orchestrator.getThreadShell(parentThreadId))?.autoSettleDisabledAt,
).not.toBeNull();
yield* invoke("t3_thread_organize", { action: "auto_settle_on" });
expect(
(yield* orchestrator.getThreadShell(parentThreadId))?.autoSettleDisabledAt,
).toBeNull();

if (parentRun === undefined || parentRun.rootNodeId === null) {
return yield* Effect.die(new Error("Parent run missing."));
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/threadAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* <
}
const projection = yield* context.threads
.getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, {
turnItemTypes: ["user_input_request"],
turnItemTypes: ["user_input_request", "approval_request"],
})
.pipe(
Effect.mapError((error) =>
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { AttachmentToolkit } from "./attachment/tools.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
import { ThreadToolkit } from "./thread/tools.ts";
import { WorktreeToolkit } from "./worktree/tools.ts";
import { ReviewToolkit } from "./review/tools.ts";
import { DeviceToolkit } from "./device/tools.ts";
import { PullRequestsToolkit } from "./pullRequests/tools.ts";
import {
Expand All @@ -47,6 +48,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => {
OrchestratorToolkit,
PreviewToolkit,
WorktreeToolkit,
ReviewToolkit,
ThreadToolkit,
AttachmentToolkit,
ProjectToolkit,
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/mcp/toolkits/review/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { OrchestratorMcpFailure, type ReviewDiffPreviewInput } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Project from "../../../project/ProjectService.ts";
import * as Review from "../../../review/ReviewService.ts";
import { readThread, unavailable } from "../../threadAccess.ts";
import { ReviewToolkit } from "./tools.ts";

const DEFAULT_DIFF_CHARACTERS = 20_000;

const invalid = (message: string) =>
new OrchestratorMcpFailure({ code: "invalid_request", message });

export const ReviewToolkitHandlersLive = ReviewToolkit.toLayer({
t3_thread_diff: (input) =>
Effect.gen(function* () {
const {
projection: { thread },
} = yield* readThread(input.threadId);
const projects = yield* Project.ProjectService;
const project = yield* projects.getById(thread.projectId).pipe(Effect.mapError(unavailable));
if (Option.isNone(project)) return yield* invalid("The project was not found.");
const review = yield* Review.ReviewService;
// A file request reads a single source; branch-range covers committed and uncommitted work.
const sourceKind = input.source ?? (input.file === undefined ? undefined : "branch-range");
const readPreview = (baseRef: string | undefined, file?: ReviewDiffPreviewInput["file"]) =>
review
.getDiffPreview({
cwd: thread.worktreePath ?? project.value.workspaceRoot,
...(baseRef === undefined ? {} : { baseRef }),
...(file === undefined ? {} : { file }),
})
.pipe(
Effect.mapError((error) => {
switch (error._tag) {
case "VcsRepositoryDetectionError":
case "VcsUnsupportedOperationError":
return invalid(error.detail);
// A baseRef with no common commit with HEAD is the caller's mistake.
case "GitCommandError":
return error.operation === "GitVcsDriver.resolveReviewMergeBase"
? invalid(error.detail)
: unavailable();
default:
return unavailable();
}
}),
);
let preview;
if (input.file !== undefined && sourceKind !== undefined) {
// A renamed file needs its old path, which only the full preview's stats know. The lookup
// is best effort: a working-tree read must not fail on a branch range it does not need.
const full = yield* readPreview(input.baseRef).pipe(Effect.orElseSucceed(() => undefined));
const previousPath =
full?.sources
.find((source) => source.kind === sourceKind)
?.files?.find((file) => file.path === input.file)?.previousPath ?? null;
preview = yield* readPreview(input.baseRef, { path: input.file, previousPath, sourceKind });
} else {
// The working tree needs no base, so a baseRef the branch range can't use is not passed.
preview = yield* readPreview(sourceKind === "working-tree" ? undefined : input.baseRef);
}
if (preview.sources.length === 0)
return yield* invalid("The thread's checkout is not a git repository.");
const maxCharacters = input.maxCharacters ?? DEFAULT_DIFF_CHARACTERS;
return {
threadId: thread.id,
cwd: preview.cwd,
sources: preview.sources
.filter((source) => sourceKind === undefined || source.kind === sourceKind)
.map((source) => ({
kind: source.kind,
title: source.title,
baseRef: source.baseRef,
headRef: source.headRef,
files: source.files ?? null,
diff: source.diff.slice(0, maxCharacters),
truncated: source.truncated || source.diff.length > maxCharacters,
})),
};
}),
});
61 changes: 61 additions & 0 deletions apps/server/src/mcp/toolkits/review/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
OrchestratorMcpFailure,
ReviewDiffFileStat,
ReviewDiffPreviewSource,
ReviewDiffPreviewSourceKind,
ThreadId,
TrimmedNonEmptyString,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/unstable/ai";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as ReviewService from "../../../review/ReviewService.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

const MAX_DIFF_CHARACTERS = 100_000;

const ThreadDiffTool = Tool.make("t3_thread_diff", {
description:
"Show what a thread changed in its checkout (omit threadId for this thread), as the diff panel does. Returns two sources: working-tree (uncommitted changes vs HEAD) and branch-range (committed and uncommitted changes since the merge base with baseRef, or the detected base branch). Each has per-file stats and a unified diff cut to maxCharacters (default 20,000, max 100,000) with truncated set when cut. Pass source to return one. Pass file (repository-relative path) for that file's patch only; it reads branch-range unless source says otherwise.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
baseRef: Schema.optional(TrimmedNonEmptyString),
source: Schema.optional(ReviewDiffPreviewSourceKind),
file: Schema.optional(Schema.NonEmptyString),
maxCharacters: Schema.optional(
Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_DIFF_CHARACTERS })),
),
}),
success: Schema.Struct({
threadId: ThreadId,
cwd: Schema.String,
sources: Schema.Array(
Schema.Struct({
kind: ReviewDiffPreviewSource.fields.kind,
title: ReviewDiffPreviewSource.fields.title,
baseRef: ReviewDiffPreviewSource.fields.baseRef,
headRef: ReviewDiffPreviewSource.fields.headRef,
// Null when there were too many untracked files to count.
files: Schema.NullOr(Schema.Array(ReviewDiffFileStat)),
diff: Schema.String,
truncated: Schema.Boolean,
}),
),
}),
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
ProjectService.ProjectService,
ReviewService.ReviewService,
],
})
.annotate(Tool.Title, "Show thread diff")
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

export const ReviewToolkit = Toolkit.make(ThreadDiffTool);
Loading
Loading