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
1 change: 1 addition & 0 deletions apps/server/src/auth/McpOAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ export const layerMcpClientAuthenticator = Layer.effect(
label: client.label,
access: client.access,
linked: client.peerLink,
...(client.peerLink ? {} : { webOrigin: requestUrls(request).issuer }),
},
capabilities: new Set<McpInvocationContext.McpCapability>([
"orchestration",
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import { formatThreadLink } from "@t3tools/shared/threadLinks";

const ALL_MCP_CAPABILITIES = [
"preview",
Expand Down Expand Up @@ -44,6 +45,8 @@ export interface McpClientCaller {
* another environment passing on the limits of the agent it calls for.
*/
readonly narrowedTo?: ClientModes | undefined;
/** The origin an outside client reached; its thread links open the web app there. */
readonly webOrigin?: string | undefined;
}

/**
Expand Down Expand Up @@ -105,6 +108,20 @@ export interface McpInvocationScope {
readonly linkedEnvironments?: boolean;
}

/**
* T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so
* they get a web URL on the origin they reached instead.
*/
export const threadLinkFor = (
scope: Pick<McpInvocationScope, "environmentId" | "client">,
threadId: ThreadId,
): string => {
const origin = scope.client?.webOrigin;
return origin === undefined
? formatThreadLink({ environmentId: scope.environmentId, threadId })
: `${origin}/${encodeURIComponent(scope.environmentId)}/${encodeURIComponent(threadId)}`;
};

Comment thread
coderabbitai[bot] marked this conversation as resolved.
export class McpInvocationContext extends Context.Service<
McpInvocationContext,
McpInvocationScope
Expand Down
16 changes: 8 additions & 8 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
CommandId,
type EnvironmentId,
type RunId,
isProviderAvailable,
MessageId,
Expand Down Expand Up @@ -108,6 +107,7 @@ import {
type McpInvocationScope,
type McpThreadInvocationScope,
requireThreadScope,
threadLinkFor,
} from "./McpInvocationContext.ts";
import * as Metrics from "../observability/Metrics.ts";
import {
Expand Down Expand Up @@ -752,15 +752,15 @@ function threadPullRequests(
}

function listItemFromShell(
environmentId: EnvironmentId,
scope: McpInvocationScope,
shell: OrchestrationV2ThreadShell,
nowMs: number,
): OrchestratorMcpThreadListItem {
return {
threadId: shell.id,
projectId: shell.projectId,
title: shell.title,
link: formatThreadLink({ environmentId, threadId: shell.id }),
link: threadLinkFor(scope, shell.id),
createdBy: shell.createdBy,
creationSource: shell.creationSource,
status: shell.activityRunStatus ?? shell.status,
Expand All @@ -782,7 +782,7 @@ function listItemFromShell(
}

function threadDetail(
environmentId: EnvironmentId,
scope: McpInvocationScope,
projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
itemCount: number,
shell: OrchestrationV2ThreadShell,
Expand All @@ -794,7 +794,7 @@ function threadDetail(
threadId: projection.thread.id,
projectId: projection.thread.projectId,
title: projection.thread.title,
link: formatThreadLink({ environmentId, threadId: projection.thread.id }),
link: threadLinkFor(scope, projection.thread.id),
createdBy: projection.thread.createdBy,
creationSource: projection.thread.creationSource,
status: active?.status ?? latest?.status ?? "idle",
Expand Down Expand Up @@ -2806,7 +2806,7 @@ const make = Effect.gen(function* () {
return {
projectId,
currentThreadId: parent?.thread.id ?? null,
threads: page.map((shell) => listItemFromShell(scope.environmentId, shell, nowMs)),
threads: page.map((shell) => listItemFromShell(scope, shell, nowMs)),
nextCursor,
total: filtered.length,
} satisfies OrchestratorMcpThreadListResult;
Expand Down Expand Up @@ -2887,7 +2887,7 @@ const make = Effect.gen(function* () {
}
return {
thread: threadDetail(
scope.environmentId,
scope,
target,
timeline.totalItems,
shell,
Expand Down Expand Up @@ -2951,7 +2951,7 @@ const make = Effect.gen(function* () {
);
return {
threadId: input.threadId,
link: formatThreadLink({ environmentId: scope.environmentId, threadId: input.threadId }),
link: threadLinkFor(scope, input.threadId),
messageId,
runId: result.run.id,
...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }),
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const clientLaunchHarness = (input: {
readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
readonly workspaceRoot?: string;
readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];
readonly webOrigin?: string;
}) => {
const projectId = ProjectId.make("project:client-target");
const modelSelection = {
Expand Down Expand Up @@ -75,6 +76,7 @@ const clientLaunchHarness = (input: {
sessionId: "session-1",
label: "Claude Code",
access: input.runtimeModeCeiling,
webOrigin: input.webOrigin,
},
issuedAt: 0,
capabilities: new Set(["orchestration" as const]),
Expand Down Expand Up @@ -155,15 +157,17 @@ it.effect(
readonly runtimeMode?: RuntimeMode;
readonly targeted?: false;
readonly overrideModel?: ModelSelection;
readonly webOrigin?: string;
},
) {
const { targeted, overrideModel, ...input } = params;
const { targeted, overrideModel, webOrigin, ...input } = params;
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const { projectId, modelSelection, dependencies } = clientLaunchHarness({
runtimeModeCeiling,
defaultRuntimeMode,
launched,
...(overrideModel === undefined ? {} : { overrideModel }),
...(webOrigin === undefined ? {} : { webOrigin }),
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
Expand All @@ -190,8 +194,14 @@ it.effect(
expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required");
expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined();

const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix" });
const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", {
title: "Fix",
webOrigin: "https://box.example.ts.net",
});
expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits");
expect(aboveCeiling.result).toMatchObject({
link: `https://box.example.ts.net/environment/${encodeURIComponent(aboveCeiling.launched[0]?.threadId ?? "")}`,
});

const requested = yield* launchWith("full-access", "approval-required", {
title: "Fix",
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import * as Repositories from "../../../sourceControl/SourceControlRepositorySer
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { formatThreadLink } from "@t3tools/shared/threadLinks";
import { resolveRuntimeMode } from "../../delegatedTaskModes.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
Expand Down Expand Up @@ -240,7 +239,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
const run = result.projection.runs.find((run) => run.userMessageId === messageId);
return {
threadId: thread.id,
link: formatThreadLink({ environmentId: scope.environmentId, threadId: thread.id }),
link: McpInvocationContext.threadLinkFor(scope, thread.id),
projectId: thread.projectId,
modelSelection: thread.modelSelection,
runId: run?.id ?? null,
Expand Down
13 changes: 8 additions & 5 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { modelSelectionCommandType } from "@t3tools/shared/model";
import { formatThreadLink } from "@t3tools/shared/threadLinks";

import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
Expand Down Expand Up @@ -104,11 +103,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
const { task, threadId } = yield* scheduler
.runNow({ id: input.taskId })
.pipe(Effect.mapError(unavailable));
const { environmentId } = yield* McpInvocationContext.McpInvocationContext;
const scope = yield* McpInvocationContext.McpInvocationContext;
return {
taskId: task.id,
threadId,
...(threadId === null ? {} : { link: formatThreadLink({ environmentId, threadId }) }),
...(threadId === null
? {}
: {
link: McpInvocationContext.threadLinkFor(scope, threadId),
}),
lastRunStatus: task.lastRunStatus,
runCount: task.runCount,
nextRunAt: task.nextRunAt,
Expand All @@ -118,15 +121,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
t3_thread_search: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const { caller } = yield* readCaller();
const { environmentId } = yield* McpInvocationContext.McpInvocationContext;
const scope = yield* McpInvocationContext.McpInvocationContext;
const threadSearch = yield* ThreadSearch.ThreadSearch;
const { matches } = yield* threadSearch
.searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId })
.pipe(Effect.mapError(unavailable));
return {
matches: matches.map((match) => ({
...match,
link: formatThreadLink({ environmentId, threadId: match.threadId }),
link: McpInvocationContext.threadLinkFor(scope, match.threadId),
})),
};
}),
Expand Down
5 changes: 4 additions & 1 deletion docs/orchestration-v2/orchestrator-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,10 @@ delegated to a linked environment name that environment. Clients open only that
environment's thread and show its current title; a link to a thread they do not
know renders as a label that opens nothing. Older `t3-thread://v1/<threadId>`
links name no environment: clients resolve them in the message's environment,
then by a unique match in another enabled environment.
then by a unique match in another enabled environment. An outside OAuth client,
one that is not a linked environment, cannot render `t3-thread://` links, so it
gets a web URL on the origin it reached, `<origin>/<environmentId>/<threadId>`,
for threads in this environment.

List and read results report `snoozed` and `snoozedUntil`, and
`t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the
Expand Down
Loading