1import * as Cause from "effect/Cause";
2import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";
4import * as SecureRandom from "effect/crypto/SecureRandom";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Predicate from "effect/Predicate";
11import * as Schema from "effect/Schema";
12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
14import type * as Types from "effect/Types";
15import { AiError, McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
16import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
17import {
18 DeviceToolError,
19 OrchestratorMcpFailure,
20 PreviewAutomationError,
21 ProviderInteractionMode,
22 type RunId,
23 RuntimeMode,
24 OrchestrationV2LinkedSender,
25 ThreadId,
26} from "@t3tools/contracts";
27
28import packageJson from "../../package.json" with { type: "json" };
29import { parseBearerToken } from "../auth/EnvironmentAuth.ts";
30import * as ServerConfig from "../config.ts";
31import * as ServerSettings from "../serverSettings.ts";
32import * as DeviceService from "../device/DeviceService.ts";
33import * as HtmlRender from "../htmlRender/HtmlRender.ts";
34import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
35import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
36import * as PullRequestWatchService from "../pullRequest/PullRequestWatchService.ts";
37import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
38import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
39import {
40 type AgentThread,
41 agentToolProperties,
42 handoffSettings,
43 MAX_REPORTED_DEPTH,
44 toolOutcome,
45} from "../telemetry/ProviderDimensions.ts";
46import { advertisedJsonSchema } from "./advertisedJsonSchema.ts";
47import * as McpInvocationContext from "./McpInvocationContext.ts";
48import * as McpToolAccess from "./McpToolAccess.ts";
49import * as McpToolExposure from "./McpToolExposure.ts";
50import { MCP_TOOL_EXPOSURE_HEADER } from "@t3tools/provider-core/server/mcpSession";
51import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
52import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
53import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts";
54import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
55import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts";
56import { ProjectToolkit } from "./toolkits/project/tools.ts";
57import * as ProjectHandlers from "./toolkits/project/handlers.ts";
58import { AttachmentToolkit } from "./toolkits/attachment/tools.ts";
59import * as AttachmentHandlers from "./toolkits/attachment/handlers.ts";
60import { ThreadToolkit } from "./toolkits/thread/tools.ts";
61import * as ThreadHandlers from "./toolkits/thread/handlers.ts";
62import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
63import * as McpSessionRegistry from "./McpSessionRegistry.ts";
64import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
65import * as OrchestratorHandlers from "./toolkits/orchestrator/handlers.ts";
66import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts";
67import * as PreviewHandlers from "./toolkits/preview/handlers.ts";
68import {
69 PreviewSnapshotTool,
70 PreviewSnapshotToolkit,
71 PreviewStandardToolkit,
72} from "./toolkits/preview/tools.ts";
73import * as WorktreeHandlers from "./toolkits/worktree/handlers.ts";
74import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
75import * as WorktreeMcpService from "./WorktreeMcpService.ts";
76import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
77import * as ToolSearchHandlers from "./toolkits/toolSearch/handlers.ts";
78import { ToolSearchToolkit } from "./toolkits/toolSearch/tools.ts";
79import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
80import * as DeviceHandlers from "./toolkits/device/handlers.ts";
81import {
82 DeviceScreenshotTool,
83 DeviceScreenshotToolkit,
84 DeviceStandardToolkit,
85} from "./toolkits/device/tools.ts";
86import * as HtmlHandlers from "./toolkits/html/handlers.ts";
87import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts";
88import { requestUrl } from "../requestUrl.ts";
89
90const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) =>
91 requestUrl(request).pipe(
92 Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`),
93 );
94
95/** Launched agents cannot open OAuth browsers; OAuth would mint credentials outliving their session. */
96const unauthorized = (input: {
97 readonly request: HttpServerRequest.HttpServerRequest;
98 readonly presentedToken: boolean;
99 readonly offerOAuth: boolean;
100}) => {
101 const metadataUrl = input.offerOAuth
102 ? Option.getOrUndefined(mcpResourceMetadataUrl(input.request))
103 : undefined;
104 const challenge = [
105 "Bearer",
106 [
107 ...(metadataUrl === undefined ? [] : [`resource_metadata="${metadataUrl}"`]),
108 ...(input.presentedToken ? ['error="invalid_token"'] : []),
109 ].join(", "),
110 ]
111 .filter((part) => part.length > 0)
112 .join(" ");
113 return HttpServerResponse.jsonUnsafe(
114 {
115 error: "invalid_mcp_credential",
116 message: "A valid T3 Code MCP credential is required.",
117 },
118 {
119 status: 401,
120 headers: {
121 "cache-control": "no-store",
122 "www-authenticate": challenge,
123 },
124 },
125 );
126};
127
128export class McpClientAuthenticator extends Context.Service<
129 McpClientAuthenticator,
130 {
131 readonly authenticate: (
132 request: HttpServerRequest.HttpServerRequest,
133 ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>;
134 }
135>()("t3/mcp/McpHttpServer/McpClientAuthenticator") {}
136
137type AuthenticatedHttpEffect = Effect.Effect<
138 HttpServerResponse.HttpServerResponse,
139 Types.unhandled,
140 McpInvocationContext.McpInvocationContext
141>;
142
143type McpAuthMiddleware = (
144 httpEffect: AuthenticatedHttpEffect,
145) => Effect.Effect<
146 HttpServerResponse.HttpServerResponse,
147 Types.unhandled,
148 HttpServerRequest.HttpServerRequest
149>;
150
151const normalizeMcpHttpResponse = (
152 response: HttpServerResponse.HttpServerResponse,
153): HttpServerResponse.HttpServerResponse => {
154 const bodyIsEmpty =
155 response.body._tag === "Empty" ||
156 (response.body._tag === "Uint8Array" && response.body.contentLength === 0) ||
157 (response.body._tag === "Raw" && response.body.contentLength === 0);
158 return response.status === 200 && bodyIsEmpty
159 ? HttpServerResponse.setStatus(response, 202)
160 : response;
161};
162
163// Session tokens are `<payload>.<signature>`; registry tokens are a bare base64url secret.
164const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes(".");
165
166const decodeModeLimit = Schema.decodeUnknownOption(
167 Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode }),
168);
169const parseModeLimit = (value: string) => {
170 const [runtimeMode, interactionMode, ...rest] = value.trim().split("/");
171 return rest.length === 0 ? decodeModeLimit({ runtimeMode, interactionMode }) : Option.none();
172};
173
174const invalidModeLimit = HttpServerResponse.jsonUnsafe(
175 {
176 error: "invalid_mode_limit",
177 message: "T3-Mode-Limit must be <runtimeMode>/<interactionMode>, e.g. auto/default.",
178 },
179 { status: 400, headers: { "cache-control": "no-store" } },
180);
181
182/** Applies a client's requested mode limit; undefined when the header is malformed. */
183const withModeLimit = (
184 invocation: McpInvocationContext.McpInvocationScope,
185 request: HttpServerRequest.HttpServerRequest,
186): McpInvocationContext.McpInvocationScope | undefined => {
187 const header = request.headers[McpInvocationContext.MODE_LIMIT_HEADER];
188 if (header === undefined || invocation.client === undefined) return invocation;
189 const limit = parseModeLimit(header);
190 return Option.isNone(limit)
191 ? undefined
192 : { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } };
193};
194
195const decodeSenderThread = Schema.decodeUnknownOption(OrchestrationV2LinkedSender);
196
197/** Records the thread a linked environment calls for; a malformed header only loses the name. */
198const withSenderThread = (
199 invocation: McpInvocationContext.McpInvocationScope,
200 request: HttpServerRequest.HttpServerRequest,
201): McpInvocationContext.McpInvocationScope => {
202 const header = request.headers[McpInvocationContext.SENDER_THREAD_HEADER];
203 if (header === undefined || invocation.client?.linked !== true) return invocation;
204 const [environmentId, threadId, title, ...rest] = header.trim().split("/");
205 if (environmentId === undefined || threadId === undefined || title === undefined)
206 return invocation;
207 if (rest.length > 0) return invocation;
208 let decoded: Option.Option<OrchestrationV2LinkedSender>;
209 try {
210 decoded = decodeSenderThread({
211 environmentId: decodeURIComponent(environmentId),
212 threadId: decodeURIComponent(threadId),
213 title: decodeURIComponent(title),
214 });
215 } catch {
216 return invocation;
217 }
218 return Option.isNone(decoded)
219 ? invocation
220 : { ...invocation, client: { ...invocation.client, senderThread: decoded.value } };
221};
222
223// The Linked environments beta is off here: links wait until it is on again.
224const linkedEnvironmentsOff = HttpServerResponse.jsonUnsafe(
225 {
226 error: "linked_environments_off",
227 message: "Linked environments is off on this machine. Turn it on in its Settings → Labs.",
228 },
229 { status: 403, headers: { "cache-control": "no-store" } },
230);
231
232const makeMcpAuthMiddleware = Effect.gen(function* () {
233 const registry = yield* McpSessionRegistry.McpSessionRegistry;
234 const clients = yield* Effect.serviceOption(McpClientAuthenticator);
235 const serverSettings = yield* ServerSettings.ServerSettingsService;
236 const runtimeModeCeiling = serverSettings.managedPolicy?.limits?.maxRuntimeMode;
237 const linksEnabled = serverSettings.getSettings.pipe(
238 Effect.map((settings) => settings.linkedEnvironmentsEnabled),
239 Effect.orElseSucceed(() => false),
240 );
241 const authenticateRequest = Effect.fn("McpHttpServer.authenticateRequest")(function* (
242 request: HttpServerRequest.HttpServerRequest,
243 ) {
244 const authorization = request.headers.authorization;
245 const token = parseBearerToken(request) ?? "";
246 const invocation =
247 (yield* registry.resolve(token)) ??
248 (Option.isSome(clients) && token.length > 0
249 ? yield* clients.value.authenticate(request)
250 : undefined);
251 if (!invocation) {
252 yield* Effect.logWarning("rejected MCP request with an unusable credential", {
253 reason:
254 authorization === undefined
255 ? "missing_authorization_header"
256 : authorization.split(" ", 1)[0]?.toLowerCase() !== "bearer"
257 ? "unsupported_scheme"
258 : token.length === 0
259 ? "missing_bearer_token"
260 : "unknown_or_expired_token",
261 });
262 return unauthorized({
263 request,
264 presentedToken: token.length > 0,
265 offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token),
266 });
267 }
268 const asked = withModeLimit(withSenderThread(invocation, request), request);
269 if (asked === undefined) return invalidModeLimit;
270 const limited = McpInvocationContext.withRuntimeModeCeiling(asked, runtimeModeCeiling);
271 const linkedEnvironments = yield* linksEnabled;
272 if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff;
273 return { ...limited, linkedEnvironments };
274 });
275 // Untraced: a span here would wrap the tool call; the HTTP server span already covers it.
276 return Effect.fnUntraced(function* (httpEffect) {
277 const admitted = yield* authenticateRequest(yield* HttpServerRequest.HttpServerRequest);
278 if (HttpServerResponse.isHttpServerResponse(admitted)) return admitted;
279 return yield* httpEffect.pipe(
280 Effect.provideService(McpInvocationContext.McpInvocationContext, admitted),
281 Effect.map(normalizeMcpHttpResponse),
282 );
283 }) satisfies McpAuthMiddleware;
284}).pipe(Effect.withSpan("McpHttpServer.makeAuthMiddleware"));
285
286const layerMcpAuthMiddleware = HttpRouter.middleware<{
287 provides: McpInvocationContext.McpInvocationContext;
288}>()(makeMcpAuthMiddleware).layer;
289
290/** Claude Code spills oversized results to files, losing locators; bound both text and its preferred structuredContent. */
291export const MAX_SNAPSHOT_TEXT_BYTES = 20_000;
292const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000;
293// Page text and the ARIA tree both shrink to this before either drops below it.
294const SNAPSHOT_TEXT_FLOOR_CHARS = 2_000;
295const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200;
296const MAX_SNAPSHOT_LOG_ENTRIES = 40;
297const MAX_SNAPSHOT_LOG_TEXT_CHARS = 500;
298const MAX_SNAPSHOT_IDENTIFIER_CHARS = 2_048;
299
300const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
301const utf8Length = (text: string) => Buffer.byteLength(text, "utf8");
302const cutText = (text: string, max: number) => {
303 if (text.length <= max) return text;
304 // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject.
305 const last = text.charCodeAt(max - 1);
306 return `${text.slice(0, last >= 0xd800 && last <= 0xdbff ? max - 1 : max)}…`;
307};
308
309const cutEntryStrings = <A>(entry: A): A =>
310 typeof entry === "object" && entry !== null
311 ? (Object.fromEntries(
312 Object.entries(entry).map(([key, value]) => [
313 key,
314 typeof value === "string" ? cutText(value, MAX_SNAPSHOT_LOG_TEXT_CHARS) : value,
315 ]),
316 ) as A)
317 : entry;
318
319const hasLongString = (entry: unknown, max: number) =>
320 typeof entry === "object" &&
321 entry !== null &&
322 Object.values(entry).some((value) => typeof value === "string" && value.length > max);
323
324type SnapshotMetadata = {
325 readonly url: string;
326 readonly title: string;
327 readonly visibleText: string;
328 readonly interactiveElements: ReadonlyArray<{
329 readonly name: string;
330 readonly [key: string]: unknown;
331 }>;
332 readonly consoleEntries: ReadonlyArray<unknown>;
333 readonly networkEntries: ReadonlyArray<unknown>;
334 readonly actionTimeline: ReadonlyArray<unknown>;
335 readonly [key: string]: unknown;
336};
337
338const halveToFloor = (chars: number) =>
339 chars > SNAPSHOT_TEXT_FLOOR_CHARS
340 ? Math.max(SNAPSHOT_TEXT_FLOOR_CHARS, Math.floor(chars / 2))
341 : Math.floor(chars / 2);
342
343const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
344 const omitted: Array<string> = [];
345 const { accessibilityTree, ...withoutTree } = metadata;
346 const ariaTree = typeof accessibilityTree === "string" ? accessibilityTree : undefined;
347 if (accessibilityTree !== undefined && ariaTree === undefined) {
348 omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)");
349 }
350 const tail = <A>(entries: ReadonlyArray<A>, label: string) => {
351 if (entries.length > MAX_SNAPSHOT_LOG_ENTRIES) {
352 omitted.push(`${entries.length - MAX_SNAPSHOT_LOG_ENTRIES} older ${label}`);
353 }
354 const kept = entries.slice(-MAX_SNAPSHOT_LOG_ENTRIES);
355 if (kept.some((entry) => hasLongString(entry, MAX_SNAPSHOT_LOG_TEXT_CHARS))) {
356 omitted.push(`${label} text after ${MAX_SNAPSHOT_LOG_TEXT_CHARS} characters`);
357 }
358 return kept.map(cutEntryStrings);
359 };
360 if (
361 metadata.url.length > MAX_SNAPSHOT_IDENTIFIER_CHARS ||
362 metadata.title.length > MAX_SNAPSHOT_IDENTIFIER_CHARS
363 ) {
364 omitted.push(`url or title after ${MAX_SNAPSHOT_IDENTIFIER_CHARS} characters`);
365 }
366 if (
367 metadata.interactiveElements.some(
368 (element) => element.name.length > MAX_SNAPSHOT_ELEMENT_NAME_CHARS,
369 )
370 ) {
371 omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`);
372 }
373 const bounded = {
374 ...withoutTree,
375 url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
376 title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS),
377 interactiveElements: metadata.interactiveElements.map((element) => ({
378 ...element,
379 name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS),
380 })),
381 consoleEntries: tail(metadata.consoleEntries, "console entries"),
382 networkEntries: tail(metadata.networkEntries, "network entries"),
383 actionTimeline: tail(metadata.actionTimeline, "action timeline entries"),
384 };
385
386 // Per-field character caps can still exceed the total byte ceiling.
387 const shedOrder = [
388 "actionTimeline",
389 "networkEntries",
390 "consoleEntries",
391 "interactiveElements",
392 ] as const;
393 const lists: Record<(typeof shedOrder)[number], ReadonlyArray<unknown>> = {
394 interactiveElements: bounded.interactiveElements,
395 consoleEntries: bounded.consoleEntries,
396 networkEntries: bounded.networkEntries,
397 actionTimeline: bounded.actionTimeline,
398 };
399 const dropped: Record<(typeof shedOrder)[number], number> = {
400 interactiveElements: 0,
401 consoleEntries: 0,
402 networkEntries: 0,
403 actionTimeline: 0,
404 };
405 let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS);
406 let ariaTreeChars = Math.min(ariaTree?.length ?? 0, 20_000);
407 const value = () => ({
408 ...bounded,
409 ...(ariaTree === undefined ? {} : { accessibilityTree: cutText(ariaTree, ariaTreeChars) }),
410 visibleText: cutText(metadata.visibleText, visibleTextChars),
411 ...lists,
412 });
413 let text = encodeJsonText(value());
414 while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) {
415 // Logs shed first, oldest first. Below the floors, locator-bearing elements and tree go last.
416 const key =
417 shedOrder.find(
418 (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0,
419 ) ??
420 (visibleTextChars > SNAPSHOT_TEXT_FLOOR_CHARS
421 ? "visibleText"
422 : ariaTreeChars > SNAPSHOT_TEXT_FLOOR_CHARS
423 ? "accessibilityTree"
424 : visibleTextChars > 0
425 ? "visibleText"
426 : lists.interactiveElements.length > 0
427 ? "interactiveElements"
428 : ariaTreeChars > 0
429 ? "accessibilityTree"
430 : undefined);
431 if (key === undefined) break;
432 if (key === "visibleText") {
433 visibleTextChars = halveToFloor(visibleTextChars);
434 } else if (key === "accessibilityTree") {
435 ariaTreeChars = halveToFloor(ariaTreeChars);
436 } else {
437 const keep = Math.floor(lists[key].length / 2);
438 dropped[key] += lists[key].length - keep;
439 lists[key] =
440 keep === 0
441 ? []
442 : key === "interactiveElements"
443 ? lists[key].slice(0, keep)
444 : lists[key].slice(-keep);
445 }
446 text = encodeJsonText(value());
447 }
448 if (visibleTextChars < metadata.visibleText.length) {
449 omitted.push(
450 `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`,
451 );
452 }
453 if (ariaTree !== undefined && ariaTreeChars < ariaTree.length) {
454 omitted.push(`accessibilityTree after ${ariaTreeChars} characters`);
455 }
456 for (const key of shedOrder) {
457 if (dropped[key] > 0) {
458 omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`);
459 }
460 }
461 return { value: value(), text, omitted };
462};
463
464export class PreviewScreenshotSaveError extends Schema.TaggedError<PreviewScreenshotSaveError>()(
465 "PreviewScreenshotSaveError",
466 { screenshotPath: Schema.String, cause: Schema.Defect() },
467) {
468 override get message(): string {
469 return `Could not save preview screenshot to ${this.screenshotPath}.`;
470 }
471}
472
473const MAX_SCREENSHOT_SITE_SLUG_LENGTH = 40;
474
475const screenshotSiteSlug = (rawUrl: string): string => {
476 try {
477 const slug = new URL(rawUrl).hostname
478 .toLowerCase()
479 .replace(/[^a-z0-9]+/g, "-")
480 .replace(/^-+|-+$/g, "")
481 .slice(0, MAX_SCREENSHOT_SITE_SLUG_LENGTH)
482 .replace(/-+$/g, "");
483 return slug || "site";
484 } catch {
485 return "site";
486 }
487};
488
489const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
490 pageUrl: string,
491 data: Uint8Array,
492) {
493 const config = yield* ServerConfig.ServerConfig;
494 const fileSystem = yield* FileSystem.FileSystem;
495 const path = yield* Path.Path;
496 const millis = yield* Clock.currentTimeMillis;
497 const random = yield* SecureRandom.SecureRandom;
498 const unique = (yield* random.uuidV4()).slice(0, 8);
499 const fileName = `browser-screenshot-${screenshotSiteSlug(pageUrl)}-${millis.toString(36)}-${unique}.png`;
500 const screenshotPath = path.join(config.browserArtifactsDir, fileName);
501 yield* fileSystem.makeDirectory(config.browserArtifactsDir, { recursive: true }).pipe(
502 Effect.andThen(fileSystem.writeFile(screenshotPath, data)),
503 Effect.mapError((cause) => new PreviewScreenshotSaveError({ screenshotPath, cause })),
504 );
505 return screenshotPath;
506});
507
508const MAX_NAMED_ARGUMENTS = 5;
509const MAX_ARGUMENT_NAME_CHARS = 64;
510
511/**
512 * Rejects top-level arguments the advertised schema does not list, naming them. Nested objects stay lenient:
513 * the advertised schema merges tagged unions, so strict decoding would reject keys agents were shown.
514 */
515const advertisedArgumentsOnly = (tool: Tool.Any) => {
516 const { properties } = advertisedJsonSchema(tool);
517 const accepted = new Set(Predicate.isObject(properties) ? Object.keys(properties) : []);
518 const acceptedText = [...accepted].join(", ") || "none";
519 return Schema.Record(Schema.String, Schema.Unknown).check(
520 Schema.makeFilter((input) => {
521 const unknown = Object.keys(input).filter((key) => !accepted.has(key));
522 if (unknown.length === 0) return undefined;
523 const named = unknown
524 .slice(0, MAX_NAMED_ARGUMENTS)
525 .map((key) => JSON.stringify(cutText(key, MAX_ARGUMENT_NAME_CHARS)));
526 const more =
527 unknown.length > named.length ? ` and ${unknown.length - named.length} more` : "";
528 return `Unknown argument${unknown.length === 1 ? "" : "s"} ${named.join(", ")}${more}. Accepted arguments: ${acceptedText}.`;
529 }),
530 );
531};
532
533/** Decode with the advertised JSON codec, which permits null for optional fields that the raw schema rejects. */
534const decodeAsAdvertised = <Tools extends Record<string, Tool.Any>>(
535 toolkit: Toolkit.Toolkit<Tools>,
536): Toolkit.Toolkit<Tools> =>
537 Toolkit.make(
538 ...Object.values(toolkit.tools).map((tool) =>
539 tool.setParameters(
540 advertisedArgumentsOnly(tool).pipe(
541 Schema.decodeTo(Schema.toCodecJson(tool.parametersSchema)),
542 ),
543 ),
544 ),
545 ) as unknown as Toolkit.Toolkit<Tools>;
546
547const parameterValidationMessage = (error: unknown) =>
548 AiError.isAiError(error) && error.reason._tag === "ToolParameterValidationError"
549 ? error.reason.message
550 : undefined;
551
552const isPreviewAutomationError = Schema.is(PreviewAutomationError);
553const isPreviewScreenshotSaveError = Schema.is(PreviewScreenshotSaveError);
554
555const previewSnapshotFailure = Effect.fn("previewSnapshotFailure")(function <E>(
556 cause: Cause.Cause<E>,
557) {
558 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
559 return Effect.failCause(cause).pipe(Effect.orDie);
560 }
561 const failures = cause.reasons.filter(Cause.isFailReason);
562 const firstFailure = failures[0]?.error;
563 const errorTag =
564 typeof firstFailure === "object" &&
565 firstFailure !== null &&
566 "_tag" in firstFailure &&
567 typeof firstFailure._tag === "string"
568 ? firstFailure._tag
569 : "PreviewSnapshotError";
570 // Preview errors contain server-authored recovery guidance, never page output.
571 const message =
572 isPreviewAutomationError(firstFailure) || isPreviewScreenshotSaveError(firstFailure)
573 ? firstFailure.message
574 : parameterValidationMessage(firstFailure);
575 const result = new McpSchema.CallToolResult({
576 isError: true,
577 structuredContent: {
578 error: {
579 _tag: errorTag,
580 operation: "snapshot",
581 failureCount: failures.length,
582 ...(message === undefined ? {} : { message }),
583 },
584 },
585 // Some clients show only the text content and others only structuredContent, so both carry it.
586 content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }],
587 });
588 return Effect.logWarning("preview snapshot failed", {
589 operation: "snapshot",
590 errorTag,
591 failureCount: failures.length,
592 }).pipe(Effect.as(result));
593});
594
595const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
596 const server = yield* McpServer.McpServer;
597 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
598 const threads = yield* ThreadManagementService.ThreadManagementService;
599 const saveServices = yield* Effect.context<
600 ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | SecureRandom.SecureRandom
601 >();
602 const built = yield* decodeAsAdvertised(PreviewSnapshotToolkit);
603 const tool = PreviewSnapshotTool;
604 yield* server.addTool({
605 tool: new McpSchema.Tool({
606 name: tool.name,
607 description: Tool.getDescription(tool),
608 inputSchema: advertisedJsonSchema(tool),
609 annotations: {
610 ...Context.getOption(tool.annotations, Tool.Title).pipe(
611 Option.map((title) => ({ title })),
612 Option.getOrUndefined,
613 ),
614 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
615 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
616 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
617 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
618 },
619 }),
620 annotations: tool.annotations,
621 handle: (payload) =>
622 Effect.withFiber((fiber) => {
623 const invocation = Context.getUnsafe(
624 fiber.context,
625 McpInvocationContext.McpInvocationContext,
626 );
627 return built.handle("preview_snapshot", payload).pipe(
628 Stream.unwrap,
629 Stream.run(Sink.last()),
630 Effect.flatMap(Effect.fromOption),
631 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
632 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
633 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
634 Effect.flatMap(({ encodedResult }) =>
635 Effect.gen(function* () {
636 const snapshot = encodedResult as SnapshotMetadata & {
637 readonly url: string;
638 readonly screenshot: {
639 readonly mimeType: "image/png";
640 readonly data: string;
641 readonly width: number;
642 readonly height: number;
643 };
644 readonly fullScreenshot?: { readonly data: string };
645 };
646 const { screenshot, fullScreenshot, ...page } = snapshot;
647 const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
648 const screenshotPath =
649 payload?.save === true
650 ? yield* saveScreenshot(
651 snapshot.url,
652 fullScreenshot
653 ? new Uint8Array(Buffer.from(fullScreenshot.data, "base64"))
654 : png,
655 )
656 : undefined;
657 // Providers replay images on later requests, and some reject them; include images only when requested.
658 const includeImage = payload?.includeImage === true;
659 if (screenshotPath !== undefined && !includeImage) {
660 const saved = {
661 url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
662 screenshotPath,
663 };
664 return new McpSchema.CallToolResult({
665 isError: false,
666 structuredContent: saved,
667 content: [{ type: "text", text: encodeJsonText(saved) }],
668 });
669 }
670 const metadata = {
671 ...page,
672 screenshot: {
673 mimeType: screenshot.mimeType,
674 width: screenshot.width,
675 height: screenshot.height,
676 },
677 ...(screenshotPath === undefined ? {} : { screenshotPath }),
678 };
679 const bounded = boundSnapshotMetadata(metadata);
680 return new McpSchema.CallToolResult({
681 isError: false,
682 structuredContent:
683 bounded.omitted.length === 0
684 ? bounded.value
685 : { ...bounded.value, omitted: bounded.omitted },
686 content: [
687 {
688 type: "text",
689 text: encodeJsonText({
690 url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
691 }),
692 },
693 { type: "text", text: bounded.text },
694 ...(bounded.omitted.length === 0
695 ? []
696 : [
697 {
698 type: "text" as const,
699 text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`,
700 },
701 ]),
702 ...(includeImage
703 ? [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]
704 : []),
705 ],
706 });
707 }),
708 ),
709 Effect.provide(saveServices),
710 Effect.matchCauseEffect({
711 onFailure: previewSnapshotFailure,
712 onSuccess: Effect.succeed,
713 }),
714 );
715 }),
716 });
717});
718
719interface ImageToolResult {
720 readonly screenshot: {
721 readonly mimeType: "image/png";
722 readonly data: string;
723 readonly width: number;
724 readonly height: number;
725 };
726 readonly [key: string]: unknown;
727}
728
729/** Expose only undescribed failures' tags; remote messages may contain renderer or device output. */
730const imageToolFailure =
731 <E>(toolName: string, operation: string, failureText: string | ((error: E) => string)) =>
732 (cause: Cause.Cause<E>) => {
733 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
734 return Effect.failCause(cause).pipe(Effect.orDie);
735 }
736 const failures = cause.reasons.filter(Cause.isFailReason);
737 const firstFailure = failures[0]?.error;
738 const errorTag =
739 typeof firstFailure === "object" &&
740 firstFailure !== null &&
741 "_tag" in firstFailure &&
742 typeof firstFailure._tag === "string"
743 ? firstFailure._tag
744 : `${toolName}Error`;
745 const message =
746 typeof failureText === "string" || failures[0] === undefined
747 ? undefined
748 : failureText(failures[0].error);
749 const result = new McpSchema.CallToolResult({
750 isError: true,
751 structuredContent: {
752 error: {
753 _tag: errorTag,
754 operation,
755 failureCount: failures.length,
756 ...(message === undefined ? {} : { message }),
757 },
758 },
759 // Some clients show only the text content and others only structuredContent, so both carry it.
760 content: [
761 {
762 type: "text",
763 text: typeof failureText === "string" ? failureText : (message ?? `${toolName} failed.`),
764 },
765 ],
766 });
767 return Effect.logWarning(`${toolName} failed`, {
768 operation,
769 errorTag,
770 failureCount: failures.length,
771 }).pipe(Effect.as(result));
772 };
773
774/**
775 * `McpServer.toolkit` serializes every result as JSON text, which is the wrong
776 * shape for a screenshot: the model needs image content.
777 */
778const registerImageTool = Effect.fn("McpHttpServer.registerImageTool")(function* <
779 T extends Tool.Any,
780 E,
781 R,
782>(
783 tool: T,
784 handle: (payload: Schema.Json) => Effect.Effect<{ readonly encodedResult: unknown }, E, R>,
785 provide: (
786 effect: Effect.Effect<{ readonly encodedResult: unknown }, E, R>,
787 ) => Effect.Effect<
788 { readonly encodedResult: unknown },
789 E,
790 McpInvocationContext.McpInvocationContext
791 >,
792 operation: string,
793 failureText: string | ((error: E) => string),
794) {
795 const server = yield* McpServer.McpServer;
796 yield* server.addTool({
797 tool: new McpSchema.Tool({
798 name: tool.name,
799 description: Tool.getDescription(tool),
800 inputSchema: advertisedJsonSchema(tool),
801 annotations: {
802 ...Context.getOption(tool.annotations, Tool.Title).pipe(
803 Option.map((title) => ({ title })),
804 Option.getOrUndefined,
805 ),
806 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
807 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
808 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
809 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
810 },
811 }),
812 annotations: tool.annotations,
813 handle: (payload) =>
814 Effect.withFiber((fiber) => {
815 const invocation = Context.getUnsafe(
816 fiber.context,
817 McpInvocationContext.McpInvocationContext,
818 );
819 return provide(handle(payload)).pipe(
820 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
821 Effect.matchCauseEffect({
822 onFailure: imageToolFailure(tool.name, operation, failureText),
823 onSuccess: ({ encodedResult }) => {
824 const { screenshot, ...rest } = encodedResult as ImageToolResult;
825 const metadata = {
826 ...rest,
827 screenshot: {
828 mimeType: screenshot.mimeType,
829 width: screenshot.width,
830 height: screenshot.height,
831 },
832 };
833 return Effect.succeed(
834 new McpSchema.CallToolResult({
835 isError: false,
836 structuredContent: metadata,
837 content: [
838 { type: "text", text: JSON.stringify(metadata) },
839 {
840 type: "image" as const,
841 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
842 mimeType: screenshot.mimeType,
843 },
844 ],
845 }),
846 );
847 },
848 }),
849 );
850 }),
851 });
852});
853
854const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () {
855 const devices = yield* DeviceService.DeviceService;
856 const threads = yield* ThreadManagementService.ThreadManagementService;
857 const built = yield* decodeAsAdvertised(DeviceScreenshotToolkit);
858 yield* registerImageTool(
859 DeviceScreenshotTool,
860 (payload) =>
861 built
862 .handle("device_screenshot", payload)
863 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
864 (effect) =>
865 effect.pipe(
866 Effect.provideService(DeviceService.DeviceService, devices),
867 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
868 ),
869 "screenshot",
870 (error) => {
871 if (!isDeviceToolError(error)) {
872 return isOrchestratorMcpFailure(error)
873 ? error.message
874 : (parameterValidationMessage(error) ?? "Device screenshot failed.");
875 }
876 return error._tag === "DeviceOperationError" && error.reason === "hub_rejected"
877 ? `${error.message} Call device_list to check the device state, and device_open if it needs to be reopened.`
878 : error.message;
879 },
880 );
881});
882
883const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);
884const isDeviceToolError = Schema.is(DeviceToolError);
885
886const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () {
887 const htmlRender = yield* HtmlRender.HtmlRender;
888 const threads = yield* ThreadManagementService.ThreadManagementService;
889 const built = yield* decodeAsAdvertised(HtmlPreviewToolkit);
890 yield* registerImageTool(
891 HtmlPreviewTool,
892 (payload) =>
893 built
894 .handle("html_preview", payload)
895 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
896 (effect) =>
897 effect.pipe(
898 Effect.provideService(HtmlRender.HtmlRender, htmlRender),
899 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
900 ),
901 "preview",
902
903 (error) =>
904 isOrchestratorMcpFailure(error) || AiError.isAiError(error)
905 ? error.message
906 : "HTML preview failed.",
907 );
908});
909
910/**
911 * Tools that hand work to another thread's agent: they create it, message it,
912 * steer it, or move context into it. Their events name the receiving agent too.
913 */
914const HANDOFF_TOOLS: ReadonlySet<string> = new Set([
915 "delegate_task",
916 "create_threads",
917 "t3_thread_launch",
918 "t3_thread_send",
919 "t3_thread_send_attachments",
920 "schedule_task",
921 "run_scheduled_task_now",
922]);
923
924/** Tool actions that hand work over with only a `{ sequence }` result; the input names the thread. */
925const INPUT_TARGET_ACTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
926 t3_queue: new Set(["edit", "promote_to_steer"]),
927 t3_pending_request: new Set(["respond"]),
928};
929
930/** Tool actions that hand work over and name the receiving thread in their result. */
931const RESULT_TARGET_ACTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
932 t3_thread_transfer: new Set(["fork", "merge_back"]),
933};
934
935const hasAction = (
936 actions: Readonly<Record<string, ReadonlySet<string>>>,
937 tool: string,
938 args: unknown,
939) =>
940 actions[tool]?.has(String((args as { readonly action?: unknown } | null | undefined)?.action)) ===
941 true;
942
943const isInputTargetHandoff = (tool: string, args: unknown) =>
944 hasAction(INPUT_TARGET_ACTIONS, tool, args);
945
946const resultThreadKeys = ["childThreadId", "targetThreadId", "boundThreadId", "threadId"] as const;
947
948/** Thread ids a handoff tool result names: the thread that received the work. */
949const resultThreadIds = (content: unknown): ReadonlyArray<string> => {
950 if (typeof content !== "object" || content === null) return [];
951 const record = content as Readonly<Record<string, unknown>>;
952 if (Array.isArray(record.threads)) return record.threads.flatMap(resultThreadIds);
953 const key = resultThreadKeys.find((candidate) => typeof record[candidate] === "string");
954 return key === undefined ? [] : [record[key] as string];
955};
956
957/**
958 * Thread ids that received a handoff. The input's `threadId` is used only after
959 * the call succeeded, so it is one the handler already resolved and authorized.
960 */
961const handoffTargetIds = (
962 tool: string,
963 args: unknown,
964 result: McpSchema.CallToolResult | undefined,
965): ReadonlyArray<string> => {
966 if (!isInputTargetHandoff(tool, args)) return resultThreadIds(result?.structuredContent);
967 if (result === undefined || toolOutcome(result).outcome !== "ok") return [];
968 const threadId = (args as { readonly threadId?: unknown } | null | undefined)?.threadId;
969 return typeof threadId === "string" ? [threadId] : [];
970};
971
972/** Tool analytics must never include IDs, prompts or free text. */
973const withToolAnalytics =
974 /* Request instrumentation already owns the span; keep this helper in that context. */
975 Effect.fnUntraced(function* <A, E, R>(registration: Effect.Effect<A, E, R>) {
976 const server = yield* McpServer.McpServer;
977 const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService);
978 if (Option.isNone(analytics)) return yield* registration;
979 const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry);
980 const threads = yield* Effect.serviceOption(ThreadManagementService.ThreadManagementService);
981 const shellOf = (threadId: string) =>
982 Option.isNone(threads)
983 ? Effect.succeed(null)
984 : threads.value
985 .getThreadShell(ThreadId.make(threadId))
986 .pipe(Effect.orElseSucceed(() => null));
987 // Delegation depth: parents walked from the caller, capped.
988 const depthOf =
989 /* Request instrumentation already owns the span; keep this helper in that context. */
990 Effect.fnUntraced(function* (thread: AgentThread) {
991 let depth = 0;
992 let parentId =
993 thread.lineage.relationshipToParent === "subagent" ? thread.lineage.parentThreadId : null;
994 while (parentId !== null && depth < MAX_REPORTED_DEPTH) {
995 depth += 1;
996 const parent = yield* shellOf(parentId);
997 parentId =
998 parent?.lineage.relationshipToParent === "subagent"
999 ? parent.lineage.parentThreadId
1000 : null;
1001 }
1002 return depth;
1003 });
1004 // Whether the caller's active run was started by a scheduled task.
1005 const scheduledRunOf = (threadId: string, runId: RunId | null) =>
1006 Option.isNone(threads) || runId === null
1007 ? Effect.succeed(false)
1008 : Effect.gen(function* () {
1009 const id = ThreadId.make(threadId);
1010 const { runs } = yield* threads.value.getThreadRecords(id, ["runs"], {
1011 runIds: [runId],
1012 });
1013 const run = runs.find((candidate) => candidate.id === runId);
1014 if (run === undefined) return false;
1015 const messages = yield* threads.value.getMessagesById(id, [run.userMessageId]);
1016 return messages.get(run.userMessageId)?.scheduledTaskId !== undefined;
1017 }).pipe(Effect.orElseSucceed(() => false));
1018 const record =
1019 /* Request instrumentation already owns the span; keep this helper in that context. */
1020 Effect.fnUntraced(
1021 function* (
1022 tool: string,
1023 args: unknown,
1024 result: McpSchema.CallToolResult | undefined,
1025 durationMs: number,
1026 ) {
1027 const handoff =
1028 HANDOFF_TOOLS.has(tool) ||
1029 isInputTargetHandoff(tool, args) ||
1030 hasAction(RESULT_TARGET_ACTIONS, tool, args);
1031 const outcome = toolOutcome(result);
1032 // Agents call status, read, and preview tools in tight loops, so an
1033 // event per successful call floods analytics. Only failures count there.
1034 if (!handoff && outcome.outcome === "ok") return;
1035 // Client credentials (no calling thread) report an unknown caller.
1036 const invocation = Option.getOrUndefined(
1037 yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext),
1038 )?.thread;
1039 const providers = Option.isSome(registry) ? yield* registry.value.getProviders : [];
1040 // Only handoff tools pay for thread lookups; the rest report the
1041 // caller's provider from the credential.
1042 const caller =
1043 handoff && invocation !== undefined
1044 ? ((yield* shellOf(invocation.threadId)) ?? undefined)
1045 : undefined;
1046 const targetIds = handoff
1047 ? [...new Set(handoffTargetIds(tool, args, result))].filter(
1048 (threadId) => threadId !== invocation?.threadId,
1049 )
1050 : [];
1051 const targets = yield* Effect.forEach(targetIds, shellOf);
1052 for (const properties of agentToolProperties({
1053 tool,
1054 providers,
1055 caller,
1056 ...(invocation === undefined
1057 ? {}
1058 : { callerProviderInstanceId: invocation.providerInstanceId }),
1059 ...(caller === undefined || invocation === undefined
1060 ? {}
1061 : {
1062 callerDepth: yield* depthOf(caller),
1063 callerScheduledRun: yield* scheduledRunOf(
1064 invocation.threadId,
1065 caller.activeRunId,
1066 ),
1067 }),
1068 targets: targets.filter((thread) => thread !== null),
1069 outcome,
1070 ...(handoff
1071 ? { settings: handoffSettings(tool, args, result?.structuredContent) }
1072 : {}),
1073 durationMs,
1074 })) {
1075 yield* analytics.value.record("mcp.tool.invoked", properties);
1076 }
1077 },
1078 (effect, _tool, _args, _result, _durationMs) => effect.pipe(Effect.ignoreCause),
1079 );
1080 const recordingServer = McpServer.McpServer.of({
1081 ...server,
1082 addTool: Effect.fn("McpServer.addTool")(function (options) {
1083 return server.addTool({
1084 ...options,
1085 handle:
1086 /* Request instrumentation already owns the span; keep this helper in that context. */
1087 Effect.fnUntraced(function* (payload) {
1088 const startedAt = yield* Clock.currentTimeMillis;
1089 return yield* options.handle(payload).pipe(
1090 Effect.onExit((exit) => {
1091 const result = exit._tag === "Success" ? exit.value : undefined;
1092 // A call that asks for input is retried with it, and that call is the one counted.
1093 if (result instanceof McpSchema.InputRequired) return Effect.void;
1094 // Handoff tools look up threads to record; the response must not wait on them.
1095 return Clock.currentTimeMillis.pipe(
1096 Effect.flatMap((endedAt) =>
1097 record(options.tool.name, payload, result, endedAt - startedAt),
1098 ),
1099 Effect.forkDetach,
1100 Effect.asVoid,
1101 );
1102 }),
1103 );
1104 }),
1105 });
1106 }),
1107 });
1108 return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer));
1109 });
1110
1111/** Each tool call gets a span named for its tool; arguments stay out, as they carry prompts and IDs. */
1112const withToolSpans = Effect.fn("McpHttpServer.withToolSpans")(function* <A, E, R>(
1113 registration: Effect.Effect<A, E, R>,
1114) {
1115 const server = yield* McpServer.McpServer;
1116 const tracingServer = McpServer.McpServer.of({
1117 ...server,
1118 addTool: Effect.fn("McpServer.addTool")(function (options) {
1119 const name = options.tool.name;
1120 return server.addTool({
1121 ...options,
1122 // Never the current span: Toolkit.handle annotates that one with the raw arguments.
1123 handle: (payload) =>
1124 Effect.useSpan(
1125 `mcp.tool.${name}`,
1126 {
1127 attributes: {
1128 "rpc.system": "mcp",
1129 "rpc.method": "tools/call",
1130 "mcp.tool.name": name,
1131 },
1132 },
1133 () => options.handle(payload),
1134 ),
1135 });
1136 }),
1137 });
1138 return yield* registration.pipe(Effect.provideService(McpServer.McpServer, tracingServer));
1139});
1140
1141/** `McpServer.registerToolkit` advertises the full codec schema; agents get the compact one. */
1142const withAdvertisedSchemas = Effect.fn("McpHttpServer.withAdvertisedSchemas")(function* <
1143 Tools extends Record<string, Tool.Any>,
1144 A,
1145 E,
1146 R,
1147>(toolkit: Toolkit.Toolkit<Tools>, registration: Effect.Effect<A, E, R>) {
1148 const server = yield* McpServer.McpServer;
1149 const tools: Record<string, Tool.Any> = toolkit.tools;
1150 const advertisingServer = McpServer.McpServer.of({
1151 ...server,
1152 addTool: Effect.fn("McpServer.addTool")(function (options) {
1153 const tool = tools[options.tool.name];
1154 return server.addTool(
1155 tool === undefined
1156 ? options
1157 : {
1158 ...options,
1159 tool: new McpSchema.Tool({
1160 ...options.tool,
1161 inputSchema: advertisedJsonSchema(tool),
1162 }),
1163 },
1164 );
1165 }),
1166 });
1167 return yield* registration.pipe(Effect.provideService(McpServer.McpServer, advertisingServer));
1168});
1169
1170/** Hands each call the services its registration was built with; the request's own still win. */
1171const provideRegistrationServices = <Services>() =>
1172 McpServer.middleware<{ provides: Services }>()(
1173 Effect.map(
1174 Effect.context<Services>(),
1175 (services) => (effect) =>
1176 Effect.updateContext(effect, (request: Context.Context<never>) =>
1177 Context.merge(services, request),
1178 ),
1179 ),
1180 );
1181
1182/**
1183 * Tools declare their handler services as request-level dependencies. Those
1184 * built with the server are provided here; `McpInvocationContext` is left to
1185 * the transport, whose auth middleware resolves it per request.
1186 */
1187export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX, RX>(
1188 toolkit: Toolkit.Toolkit<Tools>,
1189 handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
1190) =>
1191 Layer.effectDiscard(
1192 withToolAnalytics(
1193 withToolSpans(
1194 withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))),
1195 ),
1196 ),
1197 ).pipe(
1198 Layer.provide(
1199 provideRegistrationServices<
1200 Exclude<Tool.Requirements<Tools[keyof Tools]>, McpInvocationContext.McpInvocationContext>
1201 >().layer,
1202 ),
1203 Layer.provide(McpServer.McpServer.layer),
1204 Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)),
1205 );
1206
1207const imageToolRegistration = <Tools extends Record<string, Tool.Any>, A, E, R, EX, RX>(
1208 register: Effect.Effect<A, E, R>,
1209 handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
1210) =>
1211 Layer.effectDiscard(withToolAnalytics(withToolSpans(register))).pipe(
1212 Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)),
1213 );
1214
1215export const layerHtmlToolkit = Layer.mergeAll(
1216 toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender),
1217 imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview),
1218).pipe(Layer.provide(HtmlRender.layer));
1219
1220const layerPreviewStandardToolkitRegistration = toolkitRegistration(
1221 PreviewStandardToolkit,
1222 PreviewHandlers.layerStandard,
1223);
1224
1225const layerPreviewSnapshotRegistration = imageToolRegistration(
1226 registerPreviewSnapshot(),
1227 PreviewHandlers.layerSnapshot,
1228);
1229
1230export const layerPreviewToolkit = Layer.mergeAll(
1231 layerPreviewStandardToolkitRegistration,
1232 layerPreviewSnapshotRegistration,
1233);
1234
1235export const layerOrchestratorToolkit = toolkitRegistration(
1236 OrchestratorToolkit,
1237 OrchestratorHandlers.layer,
1238).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer));
1239
1240export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
1241
1242const layerWorktreeToolkitRegistration = toolkitRegistration(
1243 WorktreeToolkit,
1244 WorktreeHandlers.layer,
1245).pipe(Layer.provide(WorktreeMcpService.layer));
1246
1247const layerPreviewControlsRegistration = toolkitRegistration(
1248 PreviewControlsToolkit,
1249 PreviewControlsHandlers.layer,
1250);
1251
1252const layerEnvironmentToolkit = toolkitRegistration(
1253 EnvironmentToolkit,
1254 EnvironmentHandlers.layer,
1255).pipe(Layer.provide(ThreadCommandExecutor.layer));
1256
1257export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
1258
1259export const layerAttachmentToolkit = toolkitRegistration(
1260 AttachmentToolkit,
1261 AttachmentHandlers.layer,
1262);
1263
1264export const layerPullRequestsToolkit = toolkitRegistration(
1265 PullRequestsToolkit,
1266 PullRequestsHandlers.layer,
1267).pipe(Layer.provide(PullRequestWatchService.layer));
1268
1269const layerDeviceStandardToolkitRegistration = toolkitRegistration(
1270 DeviceStandardToolkit,
1271 DeviceHandlers.layerStandard,
1272);
1273
1274const layerDeviceScreenshotRegistration = imageToolRegistration(
1275 registerDeviceScreenshot(),
1276 DeviceHandlers.layerScreenshot,
1277);
1278
1279export const layerDeviceToolkit = Layer.mergeAll(
1280 layerDeviceStandardToolkitRegistration,
1281 layerDeviceScreenshotRegistration,
1282);
1283
1284/**
1285 * `McpServer` for in-process calls, which provide `McpInvocationContext`
1286 * around `callTool` as the auth middleware does for each /mcp request.
1287 */
1288export const layerInProcessMcpServer = McpServer.middleware<{
1289 provides: McpInvocationContext.McpInvocationContext;
1290}>()(
1291 (effect) =>
1292 Effect.flatMap(
1293 Effect.serviceOption(McpInvocationContext.McpInvocationContext),
1294 Option.match({
1295 onNone: () => Effect.die("Provide McpInvocationContext around the in-process MCP call."),
1296 onSome: (scope) =>
1297 Effect.provideService(effect, McpInvocationContext.McpInvocationContext, scope),
1298 }),
1299 ),
1300 { global: true },
1301).pipe(Layer.provideMerge(McpServer.McpServer.layer));
1302
1303export const layerMcpTransport = <RA, RE, RR>(registrations: Layer.Layer<RA, RE, RR>) =>
1304 McpServer.layerHttp({
1305 name: "T3 Code",
1306 version: packageJson.version,
1307 path: "/mcp",
1308 // The first protocol is the fallback for clients that offer an unknown version.
1309 protocols: [McpProtocol.v2025_06_18, McpProtocol.v2026_07_28],
1310 allowSessionTermination: true,
1311 registrations,
1312 }).pipe(Layer.provide(layerMcpAuthMiddleware));
1313
1314/** Every tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */
1315export const layerTools = Layer.mergeAll(
1316 layerPreviewToolkit,
1317 layerOrchestratorToolkit,
1318 layerThreadToolkit,
1319 layerAttachmentToolkit,
1320 layerProjectRegistration,
1321 layerEnvironmentToolkit,
1322 layerPreviewControlsRegistration,
1323 layerWorktreeToolkitRegistration,
1324 layerPullRequestsToolkit,
1325 layerDeviceToolkit,
1326 layerHtmlToolkit,
1327);
1328
1329const layerToolExposure = Layer.effect(
1330 McpToolExposure.McpToolExposure,
1331 Effect.gen(function* () {
1332 const server = yield* McpServer.McpServer;
1333 return McpToolExposure.make(
1334 () =>
1335 server.tools.map(({ tool }) => ({ name: tool.name, description: tool.description ?? "" })),
1336 () => server.notifications["notifications/tools/list_changed"]({}),
1337 );
1338 }),
1339);
1340
1341/**
1342 * A provider session that sends `MCP_TOOL_EXPOSURE_HEADER: deferred` lists only
1343 * the direct tools and what its `tool_search` calls loaded. Calls are never
1344 * gated, and every other client lists everything but `tool_search`.
1345 */
1346const layerDeferredListing = McpServer.visibility(
1347 Effect.gen(function* () {
1348 const exposure = yield* McpToolExposure.McpToolExposure;
1349 // Untraced: it runs once per tool on every list request.
1350 return Effect.fnUntraced(function* ({ method, target, headers }: McpServer.MiddlewareOptions) {
1351 if (method !== "tools/list") return true;
1352 const scope = yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext);
1353 const providerSessionId = Option.getOrUndefined(scope)?.thread?.providerSessionId;
1354 if (headers[MCP_TOOL_EXPOSURE_HEADER] !== "deferred" || providerSessionId === undefined) {
1355 return target !== McpToolExposure.TOOL_SEARCH;
1356 }
1357 return exposure.isListed(providerSessionId, target);
1358 });
1359 }),
1360 { global: true },
1361);
1362
1363/** Outside layerTools: the clients its budget measures never list `tool_search`. */
1364export const layerToolSearch = Layer.mergeAll(
1365 toolkitRegistration(ToolSearchToolkit, ToolSearchHandlers.layer),
1366 layerDeferredListing,
1367).pipe(Layer.provide(layerToolExposure));
1368
1369export const layer = layerMcpTransport(Layer.mergeAll(layerTools, layerToolSearch));