Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ export const afterForwarding = <P, A, E, R, E2, R2>(
const fullAccessRequired =
"Changing projects or environment settings needs a live full-access/default calling thread or a full-access client.";

/** Fork: the `writesEnvironment` check, for one branch of a tool that otherwise writes a thread. */
export const requireFullAccess = (what: string) =>
orchestratingCaller.pipe(
Effect.tap((caller) => assertNotLinked(caller, what)),
Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)),
);

/** Recheck after waits before writing; caller modes may have changed. */
export const writesEnvironment = <P, A, E, R>(
handle: (
Expand Down
44 changes: 43 additions & 1 deletion apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,10 @@ it.effect("a read-only client reads threads and is refused every write before it
threadId: "other-project-thread",
});
expect(configuration.isError).toBe(false);
expect(configuration.structuredContent).toMatchObject({ runtimeMode: "auto" });
expect(configuration.structuredContent).toMatchObject({
runtimeMode: "auto",
acknowledgeOnly: false,
});

const pinned = yield* call("t3_thread_organize", {
action: "pin",
Expand Down Expand Up @@ -1228,6 +1231,39 @@ it.effect("a read-only client reads threads and is refused every write before it
expect(dispatched).toEqual([
'thread.runtime-mode.set:approval-required:MCP client "Claude Code" (session-)',
]);

// Who answers a thread's messages is environment policy: full access only, both ways.
const acknowledgeAs = (
access: "read-only" | "auto" | "full-access",
acknowledgeOnly: boolean,
) =>
server
.callTool({
name: "t3_thread_configure",
arguments: { threadId: "other-project-thread", acknowledgeOnly },
})
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope(access)),
Effect.provideService(McpSchema.McpServerClient, client),
);
for (const access of ["read-only", "auto"] as const) {
expect(declaredFailure(yield* acknowledgeAs(access, true))).toMatchObject({
code: "capability_denied",
});
}
expect((yield* acknowledgeAs("full-access", true)).structuredContent).toMatchObject({
acknowledgeOnly: true,
});
expect(
(yield* call("t3_thread_configure", { threadId: "other-project-thread" })).structuredContent,
).toMatchObject({ acknowledgeOnly: true });
expect(declaredFailure(yield* acknowledgeAs("auto", false))).toMatchObject({
code: "capability_denied",
});
expect((yield* acknowledgeAs("full-access", false)).structuredContent).toMatchObject({
acknowledgeOnly: false,
});
expect(dispatched).toHaveLength(1);
}).pipe(
Effect.provide(
layerThreadToolkit.pipe(
Expand All @@ -1252,6 +1288,11 @@ it.effect("a read-only client reads threads and is refused every write before it
deletedAt: null,
},
} as never),
acknowledgeOnly: () => Effect.succeed(acknowledgeOnly),
setAcknowledgeOnly: (_, enabled) =>
Effect.sync(() => {
acknowledgeOnly = enabled;
}),
dispatch: (command) =>
Effect.gen(function* () {
const actor = yield* RuntimeModeChangeActor;
Expand All @@ -1267,6 +1308,7 @@ it.effect("a read-only client reads threads and is refused every write before it
),
);
const dispatched: Array<string> = [];
let acknowledgeOnly = false;

it.effect("refuses act-as-caller tools to a client caller", () =>
Effect.gen(function* () {
Expand Down
43 changes: 42 additions & 1 deletion apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts";
import * as ScheduledTasks from "../../../scheduledTasks/ScheduledTaskService.ts";
import { queuedRunsInDeliveryOrder } from "../../../orchestration-v2/QueuedRunOrder.ts";
import { freezeThread, unfreezeThread } from "../../threadFreeze.ts";
import type * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import { ThreadToolkit } from "./tools.ts";

const isApprovalKind = Schema.is(ProviderRequestKind);
Expand Down Expand Up @@ -106,6 +107,36 @@ const required = <A>(value: A | undefined, name: string) =>
}),
)
: Effect.succeed(value);
// Who answers a thread's messages is environment policy, so it needs full access both ways.
const configureAcknowledgeOnly = Effect.fn("mcp.configureAcknowledgeOnly")(function* (
threads: ThreadManagementService.ThreadManagementService["Service"],
thread: Pick<
OrchestrationV2ThreadProjection["thread"],
"id" | "modelSelection" | "runtimeMode" | "interactionMode"
>,
input: {
readonly acknowledgeOnly?: boolean | undefined;
readonly modelSelection?: unknown;
readonly runtimeMode?: unknown;
},
) {
yield* McpToolAccess.requireFullAccess("change who answers a thread");
if (input.modelSelection !== undefined || input.runtimeMode !== undefined) {
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message: "Set acknowledgeOnly separately from modelSelection and runtimeMode.",
});
}
const acknowledgeOnly = yield* required(input.acknowledgeOnly, "acknowledgeOnly");
yield* threads.setAcknowledgeOnly(thread.id, acknowledgeOnly).pipe(Effect.mapError(unavailable));
return {
threadId: thread.id,
modelSelection: thread.modelSelection,
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
acknowledgeOnly,
};
});
const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(
handle: (params: P, authorize: McpToolAccess.AuthorizeThread) => Effect.Effect<A, E, R>,
) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);
Expand Down Expand Up @@ -224,17 +255,24 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
),
),
t3_thread_configure: McpToolAccess.either(
(input) => input.modelSelection === undefined && input.runtimeMode === undefined,
(input) =>
input.modelSelection === undefined &&
input.runtimeMode === undefined &&
input.acknowledgeOnly === undefined,
McpToolAccess.reads((input) =>
Effect.gen(function* () {
const {
threads,
projection: { thread },
} = yield* readThread(input.threadId);
return {
threadId: thread.id,
modelSelection: thread.modelSelection,
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
acknowledgeOnly: yield* threads
.acknowledgeOnly(thread.id)
.pipe(Effect.mapError(unavailable)),
};
}),
),
Expand All @@ -244,6 +282,9 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
threads,
projection: { thread },
} = yield* readThread(input.threadId);
if (input.acknowledgeOnly !== undefined) {
return yield* configureAcknowledgeOnly(threads, thread, input);
}
if (input.runtimeMode !== undefined) {
if (input.modelSelection !== undefined) {
return yield* new OrchestratorMcpFailure({
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,19 @@ const threadConfiguration = Schema.Struct({
modelSelection: ModelSelection,
runtimeMode: RuntimeMode,
interactionMode: ProviderInteractionMode,
// Optional: a linked environment on another build does not report it.
acknowledgeOnly: Schema.optional(Schema.Boolean),
});
const ThreadConfigureTool = Tool.make("t3_thread_configure", {
...commandTool,
description:
"Read a thread's provider, model, and modes, or set its provider, model, and options with `modelSelection`. Omit `threadId` for this thread. Set `runtimeMode` alone to lower permissions on an idle thread; raises and active runs are refused. 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. Set `runtimeMode` alone to lower permissions on an idle thread; raises and active runs are refused. Values from `orchestrator_capabilities`. Full access: `acknowledgeOnly` alone makes the server, not the agent, acknowledge each message.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId),
modelSelection: Schema.optional(ModelSelection),
runtimeMode: Schema.optional(RuntimeMode),
acknowledgeOnly: Schema.optional(Schema.Boolean),
}),
success: Schema.Union([threadConfiguration, OrchestrationV2DispatchCommandResult]),
}).annotate(Tool.Destructive, true);
Expand Down
Loading
Loading