Skip to content
Closed
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
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,7 @@ function useMarkdownStyles(): MarkdownStyleSets {
function AgentMessageFoldHeader(props: {
readonly environmentId: EnvironmentId;
readonly senderThreadId?: ThreadId;
readonly senderTitle?: string;
readonly summary: string;
readonly expanded: boolean;
readonly onToggle: () => void;
Expand All @@ -1341,7 +1342,7 @@ function AgentMessageFoldHeader(props: {
const senderShell = useQuietThreadShell(
props.senderThreadId ? scopeThreadRef(props.environmentId, props.senderThreadId) : null,
);
const label = senderShell?.title.trim() || "Agent";
const label = senderShell?.title.trim() || props.senderTitle?.trim() || "Agent";
return (
<Pressable
accessibilityRole="button"
Expand Down Expand Up @@ -2036,8 +2037,9 @@ function renderFeedEntry(
{presentation.attribution === "guest" ? <GuestBubbleTint /> : null}
{foldRowId !== null ? (
<AgentMessageFoldHeader
environmentId={props.environmentId}
environmentId={message.senderEnvironmentId ?? props.environmentId}
senderThreadId={message.senderThreadId}
senderTitle={message.senderTitle}
summary={
replaceComposerContextReferences(presentation.text, (ref) => ref.label)
.split(/\r?\n/)
Expand Down Expand Up @@ -2146,7 +2148,7 @@ function renderFeedEntry(
<View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
{foldRowId !== null && message.senderThreadId ? (
<OpenSenderThreadButton
environmentId={props.environmentId}
environmentId={message.senderEnvironmentId ?? props.environmentId}
senderThreadId={message.senderThreadId}
/>
) : null}
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ function QueuedRunSender(props: {
const { attribution, scheduledTaskId } = resolveUserMessagePresentation(props.message);
const senderThreadId = attribution === "agent" ? props.message.senderThreadId : undefined;
const senderShell = useQuietThreadShell(
senderThreadId ? scopeThreadRef(props.environmentId, senderThreadId) : null,
senderThreadId
? scopeThreadRef(props.message.senderEnvironmentId ?? props.environmentId, senderThreadId)
: null,
);
const tasks = useEnvironmentQuery(
scheduledTaskId
Expand All @@ -88,7 +90,7 @@ function QueuedRunSender(props: {
: attribution === "agent"
? {
icon: "bubble.left.and.bubble.right" as const,
label: senderShell?.title.trim() || "Agent",
label: senderShell?.title.trim() || props.message.senderTitle?.trim() || "Agent",
}
: attribution === "t3code"
? { icon: null, label: "Sent by T3 Code" }
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from "@t3tools/shared/t3McpToolPresentation";
import type {
ChatAttachment,
EnvironmentId,
MessageId,
OrchestrationV2Actor,
OrchestrationV2CreationSource,
Expand Down Expand Up @@ -140,6 +141,8 @@ export interface ThreadFeedMessage {
readonly creationSource?: OrchestrationV2CreationSource;
readonly scheduledTaskId?: ScheduledTaskId;
readonly senderThreadId?: ThreadId;
readonly senderEnvironmentId?: EnvironmentId;
readonly senderTitle?: string;
readonly idleCompaction?: import("@t3tools/contracts").OrchestrationV2IdleCompaction;
readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
readonly sourceThreadId: ThreadId;
Expand Down Expand Up @@ -2387,6 +2390,10 @@ export function buildThreadFeed(
creationSource: item.creationSource,
...(item.scheduledTaskId ? { scheduledTaskId: item.scheduledTaskId } : {}),
...(item.senderThreadId ? { senderThreadId: item.senderThreadId } : {}),
...(item.senderEnvironmentId
? { senderEnvironmentId: item.senderEnvironmentId }
: {}),
...(item.senderTitle ? { senderTitle: item.senderTitle } : {}),
...(item.idleCompaction ? { idleCompaction: item.idleCompaction } : {}),
}
: {}),
Expand Down
30 changes: 27 additions & 3 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ it.effect("admits provider and OAuth client credentials and points only clients
Effect.gen(function* () {
const providerToken = "providerTokenWithoutDots";
const clientToken = "client-payload.client-signature";
const linkToken = "link-payload.link-signature";
const providerScope: McpInvocationContext.McpInvocationScope = {
environmentId,
requestNamespace: "provider-session",
Expand All @@ -1019,6 +1020,11 @@ it.effect("admits provider and OAuth client credentials and points only clients
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};
const linkScope: McpInvocationContext.McpInvocationScope = {
...clientScope,
requestNamespace: "client:link",
client: { sessionId: "link", label: "T3 Code · laptop", access: "auto", linked: true },
};
const seen: Array<McpInvocationContext.McpInvocationScope> = [];
const ProbeToolkit = Toolkit.make(
Tool.make("probe", {
Expand All @@ -1042,7 +1048,7 @@ it.effect("admits provider and OAuth client credentials and points only clients
}),
),
).pipe(
Layer.provide(ServerSettings.layerTest()),
Layer.provide(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })),
Layer.provide(
Layer.mock(McpSessionRegistry.McpSessionRegistry)({
resolve: (token) =>
Expand All @@ -1057,7 +1063,11 @@ it.effect("admits provider and OAuth client credentials and points only clients
Layer.succeed(McpHttpServer.McpClientAuthenticator, {
authenticate: (request) =>
Effect.succeed(
request.headers.authorization === `Bearer ${clientToken}` ? clientScope : undefined,
request.headers.authorization === `Bearer ${clientToken}`
? clientScope
: request.headers.authorization === `Bearer ${linkToken}`
? linkScope
: undefined,
),
}),
),
Expand All @@ -1068,11 +1078,12 @@ it.effect("admits provider and OAuth client credentials and points only clients
Effect.withTracer(traced.tracer),
);
const httpClient = yield* HttpClient.HttpClient;
const call = (token: string | undefined) =>
const call = (token: string | undefined, extraHeaders: Record<string, string> = {}) =>
Effect.gen(function* () {
const headers = {
accept: "application/json, text/event-stream",
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
...extraHeaders,
};
const initialize = yield* httpClient.post("/mcp", {
headers,
Expand Down Expand Up @@ -1117,6 +1128,19 @@ it.effect("admits provider and OAuth client credentials and points only clients
"client:session-1",
]);

// Only a peer link may name the thread it calls for.
const senderHeaders = {
"t3-sender-environment": "environment-laptop",
"t3-sender-thread": "thread-laptop",
"t3-sender-title": encodeURIComponent("Café build"),
};
expect((yield* call(clientToken, senderHeaders)).status).toBe(200);
expect((yield* call(linkToken, senderHeaders)).status).toBe(200);
expect(seen.slice(2).map((scope) => scope.client?.sender)).toEqual([
undefined,
{ environmentId: "environment-laptop", threadId: "thread-laptop", title: "Café build" },
]);

const missing = yield* call(undefined);
expect(missing.status).toBe(401);
expect(missing.headers["www-authenticate"]).toMatch(
Expand Down
37 changes: 36 additions & 1 deletion apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AiError, McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effec
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
import {
DeviceToolError,
EnvironmentId,
OrchestratorMcpFailure,
PreviewAutomationError,
ProviderInteractionMode,
Expand Down Expand Up @@ -190,6 +191,40 @@ const withModeLimit = (
: { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } };
};

const decodeSender = Schema.decodeUnknownOption(
Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId }),
);

/** Names the thread a peer link calls for; other clients cannot claim a sender. */
const withSender = (
invocation: McpInvocationContext.McpInvocationScope,
request: HttpServerRequest.HttpServerRequest,
): McpInvocationContext.McpInvocationScope => {
if (invocation.client?.linked !== true) return invocation;
const sender = decodeSender({
environmentId: request.headers[McpInvocationContext.SENDER_ENVIRONMENT_HEADER],
threadId: request.headers[McpInvocationContext.SENDER_THREAD_HEADER],
});
if (Option.isNone(sender)) return invocation;
const title = decodeTitle(request.headers[McpInvocationContext.SENDER_TITLE_HEADER]);
return {
...invocation,
client: {
...invocation.client,
sender: title === undefined ? sender.value : { ...sender.value, title },
},
};
};

const decodeTitle = (header: string | undefined) => {
if (header === undefined) return undefined;
try {
return decodeURIComponent(header);
} catch {
return undefined;
}
};

// The Linked environments beta is off here: links wait until it is on again.
const linkedEnvironmentsOff = HttpServerResponse.jsonUnsafe(
{
Expand Down Expand Up @@ -235,7 +270,7 @@ const makeMcpAuthMiddleware = Effect.gen(function* () {
offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token),
});
}
const asked = withModeLimit(invocation, request);
const asked = withModeLimit(withSender(invocation, request), request);
if (asked === undefined) return invalidModeLimit;
const limited = McpInvocationContext.withRuntimeModeCeiling(asked, runtimeModeCeiling);
const linkedEnvironments = yield* linksEnabled;
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface McpClientCaller {
* another environment passing on the limits of the agent it calls for.
*/
readonly narrowedTo?: ClientModes | undefined;
/** The thread a peer link calls for (`T3-Sender-*`), so what it sends here names its sender. */
readonly sender?: McpRemoteSender | undefined;
/** An outside client's thread links go through this hosted app, as Copy thread's do. */
readonly hostedAppUrl?: string | undefined;
}
Expand All @@ -57,6 +59,19 @@ export interface McpClientCaller {
*/
export const MODE_LIMIT_HEADER = "t3-mode-limit";

/** The calling thread a peer link passes on; honored only from peer links. */
export const SENDER_ENVIRONMENT_HEADER = "t3-sender-environment";
export const SENDER_THREAD_HEADER = "t3-sender-thread";
/** URI-encoded, since a header carries only Latin-1. */
export const SENDER_TITLE_HEADER = "t3-sender-title";

export interface McpRemoteSender {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
/** The sender's title when sent, for clients that cannot read the sender's thread. */
readonly title?: string | undefined;
}

export interface ClientModes {
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
NodeId,
ORCHESTRATOR_MCP_RUNNING_TASK_NEXT,
TurnItemId,
type EnvironmentId,
type OrchestrationV2Run,
type OrchestrationV2Subagent,
type OrchestrationV2ThreadProjection,
Expand Down Expand Up @@ -253,6 +254,22 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure {
}

// Without a reason, a Stop reads as the user's (see RunExecutionService.USER_STOP_REASON).
/** The thread a message comes from: the caller's own, or the one a peer link calls for. */
function senderOf(
scope: McpInvocationScope,
parent: Pick<OrchestrationV2ThreadProjection, "thread"> | undefined,
): { senderThreadId?: ThreadId; senderEnvironmentId?: EnvironmentId; senderTitle?: string } {
if (parent !== undefined) return { senderThreadId: parent.thread.id };
const sender = scope.client?.sender;
return sender === undefined || sender.environmentId === scope.environmentId
? {}
: {
senderThreadId: sender.threadId,
senderEnvironmentId: sender.environmentId,
...(sender.title === undefined ? {} : { senderTitle: sender.title }),
};
}

function agentStopReason(reason: string | undefined): string {
return reason === undefined ? "Stopped by an agent" : `Stopped by an agent: ${reason}`;
}
Expand Down Expand Up @@ -3143,7 +3160,7 @@ const make = Effect.gen(function* () {
operation: "thread-send",
}),
threadId: input.threadId,
...(parent === undefined ? {} : { senderThreadId: parent.thread.id }),
...senderOf(scope, parent),
messageId,
text: input.message,
attachments: [],
Expand Down
Loading
Loading