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
87 changes: 87 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,3 +817,90 @@ it.effect("admits provider and OAuth client credentials and points only clients
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("lists pull request watch tools only to threads that are not delegated children", () =>
Effect.scoped(
Effect.gen(function* () {
const scopeFor = (
token: string,
subagent: boolean,
): McpInvocationContext.McpThreadInvocationScope => ({
environmentId,
requestNamespace: token,
thread: {
threadId: ThreadId.make(`thread-${token}`),
providerSessionId: token,
providerInstanceId: ProviderInstanceId.make("codex"),
subagent,
},
client: undefined,
capabilities: new Set(["pull-requests"]),
issuedAt: 1,
});
const scopes = new Map([
["parentToken", scopeFor("parentToken", false)],
["childToken", scopeFor("childToken", true)],
]);
const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe(
Layer.provideMerge(McpHttpServer.layerMcpTransport),
Layer.provide(
Layer.mock(McpSessionRegistry.McpSessionRegistry)({
resolve: (token) => Effect.succeed(scopes.get(token)),
}),
),
Layer.provide(
Layer.mergeAll(
McpToolAccessTestkit.liveThreadsLayer,
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}),
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
NodeServices.layer,
),
),
);
yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe(
Layer.build,
);
const httpClient = yield* HttpClient.HttpClient;
const listedTools = (token: string) =>
Effect.gen(function* () {
const headers = {
accept: "application/json, text/event-stream",
authorization: `Bearer ${token}`,
};
const initialize = yield* httpClient.post("/mcp", {
headers,
body: HttpBody.text(
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`,
"application/json",
),
});
const listed = yield* httpClient.post("/mcp", {
headers: {
...headers,
"mcp-session-id": initialize.headers["mcp-session-id"]!,
"mcp-protocol-version": "2025-06-18",
},
body: HttpBody.text(
`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`,
"application/json",
),
});
const body = (yield* listed.json) as {
readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> };
};
return body.result.tools.map((tool) => tool.name);
});

const parentTools = yield* listedTools("parentToken");
const childTools = yield* listedTools("childToken");
expect(parentTools).toEqual(
expect.arrayContaining(["link_pull_request", "watch_pull_request", "unwatch_pull_request"]),
);
expect(childTools).toContain("link_pull_request");
expect(childTools).not.toContain("watch_pull_request");
expect(childTools).not.toContain("unwatch_pull_request");
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
13 changes: 13 additions & 0 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";

const ALL_MCP_CAPABILITIES = [
"preview",
Expand All @@ -24,6 +25,8 @@ export interface McpThreadCaller {
readonly threadId: ThreadId;
readonly providerSessionId: string;
readonly providerInstanceId: ProviderInstanceId;
/** A delegated child task; its parent owns the work it hands back. */
readonly subagent?: boolean;
}

export interface McpClientCaller {
Expand Down Expand Up @@ -51,6 +54,16 @@ export class McpInvocationContext extends Context.Service<
McpInvocationScope
>()("t3/mcp/McpInvocationContext") {}

/**
* `McpSchema.EnabledWhen` for tools a delegated child must not see or call.
* EnabledWhen only receives the MCP client profile, so read the T3 caller from the request fiber.
*/
export const hiddenFromSubagents = (): boolean => {
const fiber = Fiber.getCurrent();
const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext);
return scope?.thread?.subagent !== true;
};

export type McpCapabilityError<C extends McpCapability> = C extends "preview"
? PreviewAutomationUnavailableError
: McpCapabilityUnavailableError;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface McpCredentialRequest {
/** False omits the preview capability wherever the token is honored. Defaults to full access. */
readonly browserToolsAvailable?: boolean;
readonly capabilities?: ReadonlySet<McpInvocationContext.McpCapability>;
readonly subagent?: boolean;
}

export interface McpIssuedCredential {
Expand Down Expand Up @@ -105,6 +106,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
threadId: ThreadId.make(request.threadId),
providerSessionId,
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
subagent: request.subagent === true,
},
client: undefined,
capabilities: new Set<McpInvocationContext.McpCapability>([
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import * as McpSchema from "effect/ai/McpSchema";
import * as Tool from "effect/ai/Tool";
import * as Toolkit from "effect/ai/Toolkit";

Expand Down Expand Up @@ -326,7 +327,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {

const WatchPullRequestTool = Tool.make("watch_pull_request", {
description:
"Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.",
"Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.",
parameters: PullRequestTargetInput,
success: WatchPullRequestResult,
failure: PullRequestToolError,
Expand All @@ -336,7 +337,8 @@ const WatchPullRequestTool = Tool.make("watch_pull_request", {
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);
.annotate(Tool.OpenWorld, false)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents);

const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
description:
Expand All @@ -350,7 +352,8 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);
.annotate(Tool.OpenWorld, false)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents);

const GuideTarget = Schema.Struct({
url: PullRequestTargetInput.fields.url,
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,59 @@ it.effect(
}),
);

it.effect.each([
{ relationshipToParent: null, subagent: false },
{ relationshipToParent: "subagent", subagent: true },
] as const)(
"ProviderSessionManagerV2 marks MCP credentials of delegated children ($relationshipToParent)",
({ relationshipToParent, subagent }) =>
Effect.gen(function* () {
const state = yield* Ref.make(emptyState);
const mcpConfigs = yield* Ref.make<
ReadonlyArray<McpProviderSession.McpProviderSessionConfig | undefined>
>([]);
const effect = Effect.gen(function* () {
const eventSink = yield* EventSink.EventSinkV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
const registry = yield* McpSessionRegistry.McpSessionRegistry;
const now = yield* DateTime.now;
const threadId = ThreadId.make("thread-provider-session-manager-mcp-lineage");
const providerSessionId = yield* idAllocator.allocate.providerSession({
providerInstanceId: modelSelection.instanceId,
threadId,
});
const created = yield* makeThreadCreatedEvent({ idAllocator, threadId, now });
const parentThreadId = ThreadId.make("thread-provider-session-manager-mcp-parent");
yield* eventSink.write({
events: [
{
...created,
payload: {
...created.payload,
lineage:
relationshipToParent === null
? created.payload.lineage
: { parentThreadId, relationshipToParent, rootThreadId: parentThreadId },
},
},
],
});
yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy });

const token = (yield* Ref.get(mcpConfigs))[0]?.authorizationHeader.replace(
/^Bearer\s+/,
"",
);
assert.isDefined(token);
assert.equal((yield* registry.resolve(token!))?.thread.subagent, subagent);
yield* manager.close(providerSessionId);
});

yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs })));
}),
);

it.effect(
"ProviderSessionManagerV2 withholds the preview capability when agent browser access is off",
() =>
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/orchestration-v2/ProviderSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,10 @@ export const layerWithOptions = (
>(["orchestration", "worktree", "pull-requests"]);
if (browserToolsAvailable) capabilities.add("preview");
if (deviceToolsAvailable) capabilities.add("device");
const subagent = yield* projectionStore.getThread(threadId).pipe(
Effect.map((thread) => thread.lineage.relationshipToParent === "subagent"),
Effect.orElseSucceed(() => false),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
const existing = yield* mcpSessions.read(threadId);
if (existing !== undefined) {
// Reserve before async validation so release cannot revoke between validation and reservation.
Expand All @@ -450,7 +454,9 @@ export const layerWithOptions = (
resolved.thread.threadId === threadId &&
resolved.thread.providerInstanceId === providerInstanceId &&
resolved.capabilities.has("preview") === browserToolsAvailable &&
resolved.capabilities.has("device") === deviceToolsAvailable
resolved.capabilities.has("device") === deviceToolsAvailable &&
// A credential issued while the lineage read failed is replaced once it works.
(resolved.thread.subagent === true) === subagent
) {
return { mcpCredentialId: existing.providerSessionId, issued: false };
}
Expand All @@ -462,6 +468,7 @@ export const layerWithOptions = (
providerInstanceId,
browserToolsAvailable,
capabilities,
subagent,
});
yield* mcpSessions.set(credential.config);
reserveMcpCredential(threadId, credential.config.providerSessionId);
Expand Down
Loading