Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
import packageJson from "../../package.json" with { type: "json" };
import * as McpInvocationContext from "./McpInvocationContext.ts";
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 { OrchestratorToolkitHandlersLive } from "./toolkits/orchestrator/handlers.ts";
Expand Down Expand Up @@ -225,6 +226,7 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll(
export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(OrchestratorToolkit).pipe(
Layer.provide(OrchestratorToolkitHandlersLive),
Layer.provide(OrchestratorMcpService.layer),
Layer.provide(ThreadMetadataMcpService.layer),
);

export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe(
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh
model: shell.modelSelection.model,
runtimeMode: shell.runtimeMode,
interactionMode: shell.interactionMode,
linkedPullRequest: shell.linkedPullRequest ?? null,
parentThreadId: shell.lineage.parentThreadId,
relationshipToParent: shell.lineage.relationshipToParent,
itemCount: shell.visibleItemCount,
Expand All @@ -541,6 +542,15 @@ function threadDetail(projection: OrchestrationV2ThreadProjection): Orchestrator
model: projection.thread.modelSelection.model,
runtimeMode: projection.thread.runtimeMode,
interactionMode: projection.thread.interactionMode,
linkedPullRequest: projection.thread.linkedPullRequest ?? null,
titleRegeneration:
projection.thread.titleRegeneration === undefined ||
projection.thread.titleRegeneration === null
? null
: {
requestId: projection.thread.titleRegeneration.requestId,
startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt),
},
branch: projection.thread.branch,
worktreePath: projection.thread.worktreePath,
parentThreadId: projection.thread.lineage.parentThreadId,
Expand Down
148 changes: 146 additions & 2 deletions apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type ScheduledTaskUpsertInput,
type ServerProvider,
ThreadId,
ThreadMetadataMcpUpdateResult,
TurnItemId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -87,6 +88,7 @@ const decodeThreadListResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadL
const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
const decodeThreadSendResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadSendResult);
const decodeThreadWaitResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadWaitResult);
const decodeThreadUpdateResult = Schema.decodeUnknownEffect(ThreadMetadataMcpUpdateResult);

const codexSelection = {
instanceId: codexInstanceId,
Expand Down Expand Up @@ -626,13 +628,19 @@ describe("orchestrator MCP toolkit", () => {
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};
const invoke = (name: string, args: Record<string, unknown>) =>
const invokeAs = (
callScope: McpInvocationContext.McpInvocationScope,
name: string,
args: Record<string, unknown>,
) =>
server
.callTool({ name, arguments: args })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpInvocationContext.McpInvocationContext, callScope),
Effect.provideService(McpSchema.McpServerClient, client),
);
const invoke = (name: string, args: Record<string, unknown>) =>
invokeAs(invocation, name, args);

if (parentRun === undefined || parentRun.rootNodeId === null) {
return yield* Effect.die(new Error("Parent run missing."));
Expand Down Expand Up @@ -1144,6 +1152,28 @@ describe("orchestrator MCP toolkit", () => {
expect(threadListTool?.tool.annotations?.idempotentHint).toBe(true);
const threadReadTool = server.tools.find(({ tool }) => tool.name === "t3_thread_read");
expect(threadReadTool?.tool.annotations?.readOnlyHint).toBe(false);
const threadUpdateTool = server.tools.find(
({ tool }) => tool.name === "t3_thread_update",
);
expect(threadUpdateTool?.tool.annotations?.destructiveHint).toBe(true);
expect(threadUpdateTool?.tool.annotations?.idempotentHint).toBe(false);
expect(threadUpdateTool?.tool.inputSchema).toMatchObject({
type: "object",
properties: {
action: expect.any(Object),
title: expect.any(Object),
pullRequest: expect.any(Object),
},
});
const deniedThreadUpdate = yield* invokeAs(
{ ...invocation, capabilities: new Set() },
"t3_thread_update",
{ action: "rename", title: "Denied title" },
);
expect(deniedThreadUpdate.structuredContent).toMatchObject({
_tag: "OrchestratorMcpFailure",
code: "capability_denied",
});
const threadSendTool = server.tools.find(({ tool }) => tool.name === "t3_thread_send");
expect(threadSendTool?.tool.annotations?.destructiveHint).toBe(true);
const threadWaitTool = server.tools.find(({ tool }) => tool.name === "t3_thread_wait");
Expand Down Expand Up @@ -1609,6 +1639,111 @@ describe("orchestrator MCP toolkit", () => {
});
expect(emptyProjection.thread.forkedFrom).toBeNull();
expect(emptyProjection.runs).toEqual([]);

const defaultRenameCall = yield* invoke("t3_thread_update", {
action: "rename",
title: "MCP parent metadata",
clientRequestId: "metadata-default-thread-1",
});
const defaultRenamed = yield* decodeThreadUpdateResult(
defaultRenameCall.structuredContent,
).pipe(Effect.orDie);
expect(defaultRenamed).toMatchObject({
threadId: parentThreadId,
action: "rename",
title: "MCP parent metadata",
});

const renameCall = yield* invoke("t3_thread_update", {
threadId: emptyThread.threadId,
action: "rename",
title: "Metadata-managed thread",
clientRequestId: "metadata-rename-1",
});
const renamed = yield* decodeThreadUpdateResult(renameCall.structuredContent).pipe(
Effect.orDie,
);
expect(renamed).toMatchObject({
threadId: emptyThread.threadId,
action: "rename",
title: "Metadata-managed thread",
linkedPullRequest: null,
});
const repeatedRenameCall = yield* invoke("t3_thread_update", {
threadId: emptyThread.threadId,
action: "rename",
title: "Metadata-managed thread",
clientRequestId: "metadata-rename-1",
});
const repeatedRename = yield* decodeThreadUpdateResult(
repeatedRenameCall.structuredContent,
).pipe(Effect.orDie);
expect(repeatedRename.commandId).toBe(renamed.commandId);
expect(repeatedRename.sequence).toBe(renamed.sequence);

const linkedCall = yield* invoke("t3_thread_update", {
threadId: emptyThread.threadId,
action: "link_pull_request",
pullRequest: {
repository: "pingdotgg/t3code",
number: 8689,
url: "https://github.com/pingdotgg/t3code/pull/8689",
},
clientRequestId: "metadata-link-1",
});
const linked = yield* decodeThreadUpdateResult(linkedCall.structuredContent).pipe(
Effect.orDie,
);
expect(linked.linkedPullRequest).toEqual({
projectId,
repository: "pingdotgg/t3code",
number: 8689,
url: "https://github.com/pingdotgg/t3code/pull/8689",
});
const metadataReadCall = yield* invoke("t3_thread_read", {
threadId: emptyThread.threadId,
});
const metadataRead = yield* decodeThreadReadResult(
metadataReadCall.structuredContent,
).pipe(Effect.orDie);
expect(metadataRead.thread).toMatchObject({
title: "Metadata-managed thread",
linkedPullRequest: linked.linkedPullRequest,
});
const metadataListCall = yield* invoke("t3_thread_list", { limit: 100 });
const metadataList = yield* decodeThreadListResult(
metadataListCall.structuredContent,
).pipe(Effect.orDie);
expect(
metadataList.threads.find((thread) => thread.threadId === emptyThread.threadId),
).toMatchObject({
title: "Metadata-managed thread",
linkedPullRequest: linked.linkedPullRequest,
});

const unlinkedCall = yield* invoke("t3_thread_update", {
threadId: emptyThread.threadId,
action: "unlink_pull_request",
clientRequestId: "metadata-unlink-1",
});
const unlinked = yield* decodeThreadUpdateResult(unlinkedCall.structuredContent).pipe(
Effect.orDie,
);
expect(unlinked.linkedPullRequest).toBeNull();

const regenerateCall = yield* invoke("t3_thread_update", {
threadId: emptyThread.threadId,
action: "regenerate_title",
clientRequestId: "metadata-regenerate-title-1",
});
const regenerating = yield* decodeThreadUpdateResult(
regenerateCall.structuredContent,
).pipe(Effect.orDie);
expect(regenerating).toMatchObject({
threadId: emptyThread.threadId,
action: "regenerate_title",
titleRegeneration: { requestId: regenerating.commandId },
});
const promptedProjection = yield* waitForProjection(
orchestrator,
promptedThread.threadId,
Expand Down Expand Up @@ -1879,6 +2014,15 @@ describe("orchestrator MCP toolkit", () => {
_tag: "OrchestratorMcpFailure",
code: "thread_not_found",
});
const foreignUpdateCall = yield* invoke("t3_thread_update", {
threadId: foreignThreadId,
action: "rename",
title: "Should stay foreign",
});
expect(foreignUpdateCall.structuredContent).toMatchObject({
_tag: "OrchestratorMcpFailure",
code: "thread_not_found",
});
const listCall = yield* invoke("t3_thread_list", {
includeSubagents: false,
limit: 100,
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/mcp/ThreadMetadataMcpService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { expect, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";
import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts";

const threadId = ThreadId.make("thread:metadata-caller");
const scope: McpInvocationContext.McpInvocationScope = {
environmentId: EnvironmentId.make("environment:metadata-test"),
threadId,
providerSessionId: "provider-session:metadata-test",
providerInstanceId: ProviderInstanceId.make("codex"),
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};

function serviceLayer(
getThreadShell: ThreadManagement.ThreadManagementService["Service"]["getThreadShell"],
) {
return ThreadMetadataMcp.layer.pipe(
Layer.provide(
Layer.merge(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell,
getThreadProjection: () => Effect.die("projection must not load after shell failure"),
} satisfies Partial<ThreadManagement.ThreadManagementService["Service"]>),
NodeCrypto.layer,
),
),
);
}

const updateCallingThread = Effect.gen(function* () {
const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService;
return yield* service.update(scope, {
action: "rename",
title: "Renamed thread",
clientRequestId: "metadata-caller-classification",
});
});

it.effect("reports an absent calling thread as thread_not_found", () =>
Effect.gen(function* () {
const error = yield* updateCallingThread.pipe(
Effect.provide(serviceLayer(() => Effect.succeed(null))),
Effect.flip,
);

expect(error.code).toBe("thread_not_found");
}),
);

it.effect("keeps calling-thread storage failures as orchestration errors", () =>
Effect.gen(function* () {
const error = yield* updateCallingThread.pipe(
Effect.provide(
serviceLayer(() =>
Effect.fail(
new OrchestratorProjectionError({
threadId,
cause: new Error("storage unavailable"),
}),
),
),
),
Effect.flip,
);

expect(error.code).toBe("orchestration_error");
}),
);
Loading
Loading