Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.peerLinksList]: AuthAccessReadScope,
...CLIENT_GUARDED_RPC_SCOPES,
[WS_METHODS.threadHandoffOptions]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export const make = Effect.gen(function* () {
mcpModeLimitHeader: true,
linkFence: true,
threadImport: true,
threadHandoff: true,
connectionProbe: true,
attachmentUploads: true,
questionAttachments: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ const HANDOFF_TOOLS: ReadonlySet<string> = new Set([
"create_threads",
"t3_thread_launch",
"t3_thread_import",
"t3_thread_handoff",
"t3_thread_send",
"t3_thread_send_attachments",
"t3_thread_fork",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ function threadDetail(
interactionMode: projection.thread.interactionMode,
linkedPullRequest: projection.thread.linkedPullRequest ?? null,
pullRequests: threadPullRequests(projection.thread.pullRequests),
handoff: projection.thread.handoff ?? null,
titleRegeneration:
projection.thread.titleRegeneration === undefined ||
projection.thread.titleRegeneration === null
Expand Down
37 changes: 28 additions & 9 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as ThreadImport from "../../../orchestration-v2/ThreadImportService.ts";
import * as HandoffImport from "../../../peer/handoff/HandoffImport.ts";
import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
Expand Down Expand Up @@ -62,14 +63,36 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
(input) => input,
(input, modes) =>
Effect.gen(function* () {
const imports = yield* ThreadImport.ThreadImportService;
return yield* imports.importThread({ ...input, ...modes }).pipe(
const handoff = yield* Effect.serviceOption(HandoffImport.HandoffImport);
const imports = yield* Effect.serviceOption(ThreadImport.ThreadImportService);
if (Option.isNone(handoff) && (input.bundle !== undefined || Option.isNone(imports)))
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message: "This server cannot receive handoff bundles.",
});
const accept = Option.isSome(handoff)
? handoff.value.importThread({ ...input, ...modes })
: Option.isSome(imports)
? imports.value.importThread({ ...input, ...modes })
: Effect.die("unreachable import service");
return yield* accept.pipe(
Effect.mapError(
(error) =>
new OrchestratorMcpFailure({
code:
error._tag === "ThreadImportError" ? "orchestration_error" : "invalid_request",
message: error.message,
error._tag === "ThreadImportError" || error._tag === "HandoffImportOperationError"
? "orchestration_error"
: "invalid_request",
message:
error._tag === "HandoffImportGitRefusedError"
? error.cause.message
: error.message,
...(error._tag === "HandoffImportRefusedError"
? { handoffNotAccepted: error.notAccepted }
: error._tag === "HandoffImportProviderDisabledError" ||
error._tag === "HandoffImportGitRefusedError"
? { handoffNotAccepted: { source: error.source, projectId: error.projectId } }
: {}),
}),
),
);
Expand Down Expand Up @@ -228,11 +251,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
t3_project_list: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const projects = yield* access;
const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));
const rows = snapshot.projects.filter((project) => project.deletedAt === null);
const start = input.cursor ?? 0,
end = start + (input.limit ?? 20);
return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };
return yield* projects.list(input).pipe(Effect.mapError(unavailable));
}),
),
t3_project_read: McpToolAccess.reads((input) =>
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ import {
import * as FileSystem from "effect/FileSystem";
import * as ServerConfig from "../../../config.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as ThreadImportService from "../../../orchestration-v2/ThreadImportService.ts";
import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as Crypto from "effect/Crypto";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/ai";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../../../project/RepositoryIdentityResolver.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts";
Expand All @@ -49,12 +49,22 @@ const shared = {
};
const ProjectListTool = Tool.make("t3_project_list", {
...shared,
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
ProjectService.ProjectService,
RepositoryIdentityResolver.RepositoryIdentityResolver,
],
description:
"List registered projects locally or in a linked environment with environmentId. Pages use the current project snapshot and may shift between calls.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
cursor: Schema.optional(NonNegativeInt),
limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))),
resolveRepositoryIdentity: Schema.optional(Schema.Boolean).annotate({
description:
"Wait for repository identities on this page when matching a handoff destination.",
}),
}),
success: Schema.Struct({
projects: Schema.Array(Project),
Expand All @@ -65,6 +75,11 @@ const ProjectListTool = Tool.make("t3_project_list", {
.annotate(Tool.Destructive, false);
const ProjectReadTool = Tool.make("t3_project_read", {
...shared,
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
ProjectService.ProjectService,
],
description:
"Read a registered project locally or in a linked environment with environmentId, including its workspace and saved scripts.",
parameters: Schema.Struct({
Expand Down Expand Up @@ -183,7 +198,10 @@ const ThreadImportTool = Tool.make("t3_thread_import", {
environmentId: OrchestratorMcpEnvironmentTarget,
}),
success: OrchestratorMcpThreadImportResult,
dependencies: [...shared.dependencies, ThreadImportService.ThreadImportService],
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
],
})
.annotate(Tool.Destructive, true)
.annotate(Tool.OpenWorld, true);
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
type OrchestrationV2Command,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { modelSelectionCommandType } from "@t3tools/shared/model";

import * as ThreadHandoff from "../../../peer/handoff/ThreadHandoff.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import {
dispatchFailure,
Expand Down Expand Up @@ -75,6 +78,27 @@ const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A,
) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);

export const layer = McpToolAccess.toLayer(ThreadToolkit, {
t3_thread_handoff: writesThread((input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
const handoff = yield* Effect.serviceOption(ThreadHandoff.ThreadHandoff);
if (Option.isNone(handoff))
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message: "This server does not support thread handoff.",
});
return yield* handoff.value.start({ ...input, callerThreadId: scope.thread?.threadId }).pipe(
Effect.mapError(
(cause) =>
new OrchestratorMcpFailure({
code: cause.reason === "failed" ? "orchestration_error" : "invalid_request",
message: cause.message,
}),
),
);
}),
),

run_scheduled_task_now: McpToolAccess.writesEnvironment((input) =>
Effect.gen(function* () {
const scheduler = yield* ScheduledTasks.ScheduledTaskService;
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
OrchestratorMcpEnvironmentTarget,
OrchestratorMcpThreadHandoffInput,
OrchestratorMcpThreadHandoffResult,
ScheduledTaskId,
ScheduledTask,
OrchestrationSearchThreadsInput,
Expand Down Expand Up @@ -308,7 +310,23 @@ const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", {
.annotate(Tool.Destructive, true)
.annotate(Tool.OpenWorld, true);

const ThreadHandoffTool = Tool.make("t3_thread_handoff", {
description:
"Move a thread's conversation and Git work to a linked environment. Omit threadId to move your own thread after its active turn fully finishes. New instructions cancel a pending move. Pass projectId from that environment or omit it to match the repository. Read current handoff metadata with t3_thread_read. Retry a departing move with its handoffId and saved environmentId/projectId, omitting continuationPrompt; retries preserve the original request. The same ID after departure returns its saved destination.",
parameters: OrchestratorMcpThreadHandoffInput,
success: OrchestratorMcpThreadHandoffResult,
failure: OrchestratorMcpFailure,
failureMode: "return" as const,
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
],
})
.annotate(Tool.Destructive, true)
.annotate(Tool.OpenWorld, true);

export const ThreadToolkit = Toolkit.make(
ThreadHandoffTool,
ScheduledTaskRunTool,
ThreadSearchTool,
ThreadForkTool,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../../../project/RepositoryIdentityResolver.ts";
import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
Expand Down Expand Up @@ -47,6 +48,7 @@ const layerStubServices = Layer.mergeAll(
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}),
Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}),
ServerSettings.layerTest({}),
Layer.mock(GitWorkflowService.GitWorkflowService)({}),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
* even where they differ from the method prefix.
*/
const RPC_AGGREGATES = {
[WS_METHODS.threadHandoffOptions]: "threadHandoff",
[WS_METHODS.threadHandoffStart]: "threadHandoff",
[WS_METHODS.threadHandoffCancel]: "threadHandoff",
[WS_METHODS.peerLinksList]: "peerLinks",
[WS_METHODS.peerLinksLink]: "peerLinks",
[WS_METHODS.peerLinksUnlink]: "peerLinks",
Expand Down
Loading
Loading