1import { SERVER_VERSION } from "../version.ts";
2import * as Cause from "effect/Cause";
3import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
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, type Toolkit } from "effect/ai";
16import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
17import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
18
19import * as ServerConfig from "../config.ts";
20import * as DeviceService from "../device/DeviceService.ts";
21import * as HtmlRender from "../htmlRender/HtmlRender.ts";
22import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
23import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
24import * as McpInvocationContext from "./McpInvocationContext.ts";
25import * as McpToolAccess from "./McpToolAccess.ts";
26import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
27import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
28import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts";
29import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
30import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts";
31import { ProjectToolkit } from "./toolkits/project/tools.ts";
32import * as ProjectHandlers from "./toolkits/project/handlers.ts";
33import { AttachmentToolkit } from "./toolkits/attachment/tools.ts";
34import * as AttachmentHandlers from "./toolkits/attachment/handlers.ts";
35import { ThreadToolkit } from "./toolkits/thread/tools.ts";
36import * as ThreadHandlers from "./toolkits/thread/handlers.ts";
37import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
38import * as McpSessionRegistry from "./McpSessionRegistry.ts";
39import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
40import * as OrchestratorHandlers from "./toolkits/orchestrator/handlers.ts";
41import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts";
42import * as PreviewHandlers from "./toolkits/preview/handlers.ts";
43import {
44 PreviewSnapshotTool,
45 PreviewSnapshotToolkit,
46 PreviewStandardToolkit,
47} from "./toolkits/preview/tools.ts";
48import * as WorktreeHandlers from "./toolkits/worktree/handlers.ts";
49import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
50import * as WorktreeMcpService from "./WorktreeMcpService.ts";
51import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
52import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
53import * as DeviceHandlers from "./toolkits/device/handlers.ts";
54import {
55 DeviceScreenshotTool,
56 DeviceScreenshotToolkit,
57 DeviceStandardToolkit,
58} from "./toolkits/device/tools.ts";
59import * as HtmlHandlers from "./toolkits/html/handlers.ts";
60import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts";
61
62/** Where an MCP client discovers how to sign in (RFC 9728), at this request's own origin. */
63const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) =>
64 HttpServerRequest.toURL(request).pipe(
65 Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`),
66 );
67
68/**
69 * Agents T3 Code launched carry a registry token and must never be sent into
70 * an OAuth flow when it dies: they cannot open a browser, and a sign-in
71 * would mint a credential that outlives their session. Only a request that
72 * does not look like a provider token is pointed at the OAuth metadata.
73 */
74const unauthorized = (input: {
75 readonly request: HttpServerRequest.HttpServerRequest;
76 readonly presentedToken: boolean;
77 readonly offerOAuth: boolean;
78}) => {
79 const metadataUrl = input.offerOAuth
80 ? Option.getOrUndefined(mcpResourceMetadataUrl(input.request))
81 : undefined;
82 const challenge = [
83 "Bearer",
84 [
85 ...(metadataUrl === undefined ? [] : [`resource_metadata="${metadataUrl}"`]),
86 ...(input.presentedToken ? ['error="invalid_token"'] : []),
87 ].join(", "),
88 ]
89 .filter((part) => part.length > 0)
90 .join(" ");
91 return HttpServerResponse.jsonUnsafe(
92 {
93 error: "invalid_mcp_credential",
94 message: "A valid T3 Code MCP credential is required.",
95 },
96 {
97 status: 401,
98 headers: {
99 "cache-control": "no-store",
100 "www-authenticate": challenge,
101 },
102 },
103 );
104};
105
106/**
107 * Resolves a bearer token that is not a provider-session token: an OAuth
108 * client signed in from outside T3. Undefined when the token is not one.
109 */
110export class McpClientAuthenticator extends Context.Service<
111 McpClientAuthenticator,
112 {
113 readonly authenticate: (
114 request: HttpServerRequest.HttpServerRequest,
115 ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>;
116 }
117>()("t3/mcp/McpHttpServer/McpClientAuthenticator") {}
118
119type AuthenticatedHttpEffect = Effect.Effect<
120 HttpServerResponse.HttpServerResponse,
121 Types.unhandled,
122 McpInvocationContext.McpInvocationContext
123>;
124
125type McpAuthMiddleware = (
126 httpEffect: AuthenticatedHttpEffect,
127) => Effect.Effect<
128 HttpServerResponse.HttpServerResponse,
129 Types.unhandled,
130 HttpServerRequest.HttpServerRequest
131>;
132
133export const normalizeMcpHttpResponse = (
134 response: HttpServerResponse.HttpServerResponse,
135): HttpServerResponse.HttpServerResponse => {
136 const bodyIsEmpty =
137 response.body._tag === "Empty" ||
138 (response.body._tag === "Uint8Array" && response.body.contentLength === 0) ||
139 (response.body._tag === "Raw" && response.body.contentLength === 0);
140 return response.status === 200 && bodyIsEmpty
141 ? HttpServerResponse.setStatus(response, 202)
142 : response;
143};
144
145// Session tokens are `<payload>.<signature>`; registry tokens are a bare base64url secret.
146const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes(".");
147
148const makeMcpAuthMiddleware = Effect.gen(function* () {
149 const registry = yield* McpSessionRegistry.McpSessionRegistry;
150 const clients = yield* Effect.serviceOption(McpClientAuthenticator);
151 return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) {
152 const request = yield* HttpServerRequest.HttpServerRequest;
153 const authorization = request.headers.authorization;
154 const token =
155 authorization?.startsWith("Bearer ") === true
156 ? authorization.slice("Bearer ".length).trim()
157 : "";
158 const invocation =
159 (yield* registry.resolve(token)) ??
160 (Option.isSome(clients) && token.length > 0
161 ? yield* clients.value.authenticate(request)
162 : undefined);
163 if (!invocation) {
164 // Without this the only symptom of a dead credential is the agent
165 // quietly losing the whole `t3-code` toolkit for the rest of its
166 // session, with nothing on the server to explain why.
167 yield* Effect.logWarning("rejected MCP request with an unusable credential", {
168 reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token",
169 });
170 return unauthorized({
171 request,
172 presentedToken: token.length > 0,
173 offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token),
174 });
175 }
176 return yield* httpEffect.pipe(
177 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
178 Effect.map(normalizeMcpHttpResponse),
179 );
180 }) satisfies McpAuthMiddleware;
181}).pipe(Effect.withSpan("McpHttpServer.makeAuthMiddleware"));
182
183const layerMcpAuthMiddleware = HttpRouter.middleware<{
184 provides: McpInvocationContext.McpInvocationContext;
185}>()(makeMcpAuthMiddleware).layer;
186
187/**
188 * Claude Code moves an MCP result above its output limit to a file and hands
189 * the agent a notice instead, so a snapshot that carries the full
190 * accessibility tree and page text loses its locators too. Claude Code also
191 * shows the model `structuredContent` in place of the text blocks when a
192 * result has both, so both carry the same bounded snapshot. Keep it near
193 * 20 KB and tell the agent what was cut. The short `omitted` notes may go a
194 * little over; the provider limit is far above this.
195 */
196export const MAX_SNAPSHOT_TEXT_BYTES = 20_000;
197const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000;
198const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200;
199const MAX_SNAPSHOT_LOG_ENTRIES = 40;
200const MAX_SNAPSHOT_LOG_TEXT_CHARS = 500;
201const MAX_SNAPSHOT_IDENTIFIER_CHARS = 2_048;
202
203const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
204const utf8Length = (text: string) => Buffer.byteLength(text, "utf8");
205const cutText = (text: string, max: number) =>
206 text.length > max ? `${text.slice(0, max)}…` : text;
207
208/** Shortens every string field of a log entry; other fields pass through. */
209const cutEntryStrings = <A>(entry: A): A =>
210 typeof entry === "object" && entry !== null
211 ? (Object.fromEntries(
212 Object.entries(entry).map(([key, value]) => [
213 key,
214 typeof value === "string" ? cutText(value, MAX_SNAPSHOT_LOG_TEXT_CHARS) : value,
215 ]),
216 ) as A)
217 : entry;
218
219const hasLongString = (entry: unknown, max: number) =>
220 typeof entry === "object" &&
221 entry !== null &&
222 Object.values(entry).some((value) => typeof value === "string" && value.length > max);
223
224type SnapshotMetadata = {
225 readonly url: string;
226 readonly title: string;
227 readonly visibleText: string;
228 readonly interactiveElements: ReadonlyArray<{
229 readonly name: string;
230 readonly [key: string]: unknown;
231 }>;
232 readonly consoleEntries: ReadonlyArray<unknown>;
233 readonly networkEntries: ReadonlyArray<unknown>;
234 readonly actionTimeline: ReadonlyArray<unknown>;
235 readonly [key: string]: unknown;
236};
237
238/**
239 * Keeps server ARIA refs, drops legacy object trees, shortens page text, names, identifiers,
240 * and log strings, keeps only the newest log entries, and finally sheds
241 * interactive elements until the JSON fits. Returns the bounded value, its
242 * text, and notes on what is missing so the agent can reach for
243 * preview_evaluate.
244 */
245const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
246 const omitted: Array<string> = [];
247 const { accessibilityTree, ...withoutTree } = metadata;
248 const ariaTree = typeof accessibilityTree === "string" ? accessibilityTree : undefined;
249 if (accessibilityTree !== undefined && ariaTree === undefined) {
250 omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)");
251 }
252 const tail = <A>(entries: ReadonlyArray<A>, label: string) => {
253 if (entries.length > MAX_SNAPSHOT_LOG_ENTRIES) {
254 omitted.push(`${entries.length - MAX_SNAPSHOT_LOG_ENTRIES} older ${label}`);
255 }
256 const kept = entries.slice(-MAX_SNAPSHOT_LOG_ENTRIES);
257 if (kept.some((entry) => hasLongString(entry, MAX_SNAPSHOT_LOG_TEXT_CHARS))) {
258 omitted.push(`${label} text after ${MAX_SNAPSHOT_LOG_TEXT_CHARS} characters`);
259 }
260 return kept.map(cutEntryStrings);
261 };
262 if (
263 metadata.url.length > MAX_SNAPSHOT_IDENTIFIER_CHARS ||
264 metadata.title.length > MAX_SNAPSHOT_IDENTIFIER_CHARS
265 ) {
266 omitted.push(`url or title after ${MAX_SNAPSHOT_IDENTIFIER_CHARS} characters`);
267 }
268 if (
269 metadata.interactiveElements.some(
270 (element) => element.name.length > MAX_SNAPSHOT_ELEMENT_NAME_CHARS,
271 )
272 ) {
273 omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`);
274 }
275 const bounded = {
276 ...withoutTree,
277 url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
278 title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS),
279 interactiveElements: metadata.interactiveElements.map((element) => ({
280 ...element,
281 name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS),
282 })),
283 consoleEntries: tail(metadata.consoleEntries, "console entries"),
284 networkEntries: tail(metadata.networkEntries, "network entries"),
285 actionTimeline: tail(metadata.actionTimeline, "action timeline entries"),
286 };
287
288 // Per-field caps do not sum below the ceiling: three log arrays of 40 capped
289 // entries alone can pass 60 KB, and the caps count characters, not bytes.
290 // Halve one thing per round until the JSON fits: logs first, then page
291 // text, then the locators. The identifier caps bound the rest, so this
292 // terminates.
293 const shedOrder = [
294 "actionTimeline",
295 "networkEntries",
296 "consoleEntries",
297 "interactiveElements",
298 ] as const;
299 const lists: Record<(typeof shedOrder)[number], ReadonlyArray<unknown>> = {
300 interactiveElements: bounded.interactiveElements,
301 consoleEntries: bounded.consoleEntries,
302 networkEntries: bounded.networkEntries,
303 actionTimeline: bounded.actionTimeline,
304 };
305 const dropped: Record<(typeof shedOrder)[number], number> = {
306 interactiveElements: 0,
307 consoleEntries: 0,
308 networkEntries: 0,
309 actionTimeline: 0,
310 };
311 let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS);
312 let ariaTreeChars = Math.min(ariaTree?.length ?? 0, 20_000);
313 const value = () => ({
314 ...bounded,
315 ...(ariaTree === undefined ? {} : { accessibilityTree: cutText(ariaTree, ariaTreeChars) }),
316 visibleText: cutText(metadata.visibleText, visibleTextChars),
317 ...lists,
318 });
319 let text = encodeJsonText(value());
320 while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) {
321 // Elements carry the locators, so they go last; logs shed newest-last.
322 const key =
323 shedOrder.find(
324 (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0,
325 ) ??
326 (visibleTextChars > 0
327 ? "visibleText"
328 : lists.interactiveElements.length > 0
329 ? "interactiveElements"
330 : ariaTreeChars > 0
331 ? "accessibilityTree"
332 : undefined);
333 if (key === undefined) break;
334 if (key === "visibleText") {
335 visibleTextChars = Math.floor(visibleTextChars / 2);
336 } else if (key === "accessibilityTree") {
337 ariaTreeChars = Math.floor(ariaTreeChars / 2);
338 } else {
339 const keep = Math.floor(lists[key].length / 2);
340 dropped[key] += lists[key].length - keep;
341 // slice(-0) keeps everything, so spell out the empty case.
342 lists[key] =
343 keep === 0
344 ? []
345 : key === "interactiveElements"
346 ? lists[key].slice(0, keep)
347 : lists[key].slice(-keep);
348 }
349 text = encodeJsonText(value());
350 }
351 if (visibleTextChars < metadata.visibleText.length) {
352 omitted.push(
353 `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`,
354 );
355 }
356 if (ariaTree !== undefined && ariaTreeChars < ariaTree.length) {
357 omitted.push(`accessibilityTree after ${ariaTreeChars} characters`);
358 }
359 for (const key of shedOrder) {
360 if (dropped[key] > 0) {
361 omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`);
362 }
363 }
364 return { value: value(), text, omitted };
365};
366
367export class PreviewScreenshotSaveError extends Schema.TaggedError<PreviewScreenshotSaveError>()(
368 "PreviewScreenshotSaveError",
369 { screenshotPath: Schema.String, cause: Schema.Defect() },
370) {
371 override get message(): string {
372 return `Could not save preview screenshot to ${this.screenshotPath}.`;
373 }
374}
375
376const MAX_SCREENSHOT_SITE_SLUG_LENGTH = 40;
377
378/** Hostname reduced to a filename-safe slug, matching the desktop's own screenshot names. */
379const screenshotSiteSlug = (rawUrl: string): string => {
380 try {
381 const slug = new URL(rawUrl).hostname
382 .toLowerCase()
383 .replace(/[^a-z0-9]+/g, "-")
384 .replace(/^-+|-+$/g, "")
385 .slice(0, MAX_SCREENSHOT_SITE_SLUG_LENGTH)
386 .replace(/-+$/g, "");
387 return slug || "site";
388 } catch {
389 return "site";
390 }
391};
392
393/** Writes the snapshot PNG under the browser artifacts directory and returns its path. */
394const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
395 pageUrl: string,
396 data: Uint8Array,
397) {
398 const config = yield* ServerConfig.ServerConfig;
399 const fileSystem = yield* FileSystem.FileSystem;
400 const path = yield* Path.Path;
401 const millis = yield* Clock.currentTimeMillis;
402 const crypto = yield* Crypto.Crypto;
403 // Two saves in the same millisecond must not overwrite each other.
404 const unique = (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).slice(0, 8);
405 const fileName = `browser-screenshot-${screenshotSiteSlug(pageUrl)}-${millis.toString(36)}-${unique}.png`;
406 const screenshotPath = path.join(config.browserArtifactsDir, fileName);
407 yield* fileSystem.makeDirectory(config.browserArtifactsDir, { recursive: true }).pipe(
408 Effect.andThen(fileSystem.writeFile(screenshotPath, data)),
409 Effect.mapError((cause) => new PreviewScreenshotSaveError({ screenshotPath, cause })),
410 );
411 return screenshotPath;
412});
413
414const isPreviewAutomationError = Schema.is(PreviewAutomationError);
415
416const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
417 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
418 return Effect.failCause(cause).pipe(Effect.orDie);
419 }
420 const failures = cause.reasons.filter(Cause.isFailReason);
421 const firstFailure = failures[0]?.error;
422 const errorTag =
423 typeof firstFailure === "object" &&
424 firstFailure !== null &&
425 "_tag" in firstFailure &&
426 typeof firstFailure._tag === "string"
427 ? firstFailure._tag
428 : "PreviewSnapshotError";
429 // Preview errors build their message on the server, never from page output,
430 // and it tells the agent what to do next, such as falling back to a shell browser.
431 const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined;
432 const result = new McpSchema.CallToolResult({
433 isError: true,
434 structuredContent: {
435 error: {
436 _tag: errorTag,
437 operation: "snapshot",
438 failureCount: failures.length,
439 ...(message === undefined ? {} : { message }),
440 },
441 },
442 // Some clients show only the text content and others only structuredContent, so both carry it.
443 content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }],
444 });
445 return Effect.logWarning("preview snapshot failed", {
446 operation: "snapshot",
447 errorTag,
448 failureCount: failures.length,
449 }).pipe(Effect.as(result));
450};
451
452const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
453 const server = yield* McpServer.McpServer;
454 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
455 const threads = yield* ThreadManagementService.ThreadManagementService;
456 // The MCP tool runner only supplies the client, so hand the save path its services here.
457 const saveServices = yield* Effect.context<
458 ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto
459 >();
460 const built = yield* PreviewSnapshotToolkit;
461 const tool = PreviewSnapshotTool;
462 yield* server.addTool({
463 tool: new McpSchema.Tool({
464 name: tool.name,
465 description: Tool.getDescription(tool),
466 inputSchema: Tool.getJsonSchema(tool),
467 annotations: {
468 ...Context.getOption(tool.annotations, Tool.Title).pipe(
469 Option.map((title) => ({ title })),
470 Option.getOrUndefined,
471 ),
472 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
473 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
474 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
475 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
476 },
477 }),
478 annotations: tool.annotations,
479 handle: (payload) =>
480 Effect.withFiber((fiber) => {
481 const invocation = Context.getUnsafe(
482 fiber.context,
483 McpInvocationContext.McpInvocationContext,
484 );
485 return built.handle("preview_snapshot", payload).pipe(
486 Stream.unwrap,
487 Stream.run(Sink.last()),
488 Effect.flatMap(Effect.fromOption),
489 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
490 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
491 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
492 Effect.flatMap(({ encodedResult }) =>
493 Effect.gen(function* () {
494 const snapshot = encodedResult as SnapshotMetadata & {
495 readonly url: string;
496 readonly screenshot: {
497 readonly mimeType: "image/png";
498 readonly data: string;
499 readonly width: number;
500 readonly height: number;
501 };
502 };
503 const { screenshot, ...page } = snapshot;
504 const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
505 const screenshotPath =
506 payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined;
507 // Images stay out of tool history unless asked for: providers replay them on every
508 // later request, and some reject inline images outright.
509 const includeImage = payload?.includeImage === true;
510 if (screenshotPath !== undefined && !includeImage) {
511 // The agent only wants a file to show the user. The url keeps the site icon on the tool row.
512 const saved = {
513 url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
514 screenshotPath,
515 };
516 return new McpSchema.CallToolResult({
517 isError: false,
518 structuredContent: saved,
519 content: [{ type: "text", text: encodeJsonText(saved) }],
520 });
521 }
522 const metadata = {
523 ...page,
524 screenshot: {
525 mimeType: screenshot.mimeType,
526 width: screenshot.width,
527 height: screenshot.height,
528 },
529 ...(screenshotPath === undefined ? {} : { screenshotPath }),
530 };
531 const bounded = boundSnapshotMetadata(metadata);
532 return new McpSchema.CallToolResult({
533 isError: false,
534 structuredContent:
535 bounded.omitted.length === 0
536 ? bounded.value
537 : { ...bounded.value, omitted: bounded.omitted },
538 content: [
539 // Keep the page identity readable even if a provider truncates the snapshot.
540 {
541 type: "text",
542 text: encodeJsonText({
543 url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
544 }),
545 },
546 { type: "text", text: bounded.text },
547 ...(bounded.omitted.length === 0
548 ? []
549 : [
550 {
551 type: "text" as const,
552 text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`,
553 },
554 ]),
555 ...(includeImage
556 ? [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]
557 : []),
558 ],
559 });
560 }),
561 ),
562 Effect.provide(saveServices),
563 Effect.matchCauseEffect({
564 onFailure: previewSnapshotFailure,
565 onSuccess: Effect.succeed,
566 }),
567 );
568 }),
569 });
570});
571
572interface ImageToolResult {
573 readonly screenshot: {
574 readonly mimeType: "image/png";
575 readonly data: string;
576 readonly width: number;
577 readonly height: number;
578 };
579 readonly [key: string]: unknown;
580}
581
582/**
583 * Failures surface only their tag unless the tool describes them: the remote
584 * message may carry renderer or device output the agent should not see, and
585 * the tag is what it can act on. A describer returns a server-built message.
586 */
587const imageToolFailure =
588 <E>(toolName: string, operation: string, failureText: string | ((error: E) => string)) =>
589 (cause: Cause.Cause<E>) => {
590 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
591 return Effect.failCause(cause).pipe(Effect.orDie);
592 }
593 const failures = cause.reasons.filter(Cause.isFailReason);
594 const firstFailure = failures[0]?.error;
595 const errorTag =
596 typeof firstFailure === "object" &&
597 firstFailure !== null &&
598 "_tag" in firstFailure &&
599 typeof firstFailure._tag === "string"
600 ? firstFailure._tag
601 : `${toolName}Error`;
602 const message =
603 typeof failureText === "string" || failures[0] === undefined
604 ? undefined
605 : failureText(failures[0].error);
606 const result = new McpSchema.CallToolResult({
607 isError: true,
608 structuredContent: {
609 error: {
610 _tag: errorTag,
611 operation,
612 failureCount: failures.length,
613 ...(message === undefined ? {} : { message }),
614 },
615 },
616 // Some clients show only the text content and others only structuredContent, so both carry it.
617 content: [
618 {
619 type: "text",
620 text: typeof failureText === "string" ? failureText : (message ?? `${toolName} failed.`),
621 },
622 ],
623 });
624 return Effect.logWarning(`${toolName} failed`, {
625 operation,
626 errorTag,
627 failureCount: failures.length,
628 }).pipe(Effect.as(result));
629 };
630
631/**
632 * `McpServer.toolkit` serializes every result as JSON text, which is the
633 * wrong shape for a screenshot: the model needs image content. Tools whose
634 * result carries a `screenshot` field are registered by hand so the PNG goes
635 * out as an image block and the rest of the payload as JSON metadata.
636 */
637const registerImageTool = <T extends Tool.Any, E, R>(
638 tool: T,
639 handle: (payload: Tool.Parameters<T>) => Effect.Effect<{ readonly encodedResult: unknown }, E, R>,
640 provide: (
641 effect: Effect.Effect<{ readonly encodedResult: unknown }, E, R>,
642 ) => Effect.Effect<
643 { readonly encodedResult: unknown },
644 E,
645 McpInvocationContext.McpInvocationContext
646 >,
647 operation: string,
648 failureText: string | ((error: E) => string),
649) =>
650 Effect.gen(function* () {
651 const server = yield* McpServer.McpServer;
652 yield* server.addTool({
653 tool: new McpSchema.Tool({
654 name: tool.name,
655 description: Tool.getDescription(tool),
656 inputSchema: Tool.getJsonSchema(tool),
657 annotations: {
658 ...Context.getOption(tool.annotations, Tool.Title).pipe(
659 Option.map((title) => ({ title })),
660 Option.getOrUndefined,
661 ),
662 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
663 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
664 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
665 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
666 },
667 }),
668 annotations: tool.annotations,
669 handle: (payload) =>
670 Effect.withFiber((fiber) => {
671 const invocation = Context.getUnsafe(
672 fiber.context,
673 McpInvocationContext.McpInvocationContext,
674 );
675 return provide(handle(payload as Tool.Parameters<T>)).pipe(
676 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
677 Effect.matchCauseEffect({
678 onFailure: imageToolFailure(tool.name, operation, failureText),
679 onSuccess: ({ encodedResult }) => {
680 const { screenshot, ...rest } = encodedResult as ImageToolResult;
681 const includeImage =
682 (payload as { readonly includeImage?: boolean } | undefined)?.includeImage !==
683 false;
684 const metadata = {
685 ...rest,
686 screenshot: {
687 mimeType: screenshot.mimeType,
688 width: screenshot.width,
689 height: screenshot.height,
690 },
691 };
692 return Effect.succeed(
693 new McpSchema.CallToolResult({
694 isError: false,
695 structuredContent: metadata,
696 content: [
697 { type: "text", text: JSON.stringify(metadata) },
698 ...(includeImage
699 ? [
700 {
701 type: "image" as const,
702 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
703 mimeType: screenshot.mimeType,
704 },
705 ]
706 : []),
707 ],
708 }),
709 );
710 },
711 }),
712 );
713 }),
714 });
715 });
716
717const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () {
718 const devices = yield* DeviceService.DeviceService;
719 const threads = yield* ThreadManagementService.ThreadManagementService;
720 const built = yield* DeviceScreenshotToolkit;
721 yield* registerImageTool(
722 DeviceScreenshotTool,
723 (payload) =>
724 built
725 .handle("device_screenshot", payload)
726 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
727 (effect) =>
728 effect.pipe(
729 Effect.provideService(DeviceService.DeviceService, devices),
730 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
731 ),
732 "screenshot",
733 "Device screenshot failed.",
734 );
735});
736
737const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);
738
739const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () {
740 const htmlRender = yield* HtmlRender.HtmlRender;
741 const threads = yield* ThreadManagementService.ThreadManagementService;
742 const built = yield* HtmlPreviewToolkit;
743 yield* registerImageTool(
744 HtmlPreviewTool,
745 (payload) =>
746 built
747 .handle("html_preview", payload)
748 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
749 (effect) =>
750 effect.pipe(
751 Effect.provideService(HtmlRender.HtmlRender, htmlRender),
752 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
753 ),
754 "preview",
755 // Parameter errors and HTML render errors are both written by the server for the agent.
756 (error) =>
757 isOrchestratorMcpFailure(error) || AiError.isAiError(error)
758 ? error.message
759 : "HTML preview failed.",
760 );
761});
762
763/**
764 * `McpServer.toolkit` for handlers that declared their access (see
765 * `McpToolAccess`). Every toolkit on `/mcp` registers through this.
766 *
767 * `McpServer.toolkit` asks for every service its tools declare when it
768 * registers them, but the auth middleware provides `McpInvocationContext` to
769 * each request instead. Registration must not get one: the services it
770 * captures would replace the request's.
771 */
772export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX, RX>(
773 toolkit: Toolkit.Toolkit<Tools>,
774 handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
775) => {
776 const registration = McpServer.toolkit(toolkit);
777 // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off - the auth middleware provides it per request.
778 const registered = registration as Layer.Layer<
779 never,
780 never,
781 Exclude<Layer.Services<typeof registration>, McpInvocationContext.McpInvocationContext>
782 >;
783 return registered.pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)));
784};
785
786/** A hand-registered tool, also only with handlers that declared their access. */
787const imageToolRegistration = <Tools extends Record<string, Tool.Any>, A, E, R, EX, RX>(
788 register: Effect.Effect<A, E, R>,
789 handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
790) => Layer.effectDiscard(register).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)));
791
792export const layerHtmlToolkit = Layer.mergeAll(
793 toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender),
794 imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview),
795).pipe(Layer.provide(HtmlRender.layer));
796
797const layerPreviewStandardToolkitRegistration = toolkitRegistration(
798 PreviewStandardToolkit,
799 PreviewHandlers.layerStandard,
800);
801
802const layerPreviewSnapshotRegistration = imageToolRegistration(
803 registerPreviewSnapshot(),
804 PreviewHandlers.layerSnapshot,
805);
806
807export const layerPreviewToolkit = Layer.mergeAll(
808 layerPreviewStandardToolkitRegistration,
809 layerPreviewSnapshotRegistration,
810);
811
812export const layerOrchestratorToolkit = toolkitRegistration(
813 OrchestratorToolkit,
814 OrchestratorHandlers.layer,
815).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer));
816
817export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
818
819const layerWorktreeToolkitRegistration = toolkitRegistration(
820 WorktreeToolkit,
821 WorktreeHandlers.layer,
822).pipe(Layer.provide(WorktreeMcpService.layer));
823
824const layerPreviewControlsRegistration = toolkitRegistration(
825 PreviewControlsToolkit,
826 PreviewControlsHandlers.layer,
827);
828
829export const layerEnvironmentToolkit = toolkitRegistration(
830 EnvironmentToolkit,
831 EnvironmentHandlers.layer,
832).pipe(Layer.provide(ThreadCommandExecutor.layer));
833
834const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
835
836export const layerAttachmentToolkit = toolkitRegistration(
837 AttachmentToolkit,
838 AttachmentHandlers.layer,
839);
840
841export const layerPullRequestsToolkit = toolkitRegistration(
842 PullRequestsToolkit,
843 PullRequestsHandlers.layer,
844);
845
846const layerDeviceStandardToolkitRegistration = toolkitRegistration(
847 DeviceStandardToolkit,
848 DeviceHandlers.layerStandard,
849);
850
851const layerDeviceScreenshotRegistration = imageToolRegistration(
852 registerDeviceScreenshot(),
853 DeviceHandlers.layerScreenshot,
854);
855
856export const layerDeviceToolkit = Layer.mergeAll(
857 layerDeviceStandardToolkitRegistration,
858 layerDeviceScreenshotRegistration,
859);
860
861export const layerMcpTransport = McpServer.layerHttp({
862 name: "T3 Code",
863 version: SERVER_VERSION,
864 path: "/mcp",
865 protocols: [McpProtocol.v2025_06_18],
866 allowSessionTermination: true,
867}).pipe(Layer.provide(layerMcpAuthMiddleware));
868
869export const layer = Layer.mergeAll(
870 layerPreviewToolkit,
871 layerOrchestratorToolkit,
872 layerThreadToolkit,
873 layerAttachmentToolkit,
874 layerProjectRegistration,
875 layerEnvironmentToolkit,
876 layerPreviewControlsRegistration,
877 layerWorktreeToolkitRegistration,
878 layerPullRequestsToolkit,
879 layerDeviceToolkit,
880 layerHtmlToolkit,
881).pipe(Layer.provideMerge(layerMcpTransport));