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
106 changes: 106 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,112 @@ it("bounds public command rejections and redacts internal dispatch causes", () =
});
});

it.effect.each([
{
name: "reports an archived thread",
error: new ThreadManagement.ThreadManagementThreadArchivedError({ threadId }),
code: "thread_not_sendable",
message: `Thread ${threadId} is archived and cannot receive messages.`,
},
{
name: "reports a disabled provider instance",
error: new ThreadManagement.ThreadManagementProviderInstanceDisabledError({
projectId: McpToolAccessTestkit.liveThreadShell(threadId).projectId,
instanceId: ProviderInstanceId.make("codex"),
}),
code: "invalid_request",
message: `Provider instance "codex" is disabled for project ${McpToolAccessTestkit.liveThreadShell(threadId).projectId}.`,
},
{
name: "reports a turn that cannot be restarted",
error: new ThreadManagement.ThreadManagementNoSteerableRunError({
threadId,
mode: "restart",
}),
code: "thread_not_sendable",
message: `Thread ${threadId} has no running turn that can be restarted.`,
},
{
name: "preserves a public dispatch rejection",
error: new OrchestratorDispatchError({
commandId: CommandId.make("mcp-core-configure"),
commandType: "session.model.set",
cause: "The target provider instance is unavailable.",
}),
code: "orchestration_error",
message: "The target provider instance is unavailable.",
},
{
name: "preserves a public command rejection",
error: new OrchestratorCommandRejectedError({
commandId: CommandId.make("mcp-core-configure"),
commandType: "session.model.set",
cause: "The model cannot be changed while the thread is archived.",
}),
code: "orchestration_error",
message: "The model cannot be changed while the thread is archived.",
},
{
name: "redacts a wrapped dispatch cause",
error: new OrchestratorDispatchError({
commandId: CommandId.make("mcp-core-configure"),
commandType: "session.model.set",
cause: new Error("private-storage-path"),
}),
code: "orchestration_error",
message: "The operation could not be completed.",
},
{
name: "redacts a service storage failure",
error: new ThreadManagement.ThreadManagementProjectionLoadError({
projectId: McpToolAccessTestkit.liveThreadShell(threadId).projectId,
threadId,
cause: "private-storage-path",
}),
code: "orchestration_error",
message: "The operation could not be completed.",
},
])("thread configuration $name", ({ error, code, message }) =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const result = yield* server
.callTool({
name: "t3_thread_configure",
arguments: { modelSelection: { instanceId: "codex", model: "gpt-5" } },
})
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(declaredFailure(result)).toEqual({
_tag: "OrchestratorMcpFailure",
code,
message,
});
expect(result.structuredContent).toBeUndefined();
expect(JSON.stringify(result)).not.toContain("private-storage-path");
}).pipe(
Effect.provide(
layerThreadToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(NodeCrypto.layer),
Layer.provide(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
getProjectThreadRecords: ({ threadId }) =>
Effect.succeed(
McpToolAccessTestkit.idleThreadProjection(
McpToolAccessTestkit.liveThreadShell(threadId),
),
),
configureModelSelection: () => Effect.fail(error),
}),
),
),
),
),
);

it.effect("returns an HTML render reference that Codex and Claude tool rows both carry", () =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
Expand Down
27 changes: 22 additions & 5 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
type OrchestrationV2Command,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { modelSelectionCommandType } from "@t3tools/shared/model";

import * as McpToolAccess from "../../McpToolAccess.ts";
import {
Expand Down Expand Up @@ -188,15 +187,33 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
threads,
projection: { thread },
} = yield* readThread(input.threadId);
const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);
const result = yield* threads
.dispatch({
type,
.configureModelSelection({
projectId: thread.projectId,
threadId: thread.id,
commandId: yield* newCommandId(),
modelSelection: input.modelSelection,
createdBy: "agent",
creationSource: "mcp",
})
.pipe(Effect.mapError(dispatchFailure));
.pipe(
Effect.mapError((error) => {
switch (error._tag) {
case "ThreadManagementThreadArchivedError":
case "ThreadManagementNoSteerableRunError":
return new OrchestratorMcpFailure({
code: "thread_not_sendable",
message: error.message,
});
case "OrchestratorDispatchError":
case "OrchestratorCommandRejectedError":
case "ThreadManagementProviderInstanceDisabledError":
return dispatchFailure(error);
default:
return unavailable();
}
}),
);
return { sequence: result.sequence };
}),
),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {
const ThreadConfigureTool = Tool.make("t3_thread_configure", {
...commandTool,
description:
"Set a thread's provider, model and options with the existing selection command. Omit threadId for this thread. This does not change permission modes. Use orchestrator_capabilities to choose a selection.",
"Set a thread's provider, model and options. Omit threadId for this thread. A changed selection restarts a running turn and automatically continues its unfinished user request; an interrupted calling turn may not receive a tool result. An already-active selection is a no-op. Providers that cannot restart reject the change without interrupting the turn. This does not change permission modes. Use orchestrator_capabilities to choose a selection.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
modelSelection: ModelSelection,
Expand Down
Loading
Loading