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
208 changes: 205 additions & 3 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,28 @@ import * as Stream from "effect/Stream";
import type * as Types from "effect/Types";
import { AiError, McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
import {
OrchestratorMcpFailure,
PreviewAutomationError,
type RunId,
ThreadId,
} from "@t3tools/contracts";

import packageJson from "../../package.json" with { type: "json" };
import * as ServerConfig from "../config.ts";
import * as DeviceService from "../device/DeviceService.ts";
import * as HtmlRender from "../htmlRender/HtmlRender.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
import {
type AgentThread,
agentToolProperties,
handoffSettings,
MAX_REPORTED_DEPTH,
toolOutcome,
} from "../telemetry/ProviderDimensions.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpToolAccess from "./McpToolAccess.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
Expand Down Expand Up @@ -746,6 +760,189 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
);
});

/**
* Tools that hand work to another thread's agent: they create it, message it,
* steer it, or move context into it. Their events name the receiving agent too.
*/
const HANDOFF_TOOLS: ReadonlySet<string> = new Set([
"delegate_task",
"create_threads",
"t3_thread_launch",
"t3_thread_send",
"t3_thread_send_attachments",
"t3_thread_fork",
"t3_thread_merge_back",
"t3_queue_edit",
"t3_queue_promote_to_steer",
"t3_pending_request_respond",
"schedule_task",
"run_scheduled_task_now",
]);

/** Handoff tools whose result is only `{ sequence }`; their input names the receiving thread. */
const INPUT_TARGET_TOOLS: ReadonlySet<string> = new Set([
"t3_queue_edit",
"t3_queue_promote_to_steer",
"t3_pending_request_respond",
]);

const resultThreadKeys = ["childThreadId", "targetThreadId", "boundThreadId", "threadId"] as const;

/** Thread ids a handoff tool result names: the thread that received the work. */
const resultThreadIds = (content: unknown): ReadonlyArray<string> => {
if (typeof content !== "object" || content === null) return [];
const record = content as Readonly<Record<string, unknown>>;
if (Array.isArray(record.threads)) return record.threads.flatMap(resultThreadIds);
const key = resultThreadKeys.find((candidate) => typeof record[candidate] === "string");
return key === undefined ? [] : [record[key] as string];
};

/**
* Thread ids that received a handoff. The input's `threadId` is used only after
* the call succeeded, so it is one the handler already resolved and authorized.
*/
const handoffTargetIds = (
tool: string,
args: unknown,
result: McpSchema.CallToolResult | undefined,
): ReadonlyArray<string> => {
if (!INPUT_TARGET_TOOLS.has(tool)) return resultThreadIds(result?.structuredContent);
if (result === undefined || toolOutcome(result).outcome !== "ok") return [];
const threadId = (args as { readonly threadId?: unknown } | null | undefined)?.threadId;
return typeof threadId === "string" ? [threadId] : [];
};

/**
* Runs a tool registration against a server that records one anonymous
* `mcp.tool.invoked` event per call: the tool, the calling agent's provider,
* the outcome with its failure code, and the duration. A handoff tool also
* reports the caller's model, origin, and delegation depth, the settings the
* agent chose, and the provider, model, and modes of each thread that received
* the work. Ids, prompts, and free text are never recorded.
*/
const withToolAnalytics = <A, E, R>(registration: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService);
if (Option.isNone(analytics)) return yield* registration;
const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry);
const threads = yield* Effect.serviceOption(ThreadManagementService.ThreadManagementService);
const shellOf = (threadId: string) =>
Option.isNone(threads)
? Effect.succeed(null)
: threads.value
.getThreadShell(ThreadId.make(threadId))
.pipe(Effect.orElseSucceed(() => null));
// Delegation depth: parents walked from the caller, capped.
const depthOf = (thread: AgentThread) =>
Effect.gen(function* () {
let depth = 0;
let parentId =
thread.lineage.relationshipToParent === "subagent" ? thread.lineage.parentThreadId : null;
while (parentId !== null && depth < MAX_REPORTED_DEPTH) {
depth += 1;
const parent = yield* shellOf(parentId);
parentId =
parent?.lineage.relationshipToParent === "subagent"
? parent.lineage.parentThreadId
: null;
}
return depth;
});
// Whether the caller's active run was started by a scheduled task.
const scheduledRunOf = (threadId: string, runId: RunId | null) =>
Option.isNone(threads) || runId === null
? Effect.succeed(false)
: threads.value
.getThreadRecords(ThreadId.make(threadId), ["runs", "messages"], {
runIds: [runId],
messageRoles: ["user"],
})
.pipe(
Effect.map((records) => {
const run = records.runs.find((candidate) => candidate.id === runId);
return records.messages.some(
(message) =>
message.id === run?.userMessageId && message.scheduledTaskId !== undefined,
);
}),
Effect.orElseSucceed(() => false),
);
const record = (
tool: string,
args: unknown,
result: McpSchema.CallToolResult | undefined,
durationMs: number,
) =>
Effect.gen(function* () {
// Client credentials (no calling thread) report an unknown caller.
const invocation = Option.getOrUndefined(
yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext),
)?.thread;
const providers = Option.isSome(registry) ? yield* registry.value.getProviders : [];
// Only handoff tools pay for thread lookups; the rest report the
// caller's provider from the credential.
const handoff = HANDOFF_TOOLS.has(tool);
const caller =
handoff && invocation !== undefined
? ((yield* shellOf(invocation.threadId)) ?? undefined)
: undefined;
const targetIds = handoff
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
? [...new Set(handoffTargetIds(tool, args, result))].filter(
(threadId) => threadId !== invocation?.threadId,
)
: [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const targets = yield* Effect.forEach(targetIds, shellOf);
for (const properties of agentToolProperties({
tool,
providers,
caller,
...(invocation === undefined
? {}
: { callerProviderInstanceId: invocation.providerInstanceId }),
...(caller === undefined || invocation === undefined
? {}
: {
callerDepth: yield* depthOf(caller),
callerScheduledRun: yield* scheduledRunOf(invocation.threadId, caller.activeRunId),
}),
targets: targets.filter((thread) => thread !== null),
outcome: toolOutcome(result),
...(handoff ? { settings: handoffSettings(tool, args, result?.structuredContent) } : {}),
durationMs,
})) {
yield* analytics.value.record("mcp.tool.invoked", properties);
}
}).pipe(Effect.ignoreCause);
const recordingServer = McpServer.McpServer.of({
...server,
addTool: (options) =>
server.addTool({
...options,
handle: (payload) =>
Effect.gen(function* () {
const startedAt = yield* Clock.currentTimeMillis;
return yield* options.handle(payload).pipe(
Effect.onExit((exit) => {
const result = exit._tag === "Success" ? exit.value : undefined;
// A call that asks for input is retried with it, and that call is the one counted.
if (result instanceof McpSchema.InputRequired) return Effect.void;
// Handoff tools look up threads to record; the response must not wait on them.
return Clock.currentTimeMillis.pipe(
Effect.flatMap((endedAt) =>
record(options.tool.name, payload, result, endedAt - startedAt),
),
Effect.forkDetach,
Effect.asVoid,
);
}),
Comment thread
juliusmarminge marked this conversation as resolved.
);
}),
}),
});
return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer));
});

/**
* `McpServer.toolkit` asks for every service its tools declare when it
* registers them, but the auth middleware provides `McpInvocationContext` to
Expand All @@ -756,7 +953,9 @@ export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX,
toolkit: Toolkit.Toolkit<Tools>,
handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
) => {
const registration = McpServer.toolkit(decodeAsAdvertised(toolkit));
const registration = Layer.effectDiscard(
withToolAnalytics(McpServer.registerToolkit(decodeAsAdvertised(toolkit))),
).pipe(Layer.provide(McpServer.McpServer.layer));
// @effect-diagnostics-next-line unsafeEffectTypeAssertion:off - the auth middleware provides it per request.
const registered = registration as Layer.Layer<
never,
Expand All @@ -769,7 +968,10 @@ export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX,
const imageToolRegistration = <Tools extends Record<string, Tool.Any>, A, E, R, EX, RX>(
register: Effect.Effect<A, E, R>,
handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
) => Layer.effectDiscard(register).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)));
) =>
Layer.effectDiscard(withToolAnalytics(register)).pipe(
Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)),
);

export const layerHtmlToolkit = Layer.mergeAll(
toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender),
Expand Down
127 changes: 127 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type OrchestrationV2ProjectedTurnItem,
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as TestClock from "effect/testing/TestClock";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -49,6 +50,7 @@ import * as McpHttpServer from "../McpHttpServer.ts";
import * as McpInvocationContext from "../McpInvocationContext.ts";
import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
import { dispatchFailure } from "../threadAccess.ts";
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
import { OrchestratorToolkit } from "./orchestrator/tools.ts";
import { PreviewToolkit } from "./preview/tools.ts";
import { PreviewControlsToolkit } from "./previewControls/tools.ts";
Expand Down Expand Up @@ -362,6 +364,131 @@ it.effect("keeps unexpected handler defects private through the production regis
),
);

it.effect("records the calling agent and the agent a tool acted on", () => {
// Recording runs detached from the tool response, so each expected event is awaited.
const recorded = [
Deferred.makeUnsafe<{ event: string; properties: object }>(),
Deferred.makeUnsafe<{ event: string; properties: object }>(),
];
let recordedCount = 0;
const shell = (id: ThreadId, instanceId: string, model: string) =>
({
id,
projectId: "mcp-core-project",
providerInstanceId: ProviderInstanceId.make(instanceId),
modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model },
runtimeMode: "full-access",
interactionMode: "default",
archivedAt: null,
deletedAt: null,
activeRunId: "mcp-core-run",
createdBy: "agent",
creationSource: "mcp",
lineage: {
parentThreadId: id === threadId ? ThreadId.make("mcp-core-parent") : null,
relationshipToParent: id === threadId ? "subagent" : null,
rootThreadId: ThreadId.make("mcp-core-parent"),
},
}) as never;
return Effect.gen(function* () {
const server = yield* McpServer.McpServer;
yield* server
.callTool({ name: "t3_thread_fork", arguments: { sourcePoint: { type: "latest_stable" } } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
Effect.provideService(McpSchema.McpServerClient, client),
);
const { event, properties } = yield* Deferred.await(recorded[0]!);
expect(event).toBe("mcp.tool.invoked");
expect(properties).toMatchObject({
tool: "t3_thread_fork",
outcome: "ok",
callerProvider: "codex",
callerModel: "gpt-5.5",
callerOrigin: "agent",
callerDepth: 1,
targetProvider: "claudeAgent",
targetModel: "claude-opus-5-5",
targetRuntimeMode: "full-access",
targetInteractionMode: "default",
crossProvider: true,
});
expect(properties).toHaveProperty("durationMs");
expect(Object.values(properties).some((value) => String(value).includes("mcp-core"))).toBe(
false,
);

// Queue tools return only a sequence, so the receiving thread comes from the input.
yield* server
.callTool({
name: "t3_queue_edit",
arguments: { threadId: "mcp-core-other", queuedRunId: "mcp-core-queued", text: "secret" },
})
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
Effect.provideService(McpSchema.McpServerClient, client),
);
// The fork recorded a single event, so the next one is the queue edit.
expect((yield* Deferred.await(recorded[1]!)).properties).toMatchObject({
tool: "t3_queue_edit",
outcome: "ok",
callerProvider: "codex",
targetProvider: "claudeAgent",
crossProvider: true,
});
}).pipe(
Effect.provide(
layerThreadToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(NodeCrypto.layer),
Layer.provide(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell: (id) =>
Effect.succeed(
id === threadId
? shell(threadId, "codex", "gpt-5.5")
: shell(id, "claudeAgent", "claude-opus-5-5"),
),
getThreadRecords: () => Effect.succeed({ runs: [], messages: [] } as never),
getProjectThreadRecords: () =>
Effect.succeed({ thread: shell(threadId, "codex", "gpt-5.5") } as never),
dispatch: () => Effect.succeed({ sequence: 1 } as never),
}),
),
Layer.provide(
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([
{
instanceId: ProviderInstanceId.make("codex"),
driver: "codex",
models: [{ slug: "gpt-5.5", isCustom: false }],
} as never,
{
instanceId: ProviderInstanceId.make("claudeAgent"),
driver: "claudeAgent",
models: [{ slug: "claude-opus-5-5", isCustom: false }],
} as never,
]),
}),
),
Layer.provide(
Layer.succeed(
AnalyticsService.AnalyticsService,
AnalyticsService.AnalyticsService.of({
record: (event, properties) =>
Deferred.succeed(recorded[recordedCount++]!, {
event,
properties: properties ?? {},
}).pipe(Effect.asVoid),
flush: Effect.void,
}),
),
),
),
),
);
});

it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
const settings = {
...DEFAULT_SERVER_SETTINGS,
Expand Down
Loading
Loading