Skip to content

Commit 42e1334

Browse files
feat(mcp): expose project service operations (#10563)
1 parent 2336860 commit 42e1334

7 files changed

Lines changed: 196 additions & 0 deletions

File tree

‎apps/server/src/mcp/McpHttpServer.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
2222
import { PreviewControlsHandlersLive } from "./toolkits/previewControls/handlers.ts";
2323
import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
2424
import { EnvironmentHandlersLive } from "./toolkits/environment/handlers.ts";
25+
import { ProjectToolkit } from "./toolkits/project/tools.ts";
26+
import { ProjectHandlersLive } from "./toolkits/project/handlers.ts";
2527
import { ThreadToolkit } from "./toolkits/thread/tools.ts";
2628
import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts";
2729
import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
@@ -480,6 +482,10 @@ export const EnvironmentRegistrationLive = McpServer.toolkit(EnvironmentToolkit)
480482
Layer.provide(EnvironmentHandlersLive),
481483
);
482484

485+
export const ProjectRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe(
486+
Layer.provide(ProjectHandlersLive),
487+
);
488+
483489
const McpTransportLive = McpServer.layerHttp({
484490
name: "T3 Code",
485491
version: packageJson.version,
@@ -491,6 +497,7 @@ export const layer = Layer.mergeAll(
491497
PreviewToolkitRegistrationLive,
492498
OrchestratorToolkitRegistrationLive,
493499
ThreadToolkitRegistrationLive,
500+
ProjectRegistrationLive,
494501
EnvironmentRegistrationLive,
495502
PreviewControlsRegistrationLive,
496503
WorktreeToolkitRegistrationLive,

‎apps/server/src/mcp/toolkits/core.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { PreviewToolkit } from "./preview/tools.ts";
1919
import { PreviewControlsToolkit } from "./previewControls/tools.ts";
2020
import { EnvironmentToolkit } from "./environment/tools.ts";
2121
import * as EnvironmentHandlers from "./environment/handlers.ts";
22+
import { ProjectToolkit } from "./project/tools.ts";
2223
import { ThreadToolkit } from "./thread/tools.ts";
2324
import { WorktreeToolkit } from "./worktree/tools.ts";
2425

@@ -29,6 +30,7 @@ it("publishes unique tool names with object-root inputs", () => {
2930
PreviewToolkit,
3031
WorktreeToolkit,
3132
ThreadToolkit,
33+
ProjectToolkit,
3234
EnvironmentToolkit,
3335
PreviewControlsToolkit,
3436
]) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2+
import * as Effect from "effect/Effect";
3+
import * as Option from "effect/Option";
4+
import * as Project from "../../../project/ProjectService.ts";
5+
import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
6+
import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";
7+
import { ProjectToolkit } from "./tools.ts";
8+
9+
function projectFailure(error: Project.ProjectServiceError) {
10+
if (error._tag === "ProjectOperationError") return unavailable();
11+
const message =
12+
error._tag === "ProjectNotFoundError"
13+
? "The project was not found."
14+
: error._tag === "ProjectConflictError"
15+
? "The workspace is already registered to a project."
16+
: "The project is not empty; force=true is required to delete it.";
17+
return new OrchestratorMcpFailure({ code: "invalid_request", message });
18+
}
19+
20+
const access = Effect.gen(function* () {
21+
yield* readCaller();
22+
return yield* Project.ProjectService;
23+
});
24+
const mutation = Effect.gen(function* () {
25+
const { caller } = yield* readMutationCaller();
26+
if (
27+
caller.archivedAt !== null ||
28+
caller.runtimeMode !== "full-access" ||
29+
caller.interactionMode !== "default"
30+
)
31+
return yield* new OrchestratorMcpFailure({
32+
code: "capability_denied",
33+
message: "Project changes require a live full-access/default calling thread.",
34+
});
35+
return yield* Project.ProjectService;
36+
});
37+
export const ProjectHandlersLive = ProjectToolkit.toLayer({
38+
t3_project_list: (input) =>
39+
Effect.gen(function* () {
40+
const projects = yield* access;
41+
const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));
42+
const rows = snapshot.projects.filter((project) => project.deletedAt === null);
43+
const start = input.cursor ?? 0,
44+
end = start + (input.limit ?? 20);
45+
return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };
46+
}),
47+
t3_project_read: (input) =>
48+
Effect.gen(function* () {
49+
const projects = yield* access;
50+
const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable));
51+
if (Option.isNone(result))
52+
return yield* new OrchestratorMcpFailure({
53+
code: "invalid_request",
54+
message: "The project was not found.",
55+
});
56+
return result.value;
57+
}),
58+
t3_project_create: (input) =>
59+
Effect.gen(function* () {
60+
const projects = yield* mutation;
61+
const commandId = yield* newCommandId();
62+
return yield* projects
63+
.create({ ...input, commandId, projectId: ProjectId.make(commandId) })
64+
.pipe(Effect.mapError(projectFailure));
65+
}),
66+
t3_project_update: (input) =>
67+
Effect.gen(function* () {
68+
const projects = yield* mutation;
69+
return yield* projects
70+
.update({ ...input, commandId: yield* newCommandId() })
71+
.pipe(Effect.mapError(projectFailure));
72+
}),
73+
t3_project_delete: (input) =>
74+
Effect.gen(function* () {
75+
const projects = yield* mutation;
76+
return yield* projects
77+
.delete({ ...input, commandId: yield* newCommandId() })
78+
.pipe(Effect.mapError(projectFailure));
79+
}),
80+
t3_project_clone: (input) =>
81+
Effect.gen(function* () {
82+
yield* mutation;
83+
const repositories = yield* Repositories.SourceControlRepositoryService;
84+
return yield* repositories.cloneRepository(input).pipe(
85+
Effect.mapError(
86+
(error) =>
87+
new OrchestratorMcpFailure({
88+
code: "orchestration_error",
89+
message: error.detail,
90+
}),
91+
),
92+
);
93+
}),
94+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
NonNegativeInt,
3+
Project,
4+
ProjectCreatePayload,
5+
ProjectUpdatePayload,
6+
ProjectId,
7+
OrchestratorMcpFailure,
8+
SourceControlCloneRepositoryInput,
9+
SourceControlCloneRepositoryResult,
10+
} from "@t3tools/contracts";
11+
import * as Crypto from "effect/Crypto";
12+
import * as Schema from "effect/Schema";
13+
import { Tool, Toolkit } from "effect/unstable/ai";
14+
import { ProjectService } from "../../../project/ProjectService.ts";
15+
import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts";
16+
import { SourceControlRepositoryService } from "../../../sourceControl/SourceControlRepositoryService.ts";
17+
import { McpInvocationContext } from "../../McpInvocationContext.ts";
18+
19+
const shared = {
20+
success: Project,
21+
failure: OrchestratorMcpFailure,
22+
failureMode: "return" as const,
23+
dependencies: [McpInvocationContext, ThreadManagementService, ProjectService, Crypto.Crypto],
24+
};
25+
export const ProjectListTool = Tool.make("t3_project_list", {
26+
...shared,
27+
description:
28+
"List registered projects in this environment. Pages use the current project snapshot and may shift between calls.",
29+
parameters: Schema.Struct({
30+
cursor: Schema.optional(NonNegativeInt),
31+
limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))),
32+
}),
33+
success: Schema.Struct({
34+
projects: Schema.Array(Project),
35+
nextCursor: Schema.NullOr(NonNegativeInt),
36+
}),
37+
})
38+
.annotate(Tool.Readonly, true)
39+
.annotate(Tool.Destructive, false);
40+
export const ProjectReadTool = Tool.make("t3_project_read", {
41+
...shared,
42+
description:
43+
"Read a registered project in this environment, including its workspace and saved scripts.",
44+
parameters: Schema.Struct({ projectId: ProjectId }),
45+
})
46+
.annotate(Tool.Readonly, true)
47+
.annotate(Tool.Destructive, false);
48+
export const ProjectCreateTool = Tool.make("t3_project_create", {
49+
...shared,
50+
description:
51+
"Register a project directory through the existing project service. Set createWorkspaceRootIfMissing to create a directory. Each call creates a new request; an existing registered workspace is rejected. Clone separately with t3_project_clone when needed.",
52+
parameters: ProjectCreatePayload,
53+
}).annotate(Tool.Destructive, true);
54+
export const ProjectUpdateTool = Tool.make("t3_project_update", {
55+
...shared,
56+
description:
57+
"Update a registered project's settings. Omitted fields are preserved. Uses the same project service as the app.",
58+
parameters: Schema.Struct({ projectId: ProjectId, ...ProjectUpdatePayload.fields }),
59+
}).annotate(Tool.Destructive, true);
60+
export const ProjectDeleteTool = Tool.make("t3_project_delete", {
61+
...shared,
62+
description:
63+
"Delete a project using the existing project deletion lifecycle. Nonempty projects require force=true. This does not delete the repository directory or promise a deleted-thread count.",
64+
parameters: Schema.Struct({ projectId: ProjectId, force: Schema.optionalKey(Schema.Boolean) }),
65+
}).annotate(Tool.Destructive, true);
66+
export const ProjectCloneTool = Tool.make("t3_project_clone", {
67+
...shared,
68+
description:
69+
"Clone a repository using the app's source-control service. This only clones; register the returned cwd with t3_project_create. An existing destination is not adopted or removed on failure.",
70+
parameters: SourceControlCloneRepositoryInput,
71+
success: SourceControlCloneRepositoryResult,
72+
dependencies: [...shared.dependencies, SourceControlRepositoryService],
73+
})
74+
.annotate(Tool.Destructive, true)
75+
.annotate(Tool.OpenWorld, true);
76+
export const ProjectToolkit = Toolkit.make(
77+
ProjectListTool,
78+
ProjectReadTool,
79+
ProjectCreateTool,
80+
ProjectUpdateTool,
81+
ProjectDeleteTool,
82+
ProjectCloneTool,
83+
);

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { attachmentRelativePath } from "../../attachmentStore.ts";
4747
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
4848
import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts";
4949
import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
50+
import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts";
5051
import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
5152
import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
5253
import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
@@ -586,6 +587,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
586587
const readOnlyToolNames = [
587588
...Object.values(OrchestratorToolkit.tools),
588589
...Object.values(ThreadToolkit.tools),
590+
...Object.values(ProjectToolkit.tools),
589591
...Object.values(EnvironmentToolkit.tools),
590592
...Object.values(PreviewControlsToolkit.tools),
591593
]

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,8 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
812812
"mcp__t3-code__t3_pending_request_read",
813813
"mcp__t3-code__t3_thread_configuration",
814814
"mcp__t3-code__t3_thread_transfers",
815+
"mcp__t3-code__t3_project_list",
816+
"mcp__t3-code__t3_project_read",
815817
"mcp__t3-code__t3_thread_search",
816818
"mcp__t3-code__t3_preview_list",
817819
"mcp__t3-code__t3_environment_read",

‎packages/shared/src/t3McpToolPresentation.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ const T3_MCP_TOOLS: Record<
7575
t3_preview_close: { displayName: "Close a preview tab" },
7676
t3_environment_read: { displayName: "Read environment preferences" },
7777
t3_environment_preferences_update: { displayName: "Update environment preferences" },
78+
t3_project_list: { displayName: "List projects" },
79+
t3_project_read: { displayName: "Read a project" },
80+
t3_project_create: { displayName: "Create a project" },
81+
t3_project_update: { displayName: "Update a project" },
82+
t3_project_delete: { displayName: "Delete a project" },
83+
t3_project_clone: { displayName: "Clone a repository" },
7884
preview_status: { displayName: "Get preview browser status" },
7985
preview_open: { displayName: "Open a page in the preview browser" },
8086
preview_navigate: { displayName: "Navigate the preview browser" },

0 commit comments

Comments
 (0)