Skip to content

Commit 31d431e

Browse files
committed
Add Codex thread start MCP tool
1 parent 28107e8 commit 31d431e

13 files changed

Lines changed: 1032 additions & 303 deletions

File tree

apps/server/src/mcp/McpHttpServer.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
PreviewSnapshotToolkit,
2323
PreviewStandardToolkit,
2424
} from "./toolkits/preview/tools.ts";
25+
import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts";
26+
import { ThreadToolkit } from "./toolkits/thread/tools.ts";
2527

2628
const unauthorized = HttpServerResponse.jsonUnsafe(
2729
{
@@ -208,13 +210,22 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll(
208210
PreviewSnapshotRegistrationLive,
209211
);
210212

213+
export const ThreadToolkitRegistrationLive = McpServer.toolkit(ThreadToolkit).pipe(
214+
Layer.provide(ThreadToolkitHandlersLive),
215+
);
216+
211217
const McpTransportLive = McpServer.layerHttp({
212218
name: "T3 Code",
213219
version: packageJson.version,
214220
path: "/mcp",
215221
}).pipe(Layer.provide(McpAuthMiddlewareLive));
216222

217-
export const layer = PreviewToolkitRegistrationLive.pipe(
223+
export const ToolkitRegistrationLive = Layer.mergeAll(
224+
PreviewToolkitRegistrationLive,
225+
ThreadToolkitRegistrationLive,
226+
);
227+
228+
export const layer = ToolkitRegistrationLive.pipe(
218229
Layer.provideMerge(McpTransportLive),
219230
Layer.provide(PreviewAutomationBroker.layer),
220231
);

apps/server/src/mcp/McpInvocationContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import * as Context from "effect/Context";
88
import * as Effect from "effect/Effect";
99

10-
export type McpCapability = "preview";
10+
export type McpCapability = "preview" | "thread-management";
1111

1212
export interface McpInvocationScope {
1313
readonly environmentId: EnvironmentId;

apps/server/src/mcp/McpSessionRegistry.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
4747

4848
const resolved = yield* registry.resolve(token);
4949
expect(resolved?.threadId).toBe(threadId);
50+
expect(resolved?.capabilities.has("preview")).toBe(true);
51+
expect(resolved?.capabilities.has("thread-management")).toBe(true);
5052

5153
yield* registry.revokeThread(threadId);
5254
expect(yield* registry.resolve(token)).toBeUndefined();

apps/server/src/mcp/McpSessionRegistry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
114114
threadId: ThreadId.make(request.threadId),
115115
providerSessionId,
116116
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
117-
capabilities: new Set(["preview"]),
117+
capabilities: new Set(["preview", "thread-management"]),
118118
issuedAt,
119119
expiresAt,
120120
};
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { expect, it } from "@effect/vitest";
2+
import {
3+
EnvironmentId,
4+
ProjectId,
5+
ProviderInstanceId,
6+
ThreadId,
7+
type ModelSelection,
8+
type OrchestrationCommand,
9+
type OrchestrationProjectShell,
10+
type OrchestrationThreadShell,
11+
} from "@t3tools/contracts";
12+
import * as Effect from "effect/Effect";
13+
import * as Crypto from "effect/Crypto";
14+
import * as Layer from "effect/Layer";
15+
import * as Option from "effect/Option";
16+
import * as Stream from "effect/Stream";
17+
import { McpSchema, McpServer } from "effect/unstable/ai";
18+
19+
import { GitWorkflowService } from "../../../git/GitWorkflowService.ts";
20+
import * as BootstrapTurnStartDispatcher from "../../../orchestration/Services/BootstrapTurnStartDispatcher.ts";
21+
import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts";
22+
import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
23+
import * as McpInvocationContext from "../../McpInvocationContext.ts";
24+
import { ThreadToolkitRegistrationLive } from "../../McpHttpServer.ts";
25+
import { ThreadStartRuntimeLive } from "./handlers.ts";
26+
27+
const projectId = ProjectId.make("project-thread-mcp");
28+
const sourceThreadId = ThreadId.make("source-thread-mcp");
29+
const modelSelection: ModelSelection = {
30+
instanceId: ProviderInstanceId.make("codex"),
31+
model: "gpt-5",
32+
options: [{ id: "reasoningEffort", value: "high" }],
33+
};
34+
const sourceThread: OrchestrationThreadShell = {
35+
id: sourceThreadId,
36+
projectId,
37+
title: "Source",
38+
modelSelection,
39+
runtimeMode: "auto-accept-edits",
40+
interactionMode: "plan",
41+
branch: "feature/source",
42+
worktreePath: null,
43+
latestTurn: null,
44+
createdAt: "2026-06-16T00:00:00.000Z",
45+
updatedAt: "2026-06-16T00:00:00.000Z",
46+
archivedAt: null,
47+
session: null,
48+
latestUserMessageAt: null,
49+
hasPendingApprovals: false,
50+
hasPendingUserInput: false,
51+
hasActionableProposedPlan: false,
52+
};
53+
const project: OrchestrationProjectShell = {
54+
id: projectId,
55+
title: "Project",
56+
workspaceRoot: "/repo",
57+
defaultModelSelection: modelSelection,
58+
scripts: [],
59+
createdAt: "2026-06-16T00:00:00.000Z",
60+
updatedAt: "2026-06-16T00:00:00.000Z",
61+
};
62+
const invocation: McpInvocationContext.McpInvocationScope = {
63+
environmentId: EnvironmentId.make("environment-thread-mcp"),
64+
threadId: sourceThreadId,
65+
providerSessionId: "provider-session-thread-mcp",
66+
providerInstanceId: ProviderInstanceId.make("codex"),
67+
capabilities: new Set(["thread-management"]),
68+
issuedAt: 1,
69+
expiresAt: Number.MAX_SAFE_INTEGER,
70+
};
71+
const client = McpSchema.McpServerClient.of({
72+
clientId: 1,
73+
initializePayload: {
74+
protocolVersion: "2025-03-26",
75+
capabilities: {},
76+
clientInfo: { name: "thread-test", version: "1.0.0" },
77+
},
78+
getClient: Effect.die("unused"),
79+
});
80+
81+
const TestCryptoLive = Layer.sync(Crypto.Crypto, () => {
82+
let nextByte = 0;
83+
return Crypto.make({
84+
randomBytes: (size) =>
85+
Uint8Array.from({ length: size }, () => {
86+
nextByte = (nextByte + 1) % 256;
87+
return nextByte;
88+
}),
89+
digest: (_algorithm, data) => Effect.succeed(data),
90+
});
91+
});
92+
93+
const makeTestLayer = (commands: OrchestrationCommand[]) => {
94+
const bootstrapTurnStartDispatcherLayer = Layer.mock(
95+
BootstrapTurnStartDispatcher.BootstrapTurnStartDispatcher,
96+
)({
97+
dispatch: (command) =>
98+
Effect.sync(() => {
99+
commands.push(command);
100+
return { sequence: 1 };
101+
}),
102+
});
103+
104+
return ThreadToolkitRegistrationLive.pipe(
105+
Layer.provideMerge(ThreadStartRuntimeLive),
106+
Layer.provideMerge(
107+
BootstrapTurnStartDispatcher.ActiveBootstrapTurnStartDispatcherLive.pipe(
108+
Layer.provide(bootstrapTurnStartDispatcherLayer),
109+
),
110+
),
111+
Layer.provideMerge(TestCryptoLive),
112+
Layer.provideMerge(McpServer.McpServer.layer),
113+
Layer.provide(
114+
Layer.mock(ProjectionSnapshotQuery)({
115+
getProjectShellById: () => Effect.succeed(Option.some(project)),
116+
getThreadShellById: () => Effect.succeed(Option.some(sourceThread)),
117+
}),
118+
),
119+
Layer.provide(
120+
Layer.mock(GitWorkflowService)({
121+
listRefs: () =>
122+
Effect.succeed({
123+
refs: [
124+
{
125+
name: "main",
126+
current: false,
127+
isDefault: true,
128+
isRemote: false,
129+
worktreePath: null,
130+
},
131+
],
132+
isRepo: true,
133+
hasPrimaryRemote: true,
134+
nextCursor: null,
135+
totalCount: 1,
136+
}),
137+
status: () =>
138+
Effect.succeed({
139+
isRepo: true,
140+
hasPrimaryRemote: true,
141+
isDefaultRef: false,
142+
refName: "feature/source",
143+
hasWorkingTreeChanges: false,
144+
workingTree: {
145+
files: [],
146+
insertions: 0,
147+
deletions: 0,
148+
},
149+
hasUpstream: true,
150+
aheadCount: 0,
151+
behindCount: 0,
152+
aheadOfDefaultCount: 0,
153+
pr: null,
154+
}),
155+
}),
156+
),
157+
Layer.provide(
158+
Layer.mock(OrchestrationEngineService)({
159+
readEvents: () => Stream.empty,
160+
dispatch: () => Effect.succeed({ sequence: 1 }),
161+
streamDomainEvents: Stream.empty,
162+
}),
163+
),
164+
);
165+
};
166+
167+
const callStartTool = (arguments_: Record<string, unknown>, commands: OrchestrationCommand[]) =>
168+
Effect.gen(function* () {
169+
const server = yield* McpServer.McpServer;
170+
return yield* server
171+
.callTool({ name: "t3_thread_start", arguments: arguments_ })
172+
.pipe(
173+
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
174+
Effect.provideService(McpSchema.McpServerClient, client),
175+
);
176+
}).pipe(Effect.provide(makeTestLayer(commands)));
177+
178+
it.effect("starts a new worktree thread by default and inherits source settings", () =>
179+
Effect.gen(function* () {
180+
const commands: OrchestrationCommand[] = [];
181+
const result = yield* callStartTool({ prompt: "Investigate flaky tests" }, commands);
182+
183+
expect(result.isError).toBe(false);
184+
expect(result.structuredContent).toMatchObject({
185+
projectId,
186+
mode: "new_worktree",
187+
worktreePath: null,
188+
});
189+
const command = commands[0];
190+
expect(command?.type).toBe("thread.turn.start");
191+
if (command?.type !== "thread.turn.start") return;
192+
expect(command.message.text).toBe("Investigate flaky tests");
193+
expect(command.modelSelection).toEqual(modelSelection);
194+
expect(command.runtimeMode).toBe("auto-accept-edits");
195+
expect(command.interactionMode).toBe("plan");
196+
expect(command.bootstrap?.createThread?.modelSelection).toEqual(modelSelection);
197+
expect(command.bootstrap?.prepareWorktree).toMatchObject({
198+
projectCwd: "/repo",
199+
baseBranch: "main",
200+
});
201+
expect(command.bootstrap?.runSetupScript).toBe(true);
202+
}),
203+
);
204+
205+
it.effect("starts current-checkout threads with warning metadata", () =>
206+
Effect.gen(function* () {
207+
const commands: OrchestrationCommand[] = [];
208+
const result = yield* callStartTool(
209+
{ prompt: "Read current diff", mode: "current_checkout" },
210+
commands,
211+
);
212+
213+
expect(result.isError).toBe(false);
214+
expect(result.structuredContent).toMatchObject({
215+
projectId,
216+
mode: "current_checkout",
217+
branch: "feature/source",
218+
worktreePath: null,
219+
});
220+
expect(result.structuredContent).toHaveProperty("warning");
221+
const command = commands[0];
222+
expect(command?.type).toBe("thread.turn.start");
223+
if (command?.type !== "thread.turn.start") return;
224+
expect(command.bootstrap?.prepareWorktree).toBeUndefined();
225+
expect(command.bootstrap?.createThread?.worktreePath).toBeNull();
226+
}),
227+
);

0 commit comments

Comments
 (0)