Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a4a5839
feat(server): MCP tools for an inbox, approvals, thread diffs, and mo…
maria-rcks Oct 4, 2026
37a238b
fix(server): approvals over MCP accept only the offered decisions
maria-rcks Oct 4, 2026
1db29c2
fix(server): inbox respects snooze, file diffs keep renames, pending …
maria-rcks Oct 4, 2026
49b177a
fix(server): interrupted runs do not wake a snoozed thread in the inbox
maria-rcks Oct 4, 2026
09b9533
feat(server): MCP tools for settings, providers, git, pull requests, …
maria-rcks Oct 4, 2026
59affc8
fix(server): address review findings on MCP settings, reviewers, pin …
maria-rcks Oct 4, 2026
55dae44
fix(server): one-file thread diffs do not fail on an unreachable baseRef
maria-rcks Oct 4, 2026
982523a
Merge branch 't3code/expand-mcp-app-control' into t3code/mcp-app-cont…
maria-rcks Oct 4, 2026
b8121ce
fix(server): gate git and provider status, report no-op keybinding re…
maria-rcks Oct 4, 2026
0b8bb7d
fix(server): keybinding removal matches when by meaning; clarify sett…
maria-rcks Oct 4, 2026
1b75ae7
fix(server): any caller that can reach a thread may decline its approval
maria-rcks Oct 4, 2026
9692560
Merge branch 't3code/expand-mcp-app-control' into t3code/mcp-app-cont…
maria-rcks Oct 4, 2026
16977e5
fix(server): only host-id-shaped reviewers pass unmatched on GitLab a…
maria-rcks Oct 4, 2026
616bb40
fix(server): keep client focus history across disconnects; match flat…
maria-rcks Oct 4, 2026
3c7d9ba
fix(server): keybinding identity compares shortcut spellings by meaning
maria-rcks Oct 4, 2026
666b09e
fix(server): strip credentials from settings URLs for MCP; match reor…
maria-rcks Oct 4, 2026
4a164be
fix(server): working-tree thread diffs ignore a baseRef they don't use
maria-rcks Oct 4, 2026
e536707
Merge branch 't3code/expand-mcp-app-control' into t3code/mcp-app-cont…
maria-rcks Oct 4, 2026
035a10d
fix(server): pass the working-tree diff's baseRef explicitly
maria-rcks Oct 4, 2026
5dcdaaa
Merge branch 't3code/expand-mcp-app-control' into t3code/mcp-app-cont…
maria-rcks Oct 4, 2026
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
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeClientIntents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
[WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope,
} as const satisfies Readonly<Record<WsRpcMethod, AuthEnvironmentScope>>;
Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/clientIntents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type {
ClientIntent,
ClientIntentThreadPanel,
EnvironmentId,
ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Stream from "effect/Stream";

import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";

/**
* Broadcasts requests for connected clients to show something. Each intent
* names the desktop the user focused last, from the focus reports desktops
* already send for preview automation, so an agent driven from a terminal
* still reaches it. Clients without a target fall back to whichever is focused.
*/
export class ClientIntents extends Context.Service<
ClientIntents,
{
/** Resolves whether any client was subscribed to receive the request. */
readonly openThread: (input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly panel?: ClientIntentThreadPanel;
}) => Effect.Effect<boolean>;
readonly stream: Stream.Stream<ClientIntent>;
}
>()("t3/clientIntents") {}

const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const pubsub = yield* PubSub.unbounded<ClientIntent>();
const subscribers = yield* Ref.make(0);

return ClientIntents.of({
openThread: (input) =>
Effect.gen(function* () {
const intentId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
const targetClientId = yield* broker.lastFocusedClientId(input.environmentId);
yield* PubSub.publish(pubsub, {
type: "openThread",
intentId,
...input,
...(targetClientId === undefined ? {} : { targetClientId }),
});
return (yield* Ref.get(subscribers)) > 0;
}),
stream: Stream.unwrap(
Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(pubsub);
yield* Effect.acquireRelease(
Ref.update(subscribers, (count) => count + 1),
() => Ref.update(subscribers, (count) => count - 1),
);
return Stream.fromSubscription(subscription);
}),
),
});
});

export const layer = Layer.effect(ClientIntents, make);
42 changes: 40 additions & 2 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
mergeWithDefaultKeybindings,
compileResolvedKeybindingRule,
compileResolvedKeybindingsConfig,
parseKeybindingWhenExpression,
parseKeybindingShortcut,
} from "@t3tools/shared/keybindings";

Expand Down Expand Up @@ -96,11 +97,48 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
),
);

// `when` text compares by meaning, so "a&&!b" and "a && !b" name the same rule.
function isSameWhen(left: string | undefined, right: string | undefined): boolean {
if (left === right) return true;
if (left === undefined || right === undefined) return false;
const leftAst = parseKeybindingWhenExpression(left);
const rightAst = parseKeybindingWhenExpression(right);
return leftAst !== null && rightAst !== null && whenKey(leftAst) === whenKey(rightAst);
}

// Flattens and sorts chains of the same operator, since `a && (b && c)`, `a && b && c`, and
// `c && b && a` mean the same.
function whenKey(node: KeybindingWhenNode): string {
const operands = (child: KeybindingWhenNode): ReadonlyArray<string> =>
child.type === node.type && (child.type === "and" || child.type === "or")
? [...operands(child.left), ...operands(child.right)]
: [whenKey(child)];
switch (node.type) {
case "identifier":
return node.name;
case "not":
return `!(${whenKey(node.node)})`;
case "and":
case "or":
return `${node.type}(${[...operands(node.left), ...operands(node.right)].sort().join(",")})`;
}
}

// Shortcut spellings compare by meaning too, so "cmd+k" and "meta+k" name the same rule.
function isSameKey(left: string, right: string): boolean {
if (left === right) return true;
const leftShortcut = parseKeybindingShortcut(left);
const rightShortcut = parseKeybindingShortcut(right);
if (leftShortcut === null || rightShortcut === null) return false;
const encoded = encodeShortcut(leftShortcut);
return encoded !== null && encoded === encodeShortcut(rightShortcut);
}

function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean {
return (
left.command === right.command &&
left.key === right.key &&
(left.when ?? undefined) === (right.when ?? undefined)
isSameKey(left.key, right.key) &&
isSameWhen(left.when ?? undefined, right.when ?? undefined)
);
}

Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ import {
} from "./toolkits/preview/tools.ts";
import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts";
import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import { ReviewToolkitHandlersLive } from "./toolkits/review/handlers.ts";
import { ReviewToolkit } from "./toolkits/review/tools.ts";
import { GitToolkitHandlersLive } from "./toolkits/git/handlers.ts";
import { GitToolkit } from "./toolkits/git/tools.ts";
import { TerminalToolkitHandlersLive } from "./toolkits/terminal/handlers.ts";
import { TerminalToolkit } from "./toolkits/terminal/tools.ts";
import { ProviderHandlersLive } from "./toolkits/provider/handlers.ts";
import { ProviderToolkit } from "./toolkits/provider/tools.ts";
import { ClientToolkitHandlersLive } from "./toolkits/client/handlers.ts";
import { ClientToolkit } from "./toolkits/client/tools.ts";
import * as AgentSessionScanner from "../project/AgentSessionScanner.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
Expand Down Expand Up @@ -675,16 +687,38 @@ const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe(
Layer.provide(WorktreeMcpService.layer),
);

const ReviewToolkitRegistrationLive = McpServer.toolkit(ReviewToolkit).pipe(
Layer.provide(ReviewToolkitHandlersLive),
);

const GitToolkitRegistrationLive = McpServer.toolkit(GitToolkit).pipe(
Layer.provide(GitToolkitHandlersLive),
);

const TerminalToolkitRegistrationLive = McpServer.toolkit(TerminalToolkit).pipe(
Layer.provide(TerminalToolkitHandlersLive),
);

const ProviderRegistrationLive = McpServer.toolkit(ProviderToolkit).pipe(
Layer.provide(ProviderHandlersLive),
);

const ClientToolkitRegistrationLive = McpServer.toolkit(ClientToolkit).pipe(
Layer.provide(ClientToolkitHandlersLive),
);

const PreviewControlsRegistrationLive = McpServer.toolkit(PreviewControlsToolkit).pipe(
Layer.provide(PreviewControlsHandlersLive),
);

const EnvironmentRegistrationLive = McpServer.toolkit(EnvironmentToolkit).pipe(
Layer.provide(EnvironmentHandlersLive),
Layer.provide(ThreadCommandExecutor.layer),
);

const ProjectRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe(
Layer.provide(ProjectHandlersLive),
Layer.provide(AgentSessionScanner.layer),
);

const AttachmentRegistrationLive = McpServer.toolkit(AttachmentToolkit).pipe(
Expand Down Expand Up @@ -724,6 +758,11 @@ export const layer = Layer.mergeAll(
EnvironmentRegistrationLive,
PreviewControlsRegistrationLive,
WorktreeToolkitRegistrationLive,
ReviewToolkitRegistrationLive,
GitToolkitRegistrationLive,
TerminalToolkitRegistrationLive,
ProviderRegistrationLive,
ClientToolkitRegistrationLive,
PullRequestsToolkitRegistrationLive,
DeviceToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,14 @@ describe("orchestrator MCP toolkit", () => {
expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull();
yield* invoke("t3_thread_organize", { action: "unpin" });
expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).toBeNull();
yield* invoke("t3_thread_organize", { action: "auto_settle_off" });
expect(
(yield* orchestrator.getThreadShell(parentThreadId))?.autoSettleDisabledAt,
).not.toBeNull();
yield* invoke("t3_thread_organize", { action: "auto_settle_on" });
expect(
(yield* orchestrator.getThreadShell(parentThreadId))?.autoSettleDisabledAt,
).toBeNull();

if (parentRun === undefined || parentRun.rootNodeId === null) {
return yield* Effect.die(new Error("Parent run missing."));
Expand Down
32 changes: 30 additions & 2 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export class PreviewAutomationBroker extends Context.Service<
readonly invoke: <A = unknown>(
request: PreviewAutomationInvokeInput,
) => Effect.Effect<A, PreviewAutomationError>;
/** The connected desktop host focused most recently, or connected most recently if none was. */
readonly lastFocusedClientId: (
environmentId: PreviewAutomationHost["environmentId"],
) => Effect.Effect<string | undefined>;
}
>()("t3/mcp/PreviewAutomationBroker") {}

Expand Down Expand Up @@ -122,6 +126,8 @@ interface BrokerState {
readonly pending: ReadonlyMap<string, PendingRequest>;
readonly requestSequence: number;
readonly focusSequence: number;
/** Sequence of each client's last focus report, kept across disconnects. */
readonly focusedOrders: ReadonlyMap<string, number>;
}

const removeConnectionFromState = (
Expand Down Expand Up @@ -324,6 +330,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
pending: new Map(),
requestSequence: 0,
focusSequence: 0,
focusedOrders: new Map(),
});

const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* (
Expand Down Expand Up @@ -438,7 +445,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
liveTabs: host.liveTabs ?? currentHost.liveTabs,
focusOrder: host.focused ? focusSequence : currentHost.focusOrder,
});
return { ...current, clients, focusSequence };
const focusedOrders = host.focused
? new Map(current.focusedOrders).set(host.clientId, focusSequence)
: current.focusedOrders;
return { ...current, clients, focusSequence, focusedOrders };
});
});

Expand Down Expand Up @@ -655,7 +665,25 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
return result;
});

return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
const lastFocusedClientId: PreviewAutomationBroker["Service"]["lastFocusedClientId"] = (
environmentId,
) =>
SynchronizedRef.get(state).pipe(
Effect.map(
(current) =>
Array.from(current.clients.values())
.filter((host) => host.environmentId === environmentId)
// Real focus first; connection order only breaks ties, e.g. before anyone focused.
.sort(
(left, right) =>
(current.focusedOrders.get(right.clientId) ?? 0) -
(current.focusedOrders.get(left.clientId) ?? 0) ||
right.focusOrder - left.focusOrder,
)[0]?.clientId,
),
);

return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke, lastFocusedClientId });
}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));

export const layer = Layer.effect(PreviewAutomationBroker, make);
2 changes: 1 addition & 1 deletion apps/server/src/mcp/threadAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* <
}
const projection = yield* context.threads
.getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, {
turnItemTypes: ["user_input_request"],
turnItemTypes: ["user_input_request", "approval_request"],
})
.pipe(
Effect.mapError((error) =>
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/mcp/toolkits/client/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Effect from "effect/Effect";
import * as ClientIntents from "../../../clientIntents.ts";
import { readThread } from "../../threadAccess.ts";
import { ClientToolkit } from "./tools.ts";

export const ClientToolkitHandlersLive = ClientToolkit.toLayer({
t3_client_open_thread: (input) =>
Effect.gen(function* () {
const {
scope,
projection: { thread },
} = yield* readThread(input.threadId);
const clientIntents = yield* ClientIntents.ClientIntents;
const delivered = yield* clientIntents.openThread({
environmentId: scope.environmentId,
threadId: thread.id,
...(input.panel === undefined ? {} : { panel: input.panel }),
});
return { threadId: thread.id, delivered };
}),
});
33 changes: 33 additions & 0 deletions apps/server/src/mcp/toolkits/client/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ClientIntentThreadPanel, OrchestratorMcpFailure, ThreadId } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/unstable/ai";
import * as ClientIntents from "../../../clientIntents.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

const ClientOpenThreadTool = Tool.make("t3_client_open_thread", {
description:
"Show a thread in the user's T3 Code app (omit threadId for this thread), optionally with its diff or files panel open. The desktop app window the user focused last navigates even while another app has focus, and any focused web or desktop window does too; other open devices stay put and mobile does not respond. delivered is false when no client is connected; true means a client received the request.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
panel: Schema.optional(ClientIntentThreadPanel),
}),
success: Schema.Struct({
threadId: ThreadId,
delivered: Schema.Boolean,
}),
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
ClientIntents.ClientIntents,
],
})
.annotate(Tool.Title, "Open thread in app")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

export const ClientToolkit = Toolkit.make(ClientOpenThreadTool);
Loading
Loading