Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
04bd6a8
feat(mcp): add project management tools
juliusmarminge Aug 29, 2026
d4eedd0
fix(mcp): serialize project deletion with thread creation
juliusmarminge Aug 29, 2026
873f0e8
fix(mcp): reject retries for deleted project ids
juliusmarminge Aug 29, 2026
16f6449
style(server): use project service namespace in tests
juliusmarminge Aug 29, 2026
b882491
style(server): namespace thread management test imports
juliusmarminge Aug 29, 2026
e395ca9
fix(server): preserve project defaults in HTTP mutations
juliusmarminge Aug 29, 2026
7e7069a
fix(mcp): harden project creation boundaries
juliusmarminge Aug 30, 2026
adec222
fix(projects): serialize thread creation with deletion
juliusmarminge Aug 30, 2026
9fb259c
test(projects): prove shared creation admission
juliusmarminge Aug 30, 2026
47d0a44
test(projects): harden admission race proof
juliusmarminge Aug 30, 2026
5ab5e37
fix(mcp): preserve project admission failures
juliusmarminge Aug 30, 2026
f619cdf
fix(project): forward workspace creation option
juliusmarminge Aug 30, 2026
2ec2280
fix(mcp): order projects by timestamp instant
juliusmarminge Aug 30, 2026
027c99e
test(mcp): use project settings fixture layer
juliusmarminge Aug 30, 2026
e7b5385
fix(server): keep project admission failures structured
juliusmarminge Aug 30, 2026
6c559d7
fix(projects): share creation and deletion admission
juliusmarminge Sep 5, 2026
2a04a7b
fix(projects): report committed cascade deletions
juliusmarminge Sep 5, 2026
93b2157
test(projects): keep lower fixtures type-safe
juliusmarminge Sep 5, 2026
e3f2cda
fix(mcp): close project operation races
juliusmarminge Sep 5, 2026
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
11 changes: 11 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
import * as McpSessionRegistry from "./McpSessionRegistry.ts";
import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
import * as ProjectMcpService from "./ProjectMcpService.ts";
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import { OrchestratorToolkitHandlersLive } from "./toolkits/orchestrator/handlers.ts";
import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts";
import { ProjectToolkitHandlersLive } from "./toolkits/project/handlers.ts";
import { ProjectToolkit } from "./toolkits/project/tools.ts";
import {
PreviewSnapshotToolkitHandlersLive,
PreviewStandardToolkitHandlersLive,
Expand Down Expand Up @@ -228,6 +232,12 @@ export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(Orchestrato
Layer.provide(ThreadMetadataMcpService.layer),
);

export const ProjectToolkitRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe(
Layer.provide(ProjectToolkitHandlersLive),
Layer.provide(ProjectMcpService.layer),
Layer.provide(T3ProjectFileLoader.layer),
);

export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe(
Layer.provide(WorktreeToolkitHandlersLive),
Layer.provide(WorktreeMcpService.layer),
Expand All @@ -243,5 +253,6 @@ const McpTransportLive = McpServer.layerHttp({
export const layer = Layer.mergeAll(
PreviewToolkitRegistrationLive,
OrchestratorToolkitRegistrationLive,
ProjectToolkitRegistrationLive,
WorktreeToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
170 changes: 170 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import {
EnvironmentId,
MessageId,
NodeId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
RunId,
type ServerProvider,
ThreadId,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";

import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
Expand All @@ -19,6 +27,168 @@ import type { McpInvocationScope } from "./McpInvocationContext.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";

describe("OrchestratorMcpService", () => {
it.effect("preserves a structured parent admission failure", () =>
Effect.gen(function* () {
const parentThreadId = ThreadId.make("thread:mcp-create-admission-parent");
const parentRunId = RunId.make("run:mcp-create-admission-parent");
const parentNodeId = NodeId.make("node:mcp-create-admission-parent");
const projectId = ProjectId.make("project:mcp-create-admission");
const providerInstanceId = ProviderInstanceId.make("codex");
const now = DateTime.makeUnsafe("2026-08-30T12:00:00.000Z");
const activeParent: OrchestrationV2ThreadProjection = {
thread: {
createdBy: "agent",
creationSource: "mcp",
id: parentThreadId,
projectId,
title: "Admission parent",
providerInstanceId,
modelSelection: { instanceId: providerInstanceId, model: "gpt-test" },
runtimeMode: "full-access",
interactionMode: "default",
branch: "main",
worktreePath: null,
activeProviderThreadId: null,
lineage: {
parentThreadId: null,
relationshipToParent: null,
rootThreadId: parentThreadId,
},
forkedFrom: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
},
runs: [
{
id: parentRunId,
threadId: parentThreadId,
ordinal: 1,
providerInstanceId,
modelSelection: { instanceId: providerInstanceId, model: "gpt-test" },
providerThreadId: null,
userMessageId: MessageId.make("message:mcp-create-admission-parent"),
rootNodeId: parentNodeId,
activeAttemptId: null,
status: "running",
requestedAt: now,
startedAt: now,
completedAt: null,
checkpointId: null,
contextHandoffId: null,
},
],
attempts: [],
nodes: [],
subagents: [],
providerSessions: [],
providerThreads: [],
providerTurns: [],
runtimeRequests: [],
messages: [],
plans: [],
turnItems: [],
checkpointScopes: [],
checkpoints: [],
contextHandoffs: [],
contextTransfers: [],
visibleTurnItems: [],
updatedAt: now,
};
const replacementRunId = RunId.make("run:mcp-create-admission-parent-replacement");
const replacementNodeId = NodeId.make("node:mcp-create-admission-parent-replacement");
const replacedParentRun = {
...activeParent,
runs: [
{
...activeParent.runs[0]!,
status: "completed" as const,
completedAt: now,
},
{
...activeParent.runs[0]!,
id: replacementRunId,
ordinal: 2,
userMessageId: MessageId.make("message:mcp-create-admission-parent-replacement"),
rootNodeId: replacementNodeId,
},
],
} satisfies OrchestrationV2ThreadProjection;
const provider = {
instanceId: providerInstanceId,
driver: ProviderDriverKind.make("codex"),
enabled: true,
installed: true,
version: "test",
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-08-30T12:00:00.000Z",
models: [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities: null }],
slashCommands: [],
skills: [],
} satisfies ServerProvider;
const projectionReads = yield* Ref.make(0);
const currentParent = yield* Ref.make<OrchestrationV2ThreadProjection>(activeParent);
const admissionEntered = yield* Deferred.make<void>();
const allowAdmission = yield* Deferred.make<void>();
const dependencies = Layer.mergeAll(
NodeServices.layer,
Layer.mock(ThreadManagementService)({
getThreadProjection: () =>
Ref.update(projectionReads, (count) => count + 1).pipe(
Effect.andThen(Ref.get(currentParent)),
),
withProjectCreationAdmission: (_input, effect) =>
Deferred.succeed(admissionEntered, undefined).pipe(
Effect.andThen(Deferred.await(allowAdmission)),
Effect.andThen(effect(Option.none())),
),
dispatch: () => Effect.die("dispatch must not run after parent admission fails"),
}),
Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([provider]) }),
Layer.mock(ScheduledTaskService)({}),
);
const scope: McpInvocationScope = {
environmentId: EnvironmentId.make("environment:mcp-create-admission"),
threadId: parentThreadId,
providerSessionId: "provider-session:mcp-create-admission",
providerInstanceId,
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};

const error = yield* Effect.gen(function* () {
const creation = yield* Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
return yield* service
.createThreads(scope, {
clientRequestId: "parent-admission-failure",
threads: [{ title: "Must not be created" }],
})
.pipe(Effect.flip);
}).pipe(
Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies))),
Effect.forkChild({ startImmediately: true }),
);
yield* Deferred.await(admissionEntered);
yield* Ref.set(currentParent, replacedParentRun);
yield* Deferred.succeed(allowAdmission, undefined);
return yield* Fiber.join(creation);
}).pipe(Effect.ensuring(Deferred.succeed(allowAdmission, undefined)));

assert.equal(error.code, "parent_not_active");
assert.equal(
error.message,
"Thread creation requires an active parent in the target project.",
);
assert.equal(yield* Ref.get(projectionReads), 2);
}),
);

it.effect("retries terminal acknowledgement with a fresh command id", () =>
Effect.gen(function* () {
const parentThreadId = ThreadId.make("thread:mcp-ack-parent");
Expand Down
75 changes: 52 additions & 23 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export class OrchestratorMcpService extends Context.Service<
>()("t3/mcp/OrchestratorMcpService") {}

const isThreadManagementError = Schema.is(ThreadManagementError);
const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);

function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure {
return new OrchestratorMcpFailure({ code, message });
Expand Down Expand Up @@ -1433,32 +1434,60 @@ const make = Effect.gen(function* () {
title: request.title,
index,
});
const createCommandId = stableCommandId({
scope,
requestKey: key,
operation: "create-thread",
index,
});
const createCommand = {
type: "thread.create",
createdBy: "agent",
creationSource: "mcp",
commandId: createCommandId,
threadId,
projectId: parent.thread.projectId,
title,
modelSelection: target.modelSelection,
runtimeMode,
interactionMode,
branch: parent.thread.branch,
worktreePath: parent.thread.worktreePath,
} as const;
yield* threadManagement
.dispatch({
type: "thread.create",
createdBy: "agent",
creationSource: "mcp",
commandId: stableCommandId({
scope,
requestKey: key,
operation: "create-thread",
index,
}),
threadId,
projectId: parent.thread.projectId,
title,
modelSelection: target.modelSelection,
runtimeMode,
interactionMode,
branch: parent.thread.branch,
worktreePath: parent.thread.worktreePath,
})
.withProjectCreationAdmission(
{ projectId: parent.thread.projectId, commandId: createCommandId },
(receipt) =>
Effect.gen(function* () {
if (Option.isNone(receipt)) {
const freshParent = yield* loadProjection(scope.threadId);
const freshParentRun = latestActiveRun(freshParent);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (
freshParent.thread.projectId !== parent.thread.projectId ||
freshParent.thread.deletedAt !== null ||
freshParent.thread.archivedAt !== null ||
freshParentRun === undefined ||
freshParentRun.id !== parentRun.id ||
freshParentRun.rootNodeId === null ||
freshParentRun.providerInstanceId !== scope.providerInstanceId
) {
return yield* failure(
"parent_not_active",
"Thread creation requires an active parent in the target project.",
);
}
}
return yield* threadManagement.dispatch(createCommand);
}),
)
Comment thread
juliusmarminge marked this conversation as resolved.
.pipe(
Effect.mapError((error) =>
failure(
"orchestration_error",
`Unable to create thread ${index + 1}: ${errorMessage(error)}`,
),
isOrchestratorMcpFailure(error)
? error
: failure(
"orchestration_error",
`Unable to create thread ${index + 1}: ${errorMessage(error)}`,
),
),
);
if (request.prompt !== undefined) {
Expand Down
Loading
Loading