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
149 changes: 149 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@ import {
ThreadId,
} from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { Tool } from "effect/unstable/ai";

import { attachmentRelativePath } from "../../attachmentStore.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
import {
ProviderAdapterV2RuntimePolicy,
Expand All @@ -39,7 +42,10 @@ import {
CLAUDE_DEFAULT_INSTANCE_ID,
CLAUDE_PROVIDER,
CLAUDE_READ_ONLY_ALLOWED_TOOLS,
CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS,
CLAUDE_T3_MCP_TOOL_WILDCARD,
ClaudeProviderCapabilitiesV2,
claudeEffectiveQueryPolicyKey,
claudeMcpQueryOverrides,
claudeRuntimeQueryPolicyForRuntimePolicy,
loggedClaudeQueryOptions,
Expand Down Expand Up @@ -211,6 +217,148 @@ describe("ClaudeAdapterV2 runtime query policy", () => {
});
});

describe("ClaudeAdapterV2 MCP query overrides", () => {
const T3_MCP_SERVERS = {
"t3-code": {
type: "http",
url: "http://127.0.0.1:43123/mcp",
headers: {
Authorization: "Bearer secret-claude-token",
},
},
} as const;

const withMcpSession = (threadId: ThreadId, run: () => void) => {
McpProviderSession.setMcpProviderSession({
environmentId: EnvironmentId.make(`environment-${threadId}`),
threadId,
providerSessionId: `mcp-session-${threadId}`,
providerInstanceId: ProviderInstanceId.make("claudeAgent"),
endpoint: "http://127.0.0.1:43123/mcp",
authorizationHeader: "Bearer secret-claude-token",
});
try {
run();
} finally {
McpProviderSession.clearMcpProviderSession(threadId);
}
};

it("leaves an absent allowlist absent when no MCP session exists", () => {
const overrides = claudeMcpQueryOverrides({
threadId: ThreadId.make("thread-claude-no-mcp-no-allowlist"),
readOnlySandbox: false,
});

assert.deepEqual(overrides, {});
});

it("preserves an explicit allowlist when no MCP session exists", () => {
const overrides = claudeMcpQueryOverrides({
threadId: ThreadId.make("thread-claude-no-mcp-with-allowlist"),
readOnlySandbox: false,
allowedTools: ["Read"],
});

assert.deepEqual(overrides, { allowedTools: ["Read"] });
});

it("pre-approves all t3-code tools when attaching an MCP session without an allowlist", () => {
const threadId = ThreadId.make("thread-claude-mcp-no-allowlist");
withMcpSession(threadId, () => {
const overrides = claudeMcpQueryOverrides({ threadId, readOnlySandbox: false });

assert.deepEqual(overrides, {
allowedTools: [CLAUDE_T3_MCP_TOOL_WILDCARD],
mcpServers: T3_MCP_SERVERS,
});
});
});

it("extends an explicit allowlist with the t3-code wildcard", () => {
const threadId = ThreadId.make("thread-claude-mcp-with-allowlist");
withMcpSession(threadId, () => {
const overrides = claudeMcpQueryOverrides({
threadId,
readOnlySandbox: false,
allowedTools: ["Read", "mcp__t3-code__*"],
});

assert.deepEqual(overrides, {
allowedTools: ["Read", "mcp__t3-code__*"],
mcpServers: T3_MCP_SERVERS,
});
});
});

it("pre-approves only read-only t3-code tools in a read-only sandbox", () => {
const threadId = ThreadId.make("thread-claude-mcp-read-only");
withMcpSession(threadId, () => {
const overrides = claudeMcpQueryOverrides({
threadId,
readOnlySandbox: true,
allowedTools: [...CLAUDE_READ_ONLY_ALLOWED_TOOLS],
});

assert.deepEqual(overrides, {
allowedTools: [...CLAUDE_READ_ONLY_ALLOWED_TOOLS, ...CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS],
mcpServers: T3_MCP_SERVERS,
});
assert.isFalse(overrides.allowedTools?.includes(CLAUDE_T3_MCP_TOOL_WILDCARD));
});
});

it("pre-approves only read-only t3-code tools in a read-only sandbox without an allowlist", () => {
const threadId = ThreadId.make("thread-claude-mcp-read-only-no-allowlist");
withMcpSession(threadId, () => {
const overrides = claudeMcpQueryOverrides({ threadId, readOnlySandbox: true });

assert.deepEqual(overrides.allowedTools, [...CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS]);
});
});

it("keys live-query reuse on the MCP-derived pre-approvals", () => {
const threadId = ThreadId.make("thread-claude-mcp-query-key");
withMcpSession(threadId, () => {
const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(
ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: "/workspace",
approvalPolicy: "on-request",
sandboxPolicy: {
type: "readOnly",
access: { type: "fullAccess" },
networkAccess: false,
},
}),
);

const readOnlyKey = claudeEffectiveQueryPolicyKey(
queryPolicy,
claudeMcpQueryOverrides({ threadId, readOnlySandbox: true }),
);
const fullAccessKey = claudeEffectiveQueryPolicyKey(
queryPolicy,
claudeMcpQueryOverrides({ threadId, readOnlySandbox: false }),
);
const detachedKey = claudeEffectiveQueryPolicyKey(queryPolicy, {});

assert.notEqual(readOnlyKey, fullAccessKey);
assert.notEqual(fullAccessKey, detachedKey);
});
});

it("matches the read-only allowlist to the orchestrator toolkit annotations", () => {
const readOnlyToolNames = Object.values(OrchestratorToolkit.tools)
.filter((tool) => Context.get(tool.annotations, Tool.Readonly))
.map((tool) => `mcp__t3-code__${tool.name}`)
.sort();

assert.deepEqual([...CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS].sort(), readOnlyToolNames);
});
});

describe("ClaudeAdapterV2 native protocol logging", () => {
it("injects thread-scoped MCP configuration without logging the credential", () => {
const threadId = ThreadId.make("thread-claude-mcp");
Expand All @@ -226,6 +374,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => {
try {
const overrides = claudeMcpQueryOverrides({
threadId,
readOnlySandbox: false,
allowedTools: ["Read"],
});
assert.deepEqual(overrides, {
Expand Down
42 changes: 40 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,8 +678,28 @@ export function makeClaudeQueryOptions(input: {
return input.cwd === null ? options : { ...options, cwd: input.cwd };
}

export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*";

// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit;
// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit.
export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
"mcp__t3-code__orchestrator_capabilities",
"mcp__t3-code__task_status",
"mcp__t3-code__list_scheduled_tasks",
"mcp__t3-code__t3_thread_list",
"mcp__t3-code__t3_thread_read",
"mcp__t3-code__t3_thread_wait",
];

// The SDK's `allowedTools` only pre-approves tool calls; availability is the
// separate `tools` option. Attaching the t3-code MCP server therefore always
// pre-approves its tools (headless modes like `dontAsk` deny anything that is
// not pre-approved), but read-only sandboxes pre-approve only the annotated
// read-only orchestrator tools so a read-only session cannot silently spawn
// threads or scheduled tasks.
export function claudeMcpQueryOverrides(input: {
readonly threadId: ThreadId;
readonly readOnlySandbox: boolean;
readonly allowedTools?: ReadonlyArray<string>;
}): {
readonly allowedTools?: ReadonlyArray<string>;
Expand All @@ -689,8 +709,11 @@ export function claudeMcpQueryOverrides(input: {
if (session === undefined) {
return input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools };
}
const mcpAllowedTools = input.readOnlySandbox
? CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS
: [CLAUDE_T3_MCP_TOOL_WILDCARD];
return {
allowedTools: Array.from(new Set([...(input.allowedTools ?? []), "mcp__t3-code__*"])),
allowedTools: Array.from(new Set([...(input.allowedTools ?? []), ...mcpAllowedTools])),
mcpServers: {
"t3-code": {
type: "http",
Expand Down Expand Up @@ -1177,6 +1200,19 @@ function claudeRuntimeQueryPolicyKey(policy: ClaudeRuntimeQueryPolicy): string {
});
}

// Live-query reuse must key on the effective allowlist (including the
// MCP-derived pre-approvals), not just the runtime policy: policies that
// share a policy key can still differ in MCP pre-approvals.
export function claudeEffectiveQueryPolicyKey(
queryPolicy: ClaudeRuntimeQueryPolicy,
mcpOverrides: { readonly allowedTools?: ReadonlyArray<string> },
): string {
return claudeRuntimeQueryPolicyKey({
...queryPolicy,
...(mcpOverrides.allowedTools === undefined ? {} : { allowedTools: mcpOverrides.allowedTools }),
});
}

type ClaudeToolItemType = Extract<
OrchestrationV2TurnItem["type"],
"command_execution" | "file_change" | "dynamic_tool" | "web_search"
Expand Down Expand Up @@ -3031,11 +3067,13 @@ export function makeClaudeAdapterV2(
const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy);
const mcpOverrides = claudeMcpQueryOverrides({
threadId: turnInput.threadId,
readOnlySandbox:
sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly",
Comment thread
cursor[bot] marked this conversation as resolved.
...(queryPolicy.allowedTools === undefined
? {}
: { allowedTools: queryPolicy.allowedTools }),
});
const queryPolicyKey = claudeRuntimeQueryPolicyKey(queryPolicy);
const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides);
const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection);
const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread);
const existing = yield* Ref.get(queryContext);
Expand Down
Loading