(value: A | undefined, name: string) =>
+ value === undefined
+ ? Effect.fail(
+ new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: `This action needs \`${name}\`.`,
+ }),
+ )
+ : Effect.succeed(value);
const writesThread = (
handle: (params: P) => Effect.Effect,
) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);
@@ -117,181 +127,198 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
};
}),
),
- t3_thread_fork: writesThread((input) =>
- Effect.gen(function* () {
- const { threads, projection } = yield* readThread(input.threadId);
- const commandId = yield* newCommandId();
- const targetThreadId = ThreadId.make(`${commandId}:fork`);
- const result = yield* threads
- .dispatch({
- type: "thread.fork",
- commandId,
- sourceThreadId: projection.thread.id,
- targetThreadId,
- sourcePoint: input.sourcePoint,
- ...(input.title === undefined ? {} : { title: input.title }),
- createdBy: "agent",
- creationSource: "mcp",
- })
- .pipe(Effect.mapError(dispatchFailure));
- return { sequence: result.sequence, targetThreadId };
- }),
+ t3_thread_transfer: McpToolAccess.either(
+ (input) => input.action === "status",
+ McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const { projection } = yield* readThread(input.threadId, ["contextTransfers"]);
+ return {
+ transfers: projection.contextTransfers.map(
+ ({ id, sourceThreadId, targetThreadId, status }) => ({
+ id,
+ sourceThreadId,
+ targetThreadId,
+ status,
+ }),
+ ),
+ };
+ }),
+ ),
+ McpToolAccess.writesThreads(
+ (input) => [input.threadId, input.targetThreadId],
+ (input) =>
+ Effect.gen(function* () {
+ const sourcePoint = input.sourcePoint ?? { type: "latest_stable" as const };
+ const { threads, projection } = yield* readThread(input.threadId);
+ const commandId = yield* newCommandId();
+ if (input.action === "merge_back") {
+ const targetThreadId = yield* required(input.targetThreadId, "targetThreadId");
+ yield* readThread(targetThreadId);
+ const result = yield* threads
+ .dispatch({
+ type: "thread.merge_back",
+ commandId,
+ sourceThreadId: projection.thread.id,
+ targetThreadId,
+ sourcePoint,
+ createdBy: "agent",
+ creationSource: "mcp",
+ })
+ .pipe(Effect.mapError(dispatchFailure));
+ return { sequence: result.sequence, targetThreadId };
+ }
+ const targetThreadId = ThreadId.make(`${commandId}:fork`);
+ const result = yield* threads
+ .dispatch({
+ type: "thread.fork",
+ commandId,
+ sourceThreadId: projection.thread.id,
+ targetThreadId,
+ sourcePoint,
+ ...(input.title === undefined ? {} : { title: input.title }),
+ createdBy: "agent",
+ creationSource: "mcp",
+ })
+ .pipe(Effect.mapError(dispatchFailure));
+ return { sequence: result.sequence, targetThreadId };
+ }),
+ ),
),
- t3_thread_merge_back: McpToolAccess.writesThreads(
- (input) => [input.targetThreadId, input.sourceThreadId],
- (input) =>
+ t3_thread_configure: McpToolAccess.either(
+ (input) => input.modelSelection === undefined,
+ McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const {
+ projection: { thread },
+ } = yield* readThread(input.threadId);
+ return {
+ threadId: thread.id,
+ modelSelection: thread.modelSelection,
+ runtimeMode: thread.runtimeMode,
+ interactionMode: thread.interactionMode,
+ };
+ }),
+ ),
+ writesThread((input) =>
+ Effect.gen(function* () {
+ const modelSelection = yield* required(input.modelSelection, "modelSelection");
+ const {
+ threads,
+ projection: { thread },
+ } = yield* readThread(input.threadId);
+ const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection);
+ const result = yield* threads
+ .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection })
+ .pipe(Effect.mapError(dispatchFailure));
+ return { sequence: result.sequence };
+ }),
+ ),
+ ),
+ t3_pending_request: McpToolAccess.either(
+ (input) => input.action !== "respond",
+ McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ if (input.action === "list") {
+ const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]);
+ return {
+ requestIds: projection.runtimeRequests
+ .filter((request) => request.kind === "user_input" && request.status === "pending")
+ .map((request) => request.id),
+ };
+ }
+ const requestId = yield* required(input.requestId, "requestId");
+ const { item } = yield* readQuestion({ threadId: input.threadId, requestId });
+ return { requestId, questions: item.questions };
+ }),
+ ),
+ writesThread((input) =>
Effect.gen(function* () {
- const context = yield* readThread(input.targetThreadId);
- const source = yield* readThread(input.sourceThreadId);
- const result = yield* context.threads
+ const requestId = yield* required(input.requestId, "requestId");
+ const answers = yield* required(input.answers, "answers");
+ const { threads, projection } = yield* readQuestion({
+ threadId: input.threadId,
+ requestId,
+ });
+ const result = yield* threads
.dispatch({
- type: "thread.merge_back",
+ type: "runtime-request.respond",
+ threadId: projection.thread.id,
commandId: yield* newCommandId(),
- sourceThreadId: source.projection.thread.id,
- targetThreadId: input.targetThreadId,
- sourcePoint: input.sourcePoint,
- createdBy: "agent",
- creationSource: "mcp",
+ requestId,
+ answers,
})
.pipe(Effect.mapError(dispatchFailure));
- return { sequence: result.sequence, targetThreadId: input.targetThreadId };
+ return { sequence: result.sequence };
}),
+ ),
),
- t3_thread_transfers: McpToolAccess.reads((input) =>
- Effect.gen(function* () {
- const { projection } = yield* readThread(input.threadId, ["contextTransfers"]);
- return {
- transfers: projection.contextTransfers.map(
- ({ id, sourceThreadId, targetThreadId, status }) => ({
- id,
- sourceThreadId,
- targetThreadId,
- status,
- }),
- ),
- };
- }),
- ),
- t3_thread_configuration: McpToolAccess.reads((input) =>
- Effect.gen(function* () {
- const {
- projection: { thread },
- } = yield* readThread(input.threadId);
- return {
- threadId: thread.id,
- modelSelection: thread.modelSelection,
- runtimeMode: thread.runtimeMode,
- interactionMode: thread.interactionMode,
- };
- }),
- ),
- t3_thread_configure: writesThread((input) =>
- Effect.gen(function* () {
- const {
- threads,
- projection: { thread },
- } = yield* readThread(input.threadId);
- const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);
- const result = yield* threads
- .dispatch({
- type,
- threadId: thread.id,
- commandId: yield* newCommandId(),
- modelSelection: input.modelSelection,
- })
- .pipe(Effect.mapError(dispatchFailure));
- return { sequence: result.sequence };
- }),
- ),
- t3_pending_request_list: McpToolAccess.reads((input) =>
- Effect.gen(function* () {
- const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]);
- return {
- requestIds: projection.runtimeRequests
- .filter((request) => request.kind === "user_input" && request.status === "pending")
- .map((request) => request.id),
- };
- }),
- ),
- t3_pending_request_read: McpToolAccess.reads((input) =>
- Effect.gen(function* () {
- const { item } = yield* readQuestion(input);
- return { requestId: input.requestId, questions: item.questions };
- }),
- ),
- t3_pending_request_respond: writesThread((input) =>
- Effect.gen(function* () {
- const { threads, projection } = yield* readQuestion(input);
- const result = yield* threads
- .dispatch({
- type: "runtime-request.respond",
- threadId: projection.thread.id,
- commandId: yield* newCommandId(),
- requestId: input.requestId,
- answers: input.answers,
- })
- .pipe(Effect.mapError(dispatchFailure));
- return { sequence: result.sequence };
- }),
- ),
- t3_queue_list: McpToolAccess.reads((input) =>
- Effect.gen(function* () {
- const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
- const runs = queuedRunsInDeliveryOrder(projection);
- const cursor = input.cursor ?? 0;
- const end = cursor + (input.limit ?? 20);
- return {
- items: runs.slice(cursor, end).flatMap((run) => {
- const entry = queueEntry(projection, run.id, 1000);
- return entry === undefined ? [] : [entry];
- }),
- nextCursor: end < runs.length ? end : null,
- };
- }),
- ),
- t3_queue_read: McpToolAccess.reads((input) =>
- Effect.gen(function* () {
- const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
- const entry = queueEntry(projection, input.queuedRunId, 16000);
- return (
- entry ??
- (yield* new OrchestratorMcpFailure({
- code: "invalid_request",
- message: "The queued message was not found.",
- }))
- );
- }),
- ),
- t3_queue_edit: writesThread((input) =>
- dispatch(input.threadId, (common) => ({
- ...common,
- type: "queued-run.edit",
- runId: input.queuedRunId,
- text: input.text,
- })),
- ),
- t3_queue_cancel: writesThread((input) =>
- dispatch(input.threadId, (common) => ({
- ...common,
- type: "queued-run.cancel",
- runId: input.queuedRunId,
- })),
- ),
- t3_queue_reorder: writesThread((input) =>
- dispatch(input.threadId, (common) => ({
- ...common,
- type: "queued-run.reorder",
- runId: input.queuedRunId,
- beforeRunId: input.beforeRunId,
- })),
- ),
- t3_queue_promote_to_steer: writesThread((input) =>
- dispatch(input.threadId, (common) => ({
- ...common,
- type: "queued-message.promote-to-steer",
- queuedRunId: input.queuedRunId,
- targetRunId: input.targetRunId,
- })),
+ t3_queue: McpToolAccess.either(
+ (input) => input.action === "list" || input.action === "read",
+ McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
+ if (input.action === "list") {
+ const runs = queuedRunsInDeliveryOrder(projection);
+ const cursor = input.cursor ?? 0;
+ const end = cursor + (input.limit ?? 20);
+ return {
+ items: runs.slice(cursor, end).flatMap((run) => {
+ const entry = queueEntry(projection, run.id, 1000);
+ return entry === undefined ? [] : [entry];
+ }),
+ nextCursor: end < runs.length ? end : null,
+ };
+ }
+ const queuedRunId = yield* required(input.queuedRunId, "queuedRunId");
+ return (
+ queueEntry(projection, queuedRunId, 16000) ??
+ (yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: "The queued message was not found.",
+ }))
+ );
+ }),
+ ),
+ writesThread((input) =>
+ Effect.gen(function* () {
+ const runId = yield* required(input.queuedRunId, "queuedRunId");
+ switch (input.action) {
+ case "edit": {
+ const text = yield* required(input.text, "text");
+ return yield* dispatch(input.threadId, (common) => ({
+ ...common,
+ type: "queued-run.edit",
+ runId,
+ text,
+ }));
+ }
+ case "reorder": {
+ const beforeRunId = yield* required(input.beforeRunId, "beforeRunId");
+ return yield* dispatch(input.threadId, (common) => ({
+ ...common,
+ type: "queued-run.reorder",
+ runId,
+ beforeRunId,
+ }));
+ }
+ case "promote_to_steer": {
+ const targetRunId = yield* required(input.targetRunId, "targetRunId");
+ return yield* dispatch(input.threadId, (common) => ({
+ ...common,
+ type: "queued-message.promote-to-steer",
+ queuedRunId: runId,
+ targetRunId,
+ }));
+ }
+ default:
+ return yield* dispatch(input.threadId, (common) => ({
+ ...common,
+ type: "queued-run.cancel",
+ runId,
+ }));
+ }
+ }),
+ ),
),
t3_thread_organize: writesThread((input) =>
Effect.gen(function* () {
diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts
index e7c4f56c30f24..1e73f180bdf0f 100644
--- a/apps/server/src/mcp/toolkits/thread/tools.ts
+++ b/apps/server/src/mcp/toolkits/thread/tools.ts
@@ -61,7 +61,6 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
.annotate(Tool.Title, "Organize a thread")
.annotate(Tool.Destructive, true);
-const queueTarget = { threadId: Schema.optional(ThreadId), queuedRunId: RunId };
const commandTool = {
success: OrchestrationV2DispatchCommandResult,
failure: OrchestratorMcpFailure,
@@ -77,55 +76,34 @@ const queueEntry = Schema.Struct({
text: Schema.String,
truncated: Schema.Boolean,
});
-const QueueListTool = Tool.make("t3_queue_list", {
+const QUEUE_READ_ACTIONS = ["list", "read"] as const;
+const QueueTool = Tool.make("t3_queue", {
...commandTool,
- description: "List queued messages in send order. For full history, use `t3_thread_read`.",
+ description:
+ "Manage a thread's queued messages. Omit `threadId` for this thread. list: send order, paged by `cursor` and `limit`. read: up to 16,000 chars of one. edit: replace its `text`; attachments stay. cancel. reorder: before `beforeRunId`, or to the end with null. promote_to_steer: send it now as steering to active `targetRunId`. Writes fail once the run is no longer queued.",
parameters: Schema.Struct({
+ action: Schema.Literals([
+ ...QUEUE_READ_ACTIONS,
+ "edit",
+ "cancel",
+ "reorder",
+ "promote_to_steer",
+ ]),
threadId: Schema.optional(ThreadId),
+ queuedRunId: Schema.optional(RunId),
+ text: Schema.optional(Schema.String.check(Schema.isMaxLength(100000))),
+ beforeRunId: Schema.optional(Schema.NullOr(RunId)),
+ targetRunId: Schema.optional(RunId),
cursor: Schema.optional(NonNegativeInt),
limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))),
}),
- success: Schema.Struct({
- items: Schema.Array(queueEntry),
- nextCursor: Schema.NullOr(NonNegativeInt),
- }),
-})
- .annotate(Tool.Readonly, true)
- .annotate(Tool.Destructive, false);
-const QueueReadTool = Tool.make("t3_queue_read", {
- ...commandTool,
- description: "Read up to 16,000 chars of one queued message. Omit `threadId` for this thread.",
- parameters: Schema.Struct(queueTarget),
- success: queueEntry,
-})
- .annotate(Tool.Readonly, true)
- .annotate(Tool.Destructive, false);
-const QueueEditTool = Tool.make("t3_queue_edit", {
- ...commandTool,
- description:
- "Replace one queued message's text. Attachments stay. Fails if the run is no longer queued.",
- parameters: Schema.Struct({
- ...queueTarget,
- text: Schema.String.check(Schema.isMaxLength(100000)),
- }),
-}).annotate(Tool.Destructive, true);
-const QueueCancelTool = Tool.make("t3_queue_cancel", {
- ...commandTool,
- description: "Cancel one queued run.",
- parameters: Schema.Struct(queueTarget),
-}).annotate(Tool.Destructive, true);
-const QueueReorderTool = Tool.make("t3_queue_reorder", {
- ...commandTool,
- description: "Move one queued run before another, or to the end with `beforeRunId=null`.",
- parameters: Schema.Struct({ ...queueTarget, beforeRunId: Schema.NullOr(RunId) }),
-}).annotate(Tool.Destructive, true);
-const QueuePromoteTool = Tool.make("t3_queue_promote_to_steer", {
- ...commandTool,
- description: "Send one queued message now as steering to the given active run.",
- parameters: Schema.Struct({ ...queueTarget, targetRunId: RunId }),
+ success: Schema.Union([
+ Schema.Struct({ items: Schema.Array(queueEntry), nextCursor: Schema.NullOr(NonNegativeInt) }),
+ queueEntry,
+ OrchestrationV2DispatchCommandResult,
+ ]),
}).annotate(Tool.Destructive, true);
-const requestTarget = { threadId: Schema.optional(ThreadId), requestId: RuntimeRequestId };
const question = Schema.Struct({
id: Schema.String,
header: Schema.String,
@@ -142,98 +120,67 @@ const question = Schema.Struct({
initialAnswer: Schema.optional(Schema.String),
required: Schema.optional(Schema.Boolean),
});
-const pendingRequest = Schema.Struct({
- requestId: RuntimeRequestId,
- questions: Schema.Array(question),
-});
-const PendingRequestListTool = Tool.make("t3_pending_request_list", {
+const PendingRequestTool = Tool.make("t3_pending_request", {
...commandTool,
description:
- "List pending user questions in a thread. Omit `threadId` for this thread. Approvals are not listed.",
- parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
- success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }),
-})
- .annotate(Tool.Readonly, true)
- .annotate(Tool.Destructive, false);
-const PendingRequestReadTool = Tool.make("t3_pending_request_read", {
- ...commandTool,
- description: "Read one pending user question. Answer it with `t3_pending_request_respond`.",
- parameters: Schema.Struct(requestTarget),
- success: pendingRequest,
-})
- .annotate(Tool.Readonly, true)
- .annotate(Tool.Destructive, false);
-const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {
- ...commandTool,
- description: "Answer one pending user question. This cannot approve a permission request.",
- parameters: Schema.Struct({ ...requestTarget, answers: ProviderUserInputAnswers }),
+ "Pending user questions in a thread. Omit `threadId` for this thread. list: their ids; approvals are not listed. read: one by `requestId`. respond: answer one with `answers`; this cannot approve a permission request.",
+ parameters: Schema.Struct({
+ action: Schema.Literals(["list", "read", "respond"]),
+ threadId: Schema.optional(ThreadId),
+ requestId: Schema.optional(RuntimeRequestId),
+ answers: Schema.optional(ProviderUserInputAnswers),
+ }),
+ success: Schema.Union([
+ Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }),
+ Schema.Struct({ requestId: RuntimeRequestId, questions: Schema.Array(question) }),
+ OrchestrationV2DispatchCommandResult,
+ ]),
})
.annotate(Tool.Destructive, true)
.annotate(Tool.OpenWorld, true);
-const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {
- ...commandTool,
- description: "Read a thread's provider, model, and modes. Omit `threadId` for this thread.",
- parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
- success: Schema.Struct({
- threadId: ThreadId,
- modelSelection: ModelSelection,
- runtimeMode: RuntimeMode,
- interactionMode: ProviderInteractionMode,
- }),
-})
- .annotate(Tool.Readonly, true)
- .annotate(Tool.Destructive, false);
+const threadConfiguration = Schema.Struct({
+ threadId: ThreadId,
+ modelSelection: ModelSelection,
+ runtimeMode: RuntimeMode,
+ interactionMode: ProviderInteractionMode,
+});
const ThreadConfigureTool = Tool.make("t3_thread_configure", {
...commandTool,
description:
- "Set a thread's provider, model, and options. Omit `threadId` for this thread. Permission modes stay. Values from `orchestrator_capabilities`.",
+ "Read a thread's provider, model, and modes, or set its provider, model, and options with `modelSelection`. Omit `threadId` for this thread. Permission modes stay. Values from `orchestrator_capabilities`.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
- modelSelection: ModelSelection,
+ modelSelection: Schema.optional(ModelSelection),
}),
+ success: Schema.Union([threadConfiguration, OrchestrationV2DispatchCommandResult]),
}).annotate(Tool.Destructive, true);
-const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId });
-const ThreadForkTool = Tool.make("t3_thread_fork", {
+const ThreadTransferTool = Tool.make("t3_thread_transfer", {
...commandTool,
description:
- "Fork a thread from a stable run or checkpoint. Omit `threadId` for this thread. Keeps source setup. No turn has run yet.",
+ "Move context between threads. Omit `threadId` for this thread. fork: copy it from `sourcePoint` (default latest stable run) into a new thread with the same setup; no turn runs yet. merge_back: merge it from `sourcePoint` into a related `targetThreadId` in the same project. status: its context transfers.",
parameters: Schema.Struct({
+ action: Schema.Literals(["fork", "merge_back", "status"]),
threadId: Schema.optional(ThreadId),
- sourcePoint: OrchestrationV2ThreadForkSourcePoint,
+ sourcePoint: Schema.optional(OrchestrationV2ThreadForkSourcePoint),
+ targetThreadId: Schema.optional(ThreadId),
title: Schema.optional(TrimmedNonEmptyString),
}),
- success: transferResult,
-}).annotate(Tool.Destructive, true);
-const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {
- ...commandTool,
- description:
- "Merge context from one thread into a related thread in the same project. Omit `sourceThreadId` for this thread.",
- parameters: Schema.Struct({
- sourceThreadId: Schema.optional(ThreadId),
- targetThreadId: ThreadId,
- sourcePoint: OrchestrationV2ThreadForkSourcePoint,
- }),
- success: transferResult,
+ success: Schema.Union([
+ Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }),
+ Schema.Struct({
+ transfers: Schema.Array(
+ Schema.Struct({
+ id: OrchestrationV2ContextTransfer.fields.id,
+ sourceThreadId: OrchestrationV2ContextTransfer.fields.sourceThreadId,
+ targetThreadId: OrchestrationV2ContextTransfer.fields.targetThreadId,
+ status: OrchestrationV2ContextTransfer.fields.status,
+ }),
+ ),
+ }),
+ ]),
}).annotate(Tool.Destructive, true);
-const ThreadTransfersTool = Tool.make("t3_thread_transfers", {
- ...commandTool,
- description: "Read context transfer status for a thread. Omit `threadId` for this thread.",
- parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
- success: Schema.Struct({
- transfers: Schema.Array(
- Schema.Struct({
- id: OrchestrationV2ContextTransfer.fields.id,
- sourceThreadId: OrchestrationV2ContextTransfer.fields.sourceThreadId,
- targetThreadId: OrchestrationV2ContextTransfer.fields.targetThreadId,
- status: OrchestrationV2ContextTransfer.fields.status,
- }),
- ),
- }),
-})
- .annotate(Tool.Readonly, true)
- .annotate(Tool.Destructive, false);
const ThreadSearchTool = Tool.make("t3_thread_search", {
...commandTool,
@@ -281,19 +228,9 @@ const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", {
export const ThreadToolkit = Toolkit.make(
ScheduledTaskRunTool,
ThreadSearchTool,
- ThreadForkTool,
- ThreadMergeBackTool,
- ThreadTransfersTool,
- ThreadConfigurationTool,
ThreadConfigureTool,
- PendingRequestListTool,
- PendingRequestReadTool,
- PendingRequestRespondTool,
+ ThreadTransferTool,
ThreadOrganizeTool,
- QueueListTool,
- QueueReadTool,
- QueueEditTool,
- QueueCancelTool,
- QueueReorderTool,
- QueuePromoteTool,
+ PendingRequestTool,
+ QueueTool,
);
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index b893b26b5d948..b3ab6b4448529 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -1527,10 +1527,6 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [
"mcp__t3-code__list_scheduled_tasks",
"mcp__t3-code__t3_thread_list",
"mcp__t3-code__t3_thread_wait",
- "mcp__t3-code__t3_pending_request_list",
- "mcp__t3-code__t3_pending_request_read",
- "mcp__t3-code__t3_thread_configuration",
- "mcp__t3-code__t3_thread_transfers",
"mcp__t3-code__t3_worktree_status",
"mcp__t3-code__t3_worktree_list",
"mcp__t3-code__t3_project_list",
@@ -1538,8 +1534,6 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [
"mcp__t3-code__t3_thread_search",
"mcp__t3-code__t3_preview_list",
"mcp__t3-code__t3_environment_read",
- "mcp__t3-code__t3_queue_list",
- "mcp__t3-code__t3_queue_read",
"mcp__t3-code__html_preview",
"mcp__t3-code__html_render",
];
diff --git a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts
index 50ae6c74678fa..f6eeb9a00ede3 100644
--- a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts
+++ b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts
@@ -45,7 +45,7 @@ const make = Effect.gen(function* () {
commandId: CommandId.make(`server:delegated-task-question:${key}`),
threadId: task.threadId,
messageId: MessageId.make(`message:delegated-task-question:${key}`),
- text: `${situation} Read it with t3_pending_request_read using threadId ${task.childThreadId} and requestId ${request.id}. Answer it with t3_pending_request_respond when the answer follows from the task or the user's instructions; otherwise ask the user, then answer it. If it is no longer pending, it was answered or dismissed.`,
+ text: `${situation} Read it with t3_pending_request action read, threadId ${task.childThreadId}, and requestId ${request.id}. Answer it with action respond when the answer follows from the task or the user's instructions; otherwise ask the user, then answer it. If it is no longer pending, it was answered or dismissed.`,
notification: {
source: { kind: "delegated_task", taskIds: [task.id], childThreadId: task.childThreadId },
outcome: "updated",
diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md
index b3a716a6d29c8..81dd0d10e0b2b 100644
--- a/docs/internals/coder-only.md
+++ b/docs/internals/coder-only.md
@@ -583,6 +583,10 @@ listed here is drift to remove rather than fork behavior to keep.
- `mcp/bridge/T3ToolBridge.ts` runs upstream's toolkit handlers by name with the credential's
`McpInvocationContext`, enforces read-only tools in plan mode, and turns calls that outlive
its inline budget into `bridge-job:` tasks. `coderServer.ts` binds it once the orchestrator runs.
+ `--schema` prints upstream's compact `advertisedJsonSchema`, as its MCP server advertises
+ (#25790). Plan mode follows the tool annotation: upstream's action tools (#25423, such as
+ `t3_queue` and `t3_pending_request`) are not read-only, so their read actions are refused there,
+ as upstream's Claude plan-mode allowlist dropped them.
- `toolkits/environment/` reads the `CoderEnvironment` descriptor in place of upstream's
`ServerEnvironment`.
- provider-core's `server/t3ToolBridgeInstructions.ts` reuses upstream's orchestration
diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md
index dc4af856b8c30..e41f42c8248a3 100644
--- a/docs/orchestration-v2/orchestrator-mcp-server.md
+++ b/docs/orchestration-v2/orchestrator-mcp-server.md
@@ -270,9 +270,8 @@ provider session. The request becomes the V2 command
the timeout expires. A wait timeout does not cancel the child; the result sets
`waitTimedOut: true`, and the caller can continue with `task_status`. A pending blocking
question also ends the wait with `hasPendingQuestion: true` and `waitTimedOut: false`.
-If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request_list`,
-`t3_pending_request_read` (with `requestId`) and `t3_pending_request_respond`, or ask
-the user; the task's completion still wakes this thread. A pending live approval for the
+If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request` with
+action `list`, `read` (with `requestId`), or `respond`, or ask the user; the task's completion still wakes this thread. A pending live approval for the
task's own unfinished work also ends the wait. Its identity appears in `pendingApproval`;
it grants no approval authority. Read the fresh full request in the child thread before
responding, since the durable record can outlive its provider callback. Async questions
diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts
index 4a122ef8c9511..1d6d0fd8c7bc0 100644
--- a/packages/client-runtime/src/work-log/presentation.ts
+++ b/packages/client-runtime/src/work-log/presentation.ts
@@ -10,6 +10,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import {
+ resolveT3McpSummaryAction,
resolveT3McpToolDefinition,
type T3McpToolDefinition,
type T3McpToolSummaryAction,
@@ -160,6 +161,16 @@ function workEntryToolName(
return resolveT3McpToolDefinition(entry.toolTitle) ? entry.toolTitle : entry.label;
}
+function workEntryToolInput(
+ entry: Pick,
+): unknown {
+ const item = entry.structuredPayload;
+ const data = asRecord(entry.toolData);
+ return item?.type === "dynamic_tool"
+ ? item.input
+ : (data?.arguments ?? data?.input ?? data?.rawInput);
+}
+
function workEntryToolOutput(
entry: Pick,
): unknown {
@@ -237,7 +248,10 @@ export function resolveWorkEntryToolPresentation(
>,
fallbackStatus?: "inProgress" | "completed",
) {
- const definition = resolveT3McpToolDefinition(workEntryToolName(entry));
+ const definition = resolveT3McpToolDefinition(
+ workEntryToolName(entry),
+ workEntryToolInput(entry),
+ );
const status = entry.toolLifecycleStatus ?? fallbackStatus;
return resolveT3McpToolPresentation(
definition,
@@ -472,16 +486,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
}
function t3ToolSummaryCall(entry: WorkLogPresentationEntry): T3ToolSummaryCall {
- const item = entry.structuredPayload;
- const data =
- entry.toolData !== null && typeof entry.toolData === "object"
- ? (entry.toolData as Record)
- : undefined;
return {
- input:
- item?.type === "dynamic_tool"
- ? item.input
- : (data?.arguments ?? data?.input ?? data?.rawInput),
+ input: workEntryToolInput(entry),
output: workEntryToolOutput(entry),
outcome:
entry.toolLifecycleStatus === "failed" ||
@@ -559,7 +565,10 @@ export function summarizeToolGroup(entries: ReadonlyArray();
const sources = new Map();
for (const entry of entries) {
- const t3Action = resolveT3McpToolDefinition(workEntryToolName(entry))?.summaryAction ?? null;
+ const t3Action = resolveT3McpSummaryAction(
+ workEntryToolName(entry),
+ t3ToolSummaryCall(entry).input,
+ );
if (entry.toolSource && t3Action === null) {
sources.set(entry.toolSource.key, entry.toolSource);
continue;
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index df65ee6ca320e..bb2820ad44282 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -200,7 +200,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({
resultContextTransferId: Schema.NullOr(ContextTransferId),
hasPendingQuestion: Schema.Boolean.annotate({
description:
- "A question blocks the child and ends a wait. Answer via `t3_pending_request_*` on `childThreadId`, or the user does.",
+ "A question blocks the child and ends a wait. Answer via `t3_pending_request` on `childThreadId`, or the user does.",
}),
pendingApproval: Schema.NullOr(
Schema.Struct({ requestId: RuntimeRequestId, kind: ProviderRequestKind, runId: RunId }),
@@ -582,6 +582,16 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({
});
export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type;
+/** `schedule_task` creates a task, or updates one when `scheduledTaskId` is given. */
+export const OrchestratorMcpSaveScheduledTaskInput = Schema.Struct({
+ ...OrchestratorMcpScheduleTaskInput.fields,
+ scheduledTaskId: Schema.optional(ScheduledTaskId),
+ prompt: Schema.optional(OrchestratorMcpPrompt),
+ schedule: Schema.optional(OrchestratorMcpSchedule),
+});
+export type OrchestratorMcpSaveScheduledTaskInput =
+ typeof OrchestratorMcpSaveScheduledTaskInput.Type;
+
export const OrchestratorMcpScheduledTask = Schema.Struct({
scheduledTaskId: ScheduledTaskId,
title: Schema.String,
diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts
index abaa03512d1ce..5862b643e00ae 100644
--- a/packages/contracts/src/previewAutomation.ts
+++ b/packages/contracts/src/previewAutomation.ts
@@ -411,6 +411,52 @@ export const PreviewAutomationScrollInput = Schema.Struct({
);
export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;
+/** Strict input for each `preview_act` action. */
+export const PreviewAutomationActInputs = {
+ click: PreviewAutomationClickInput,
+ type: PreviewAutomationTypeInput,
+ hover: PreviewAutomationHoverInput,
+ select: PreviewAutomationSelectInput,
+ drag: PreviewAutomationDragInput,
+ upload: PreviewAutomationUploadInput,
+ press: PreviewAutomationPressInput,
+ scroll: PreviewAutomationScrollInput,
+} as const;
+export type PreviewAutomationAction = keyof typeof PreviewAutomationActInputs;
+
+/** One flat shape for every page action. The server decodes it with the action's strict input. */
+export const PreviewAutomationActInput = Schema.Struct({
+ action: Schema.Literals([
+ "click",
+ "type",
+ "hover",
+ "select",
+ "drag",
+ "upload",
+ "press",
+ "scroll",
+ ]),
+ ...PreviewAutomationTabTargetFields,
+ locator: Schema.optional(Locator),
+ selector: Schema.optional(LegacySelector),
+ x: Schema.optional(Schema.Finite),
+ y: Schema.optional(Schema.Finite),
+ button: Schema.optional(Schema.Literals(["left", "right", "middle"])),
+ clickCount: Schema.optional(Schema.Int),
+ text: Schema.optional(Schema.String),
+ clear: Schema.optional(Schema.Boolean),
+ values: Schema.optional(Schema.Array(Schema.String)),
+ source: Schema.optional(Locator),
+ target: Schema.optional(Locator),
+ paths: Schema.optional(Schema.Array(Schema.String)),
+ key: Schema.optional(Schema.String),
+ modifiers: PreviewAutomationPressInput.fields.modifiers,
+ deltaX: Schema.optional(Schema.Finite),
+ deltaY: Schema.optional(Schema.Finite),
+ timeoutMs: Schema.optional(Schema.Int),
+});
+export type PreviewAutomationActInput = typeof PreviewAutomationActInput.Type;
+
export const PreviewAutomationEvaluateInput = Schema.Struct({
...PreviewAutomationTabTargetFields,
expression: Schema.String.check(Schema.isTrimmed())
diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts
index 5d8e4d29d68ad..b4dffacd74c04 100644
--- a/packages/contracts/src/scheduledTask.ts
+++ b/packages/contracts/src/scheduledTask.ts
@@ -66,7 +66,7 @@ export const MAX_WEBHOOK_DELIVERY_AGE_MINUTES = 24 * 60;
const WebhookMaxDeliveryAgeMinutes = Schema.Int.check(
Schema.isBetween({ minimum: 1, maximum: MAX_WEBHOOK_DELIVERY_AGE_MINUTES }),
).annotate({
- description: "Skip requests held offline over this many minutes. Null: run all.",
+ description: "Skip requests held offline over this many minutes. Omit to run all.",
});
export function isWebhookDedupeTemplate(template: string): boolean {
@@ -152,7 +152,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({
}),
),
).annotate({
- description: "Null or omitted: URL token only.",
+ description: "Omit for URL token only.",
}),
maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)),
dedupe: Schema.optional(ScheduledTaskWebhookDedupe),
diff --git a/packages/contracts/src/threadMetadataMcp.test.ts b/packages/contracts/src/threadMetadataMcp.test.ts
index 3e99b35aee6f0..884741b762d65 100644
--- a/packages/contracts/src/threadMetadataMcp.test.ts
+++ b/packages/contracts/src/threadMetadataMcp.test.ts
@@ -19,31 +19,4 @@ describe("ThreadMetadataMcpUpdateInput", () => {
decodeUpdate({ action: "regenerate_title", clientRequestId: "metadata-\udc00" }),
);
});
-
- it("accepts HTTP(S) pull request URLs and rejects other or malformed URLs", () => {
- assert.deepEqual(
- decodeUpdate({
- action: "link_pull_request",
- pullRequest: {
- repository: "engineering/t3code",
- number: 42,
- url: "https://git.corp.example/engineering/t3code/pulls/42",
- },
- }).pullRequest,
- {
- repository: "engineering/t3code",
- number: 42,
- url: "https://git.corp.example/engineering/t3code/pulls/42",
- },
- );
-
- for (const url of ["not a URL", "javascript:alert(1)", "file:///tmp/pull-request"]) {
- assert.throws(() =>
- decodeUpdate({
- action: "link_pull_request",
- pullRequest: { repository: "pingdotgg/t3code", number: 8690, url },
- }),
- );
- }
- });
});
diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts
index 7c132e4d45f4b..8c921ae9ba60a 100644
--- a/packages/contracts/src/threadMetadataMcp.ts
+++ b/packages/contracts/src/threadMetadataMcp.ts
@@ -4,7 +4,6 @@ import {
CommandId,
IsoDateTime,
NonNegativeInt,
- PositiveInt,
ThreadId,
TrimmedNonEmptyString,
} from "./baseSchemas.ts";
@@ -27,15 +26,6 @@ function hasOnlyPairedSurrogates(value: string): boolean {
return true;
}
-function isHttpUrl(value: string): boolean {
- try {
- const url = new URL(value);
- return url.protocol === "http:" || url.protocol === "https:";
- } catch {
- return false;
- }
-}
-
const ThreadMetadataTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({
description: "New concise display title. Required only when action is rename.",
});
@@ -50,62 +40,20 @@ const ThreadMetadataClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLe
)
.annotate({ description: "Stable idempotency key to reuse when retrying this mutation." });
-const ThreadMetadataMcpPullRequestUrl = TrimmedNonEmptyString.check(
- Schema.makeFilter((value) =>
- isHttpUrl(value) ? true : "Pull request URL must be a well-formed HTTP(S) URL.",
- ),
-).annotate({
- description: "Canonical HTTP(S) pull request URL, including self-hosted repository URLs.",
-});
-
-export const ThreadMetadataMcpAction = Schema.Literals([
- "rename",
- "regenerate_title",
- "link_pull_request",
- "unlink_pull_request",
-]).annotate({
- description:
- "Metadata mutation: rename, regenerate_title, link_pull_request, or unlink_pull_request.",
-});
+export const ThreadMetadataMcpAction = Schema.Literals(["rename", "regenerate_title"]);
export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type;
-export const ThreadMetadataMcpPullRequest = Schema.Struct({
- repository: TrimmedNonEmptyString.annotate({
- description: "Repository name as owner/name.",
- }),
- number: PositiveInt.annotate({ description: "Pull request number." }),
- url: ThreadMetadataMcpPullRequestUrl,
-});
-export type ThreadMetadataMcpPullRequest = typeof ThreadMetadataMcpPullRequest.Type;
-
export const ThreadMetadataMcpUpdateInput = Schema.Struct({
- threadId: Schema.optional(ThreadId).annotate({
- description: "Thread to update. Omit to update the calling thread.",
- }),
+ threadId: Schema.optional(ThreadId),
action: ThreadMetadataMcpAction,
title: Schema.optional(ThreadMetadataTitle),
- pullRequest: Schema.optional(ThreadMetadataMcpPullRequest).annotate({
- description: "Pull request to link. Required only when action is link_pull_request.",
- }),
clientRequestId: Schema.optional(ThreadMetadataClientRequestId),
}).check(
- Schema.makeFilter((input) => {
- switch (input.action) {
- case "rename":
- return input.title !== undefined && input.pullRequest === undefined
- ? true
- : "rename requires title and does not accept pullRequest.";
- case "link_pull_request":
- return input.pullRequest !== undefined && input.title === undefined
- ? true
- : "link_pull_request requires pullRequest and does not accept title.";
- case "regenerate_title":
- case "unlink_pull_request":
- return input.title === undefined && input.pullRequest === undefined
- ? true
- : `${input.action} does not accept title or pullRequest.`;
- }
- }),
+ Schema.makeFilter((input) =>
+ (input.action === "rename") === (input.title !== undefined)
+ ? true
+ : "rename requires title; regenerate_title does not accept one.",
+ ),
);
export type ThreadMetadataMcpUpdateInput = typeof ThreadMetadataMcpUpdateInput.Type;
diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts
index 9db8e5e67e87e..a0877ac4096c0 100644
--- a/packages/provider-core/src/server/orchestrationInstructions.ts
+++ b/packages/provider-core/src/server/orchestrationInstructions.ts
@@ -6,9 +6,9 @@ export const T3_CODE_ORCHESTRATION_SECTIONS: ReadonlyArray = [
Ordinary coding tasks need no T3 tools.
-- Subagents: use your native tool when the child uses your provider, a model it offers, and no settings beyond yours, and you wait in this turn (Claude \`Agent\` always with \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` for another provider's model, user-asked settings, a separate checkout (\`isolation:"worktree"\`), background work, or \`delegate_task\` by name. A native type running another provider's model counts too. Workflow only when asked.
-- Before \`delegate_task\`, call \`orchestrator_capabilities\` once. Again only after an error or a user-named provider change. Settings go in \`target.options\`. Provider or model unavailable: say so. Never switch unasked, or fall back to native tools, Workflow, or CLIs. Never make a child's checkout in the shell or in \`task\`.
-- Every new delegate review or re-check is a new \`delegate_task\` (brief, findings, objections), never a \`t3_thread_send\`. Fix small review findings yourself; start no delegate for them. When told to proceed without a delegate, \`task_cancel\` it. Start no replacement.
+- Subagents: use your native tool when the child uses your provider, a model it offers, and no settings beyond yours, and it finishes in this turn (Claude \`Agent\` always with \`run_in_background:false\`; Codex \`spawn_agent\`, then wait). Use \`delegate_task\` for another provider's model, user-asked settings, a separate checkout (\`isolation:"worktree"\`), background work, or \`delegate_task\` by name. A native type running another provider's model counts too. Workflow only when asked.
+- Call \`orchestrator_capabilities\` once per thread, before the first \`delegate_task\`. Again only after an error or a user-named provider change. Settings go in \`target.options\`. Provider or model unavailable: say so. Never switch unasked, or fall back to native tools, Workflow, or CLIs. Never make a child's checkout in the shell or in \`task\`.
+- Every new delegate review or re-check is a new \`delegate_task\` (brief, findings, objections), never a \`t3_thread_send\`. Fix small review findings yourself; start no delegate for them. When told to proceed without a delegate, or to run fewer, \`task_cancel\` the extras. Start no replacement.
- Follow a running delegate's \`next\`. Do not poll it (\`task_status\`, \`sleep\`, watchers, Monitor, ScheduleWakeup). Stop a delegate run you replace or no longer need with \`task_cancel\`, never \`t3_thread_interrupt\` on a \`childThreadId\`.
- If you are a subagent or child, do the work yourself. Start no subagents unless your task says to.`,
`- \`t3_thread_launch\` and \`create_threads\` make new top-level threads, only when the user asks for a new thread. "Subagent", "delegate", and "parallel" never mean one. Worktree thread: set \`workspaceStrategy\`; shell worktrees do not bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree.
diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts
index fc3345d6c537a..1c2683753656c 100644
--- a/packages/shared/src/t3McpToolPresentation.test.ts
+++ b/packages/shared/src/t3McpToolPresentation.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vite-plus/test";
-import { T3_MCP_TOOL_NAMES, resolveT3McpToolPresentation } from "./t3McpToolPresentation.ts";
+import {
+ T3_MCP_TOOL_NAMES,
+ resolveT3McpSummaryAction,
+ resolveT3McpToolDefinition,
+ resolveT3McpToolPresentation,
+} from "./t3McpToolPresentation.ts";
describe("resolveT3McpToolPresentation", () => {
it("recognizes every T3 tool across provider prefixes and completion suffixes", () => {
@@ -32,3 +37,34 @@ describe("resolveT3McpToolPresentation", () => {
expect(resolveT3McpToolPresentation("t3-code-thread_opencode2-adapter_not_a_tool")).toBeNull();
});
});
+
+describe("resolveT3McpToolDefinition", () => {
+ it("labels an action tool call as the action it ran", () => {
+ expect(
+ resolveT3McpToolDefinition("mcp__t3-code__preview_act", { action: "type" })?.labels,
+ ).toEqual(resolveT3McpToolDefinition("preview_type")?.labels);
+ expect(resolveT3McpToolDefinition("t3_thread_transfer", { action: "fork" })?.displayName).toBe(
+ resolveT3McpToolDefinition("t3_thread_fork")?.displayName,
+ );
+ expect(resolveT3McpToolDefinition("preview_act", { action: "unknown" })?.labels[2]).toBe(
+ "Used",
+ );
+ });
+});
+
+describe("resolveT3McpSummaryAction", () => {
+ it("summarizes an action tool by the action each call ran", () => {
+ expect(resolveT3McpSummaryAction("mcp__t3-code__t3_queue", { action: "list" })).toBe(
+ "queue-list",
+ );
+ expect(resolveT3McpSummaryAction("t3_queue", { action: "edit" })).toBe("queue-edit");
+ expect(resolveT3McpSummaryAction("t3_thread_transfer", { action: "status" })).toBe(
+ "thread-transfers",
+ );
+ expect(resolveT3McpSummaryAction("t3_thread_configure", {})).toBe("thread-configuration");
+ expect(resolveT3McpSummaryAction("schedule_task", { scheduledTaskId: "task-1" })).toBe(
+ "schedule-update",
+ );
+ expect(resolveT3McpSummaryAction("delegate_task", { task: "x" })).toBe("delegate");
+ });
+});
diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts
index ff0ae1f3d2f06..fa6a11e18fbd5 100644
--- a/packages/shared/src/t3McpToolPresentation.ts
+++ b/packages/shared/src/t3McpToolPresentation.ts
@@ -190,6 +190,7 @@ const T3_MCP_TOOLS: Readonly> = {
"browser",
"Snapshot the preview page",
),
+ preview_act: tool(["Use", "Using", "Used", "the preview browser"], "browser", "browser"),
preview_click: tool(
["Click", "Clicking", "Clicked", "in the preview browser"],
"browser",
@@ -272,6 +273,11 @@ const T3_MCP_TOOLS: Readonly> = {
["Run", "Running", "Requested a run of", "a scheduled task"],
"schedule-run",
),
+ t3_queue: tool(["Manage", "Managing", "Managed", "queued messages"], "queue-edit"),
+ t3_pending_request: tool(
+ ["Handle", "Handling", "Handled", "pending questions"],
+ "question-respond",
+ ),
t3_queue_list: tool(["List", "Listing", "Listed", "queued messages"], "queue-list"),
t3_queue_read: tool(["Read", "Reading", "Read", "a queued message"], "queue-read"),
t3_queue_edit: tool(["Edit", "Editing", "Edited", "a queued message"], "queue-edit"),
@@ -297,7 +303,8 @@ const T3_MCP_TOOLS: Readonly> = {
["Read", "Reading", "Read", "thread configuration"],
"thread-configuration",
),
- t3_thread_configure: tool(["Set", "Setting", "Set", "thread model"], "thread-configure"),
+ t3_thread_configure: tool(["Use", "Using", "Used", "thread model settings"], "thread-configure"),
+ t3_thread_transfer: tool(["Use", "Using", "Used", "thread transfers"], "thread-fork"),
t3_thread_fork: tool(["Fork", "Forking", "Requested a fork of", "this thread"], "thread-fork"),
t3_thread_merge_back: tool(
["Merge", "Merging", "Requested a merge of", "thread context"],
@@ -381,16 +388,61 @@ export function resolveT3McpToolId(toolName: string | null | undefined): string
return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? name : null;
}
+type SummaryInput = Readonly>;
+
+const asSummaryInput = (input: unknown): SummaryInput | undefined =>
+ typeof input === "object" && input !== null ? (input as SummaryInput) : undefined;
+
+/** Action tools present each call as the single-purpose tool its action replaced. */
+const ACTION_TOOLS: Readonly string | undefined>> = {
+ preview_act: (action) => `preview_${action}`,
+ t3_queue: (action) => `t3_queue_${action}`,
+ t3_pending_request: (action) => `t3_pending_request_${action}`,
+ t3_thread_transfer: (action) =>
+ ({ fork: "t3_thread_fork", merge_back: "t3_thread_merge_back", status: "t3_thread_transfers" })[
+ action
+ ],
+};
+
+/** Pass the call's input so an action tool resolves to the labels and summary of its action. */
export function resolveT3McpToolDefinition(
toolName: string | null | undefined,
+ input?: unknown,
): T3McpToolDefinition | null {
const name = toolName == null ? null : resolveT3McpToolName(toolName);
- return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? T3_MCP_TOOLS[name]! : null;
+ if (name === null || !Object.hasOwn(T3_MCP_TOOLS, name)) return null;
+ const action = asSummaryInput(input)?.action;
+ const actionTool = typeof action === "string" ? ACTION_TOOLS[name]?.(action) : undefined;
+ return actionTool !== undefined && Object.hasOwn(T3_MCP_TOOLS, actionTool)
+ ? T3_MCP_TOOLS[actionTool]!
+ : T3_MCP_TOOLS[name]!;
+}
+
+/** Tools whose summary depends on which fields a call sets. */
+const ACTION_SUMMARIES: Readonly<
+ Record T3McpToolSummaryAction | undefined>
+> = {
+ t3_thread_configure: ({ modelSelection }) =>
+ modelSelection === undefined ? "thread-configuration" : "thread-configure",
+ schedule_task: ({ scheduledTaskId }) =>
+ scheduledTaskId === undefined ? "schedule-create" : "schedule-update",
+};
+
+export function resolveT3McpSummaryAction(
+ toolName: string | null | undefined,
+ input: unknown,
+): T3McpToolSummaryAction | null {
+ const definition = resolveT3McpToolDefinition(toolName, input);
+ if (definition === null) return null;
+ const summarize = ACTION_SUMMARIES[resolveT3McpToolName(toolName!) ?? ""];
+ const fields = asSummaryInput(input);
+ return (fields && summarize?.(fields)) ?? definition.summaryAction;
}
export function resolveT3McpToolPresentation(
toolName: string | null | undefined,
+ input?: unknown,
): T3McpToolPresentation | null {
- const definition = resolveT3McpToolDefinition(toolName);
+ const definition = resolveT3McpToolDefinition(toolName, input);
return definition === null ? null : { displayName: definition.displayName, logo: "t3-code" };
}