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
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
import { EnvironmentHandlersLive } from "./toolkits/environment/handlers.ts";
import { ProjectToolkit } from "./toolkits/project/tools.ts";
import { ProjectHandlersLive } from "./toolkits/project/handlers.ts";
import { AttachmentToolkit } from "./toolkits/attachment/tools.ts";
import { AttachmentHandlersLive } from "./toolkits/attachment/handlers.ts";
import { ThreadToolkit } from "./toolkits/thread/tools.ts";
import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts";
import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
Expand Down Expand Up @@ -257,6 +259,10 @@ export const ProjectRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe(
Layer.provide(ProjectHandlersLive),
);

export const AttachmentRegistrationLive = McpServer.toolkit(AttachmentToolkit).pipe(
Layer.provide(AttachmentHandlersLive),
);

const McpTransportLive = McpServer.layerHttp({
name: "T3 Code",
version: packageJson.version,
Expand All @@ -268,6 +274,7 @@ export const layer = Layer.mergeAll(
PreviewToolkitRegistrationLive,
OrchestratorToolkitRegistrationLive,
ThreadToolkitRegistrationLive,
AttachmentRegistrationLive,
ProjectRegistrationLive,
EnvironmentRegistrationLive,
PreviewControlsRegistrationLive,
Expand Down
86 changes: 86 additions & 0 deletions apps/server/src/mcp/toolkits/attachment/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { type ChatAttachment, MessageId, OrchestratorMcpFailure } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Upload from "../../../assets/AttachmentUpload.ts";
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
import {
newCommandId,
readMutationCaller,
readWritableThread,
unavailable,
} from "../../threadAccess.ts";
import { AttachmentToolkit } from "./tools.ts";

export function resolveAttachmentReferences(
requested: ReadonlyArray<ChatAttachment>,
stored: ReadonlyArray<ChatAttachment>,
) {
const owned = new Map(stored.map((attachment) => [attachment.id, attachment]));
return Effect.forEach(requested, (attachment) => {
const canonical = Claims.attachmentIsPendingUpload(attachment)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
? attachment
: owned.get(attachment.id);
return canonical === undefined
? Effect.fail(
new OrchestratorMcpFailure({
code: "invalid_request",
message: "Attachments must be pending uploads or already belong to the target thread.",
}),
)
: Effect.succeed(canonical);
});
}

export const AttachmentHandlersLive = AttachmentToolkit.toLayer({
t3_attachment_prepare_upload: (input) =>
Effect.gen(function* () {
yield* readMutationCaller();
return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe(
Effect.mapError(unavailable),
);
}),
t3_attachment_discard: (input) =>
Effect.gen(function* () {
yield* readMutationCaller();
yield* Upload.deletePendingAttachment(input.attachmentId);
return {};
}),
t3_thread_send_attachments: (input) =>
Effect.gen(function* () {
const { caller, projection } = yield* readWritableThread(input.threadId);
if (projection.thread.archivedAt !== null)
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message: "Unarchive the target thread before sending attachments.",
});
const attachments = yield* resolveAttachmentReferences(
input.attachments,
projection.messages.flatMap((message) => message.attachments),
);
const commandId = yield* newCommandId();
const messageId = MessageId.make(commandId);
const result = yield* ThreadMessageIntake.sendToThread({
projectId: caller.projectId,
threadId: projection.thread.id,
commandId,
messageId,
text: input.message ?? "",
attachments,
mode: "auto",
createdBy: "agent",
creationSource: "mcp",
}).pipe(
Effect.mapError((error) =>
error._tag === "AttachmentClaimError"
? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
: unavailable(),
),
);
return {
threadId: projection.thread.id,
messageId,
runId: result.run.id,
status: result.run.status,
};
}),
});
73 changes: 73 additions & 0 deletions apps/server/src/mcp/toolkits/attachment/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
AttachmentCreateUploadUrlInput,
AttachmentCreateUploadUrlResult,
AttachmentDeleteInput,
ChatImageAttachment,
ChatFileAttachment,
MessageId,
RunId,
ThreadId,
OrchestrationV2RunStatus,
OrchestratorMcpFailure,
} from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as FileSystem from "effect/FileSystem";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/unstable/ai";
import { ServerSecretStore } from "../../../auth/ServerSecretStore.ts";
import { ServerConfig } from "../../../config.ts";
import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts";
import { McpInvocationContext } from "../../McpInvocationContext.ts";

const shared = {
failure: OrchestratorMcpFailure,
failureMode: "return" as const,
dependencies: [
McpInvocationContext,
ThreadManagementService,
ServerConfig,
ServerSecretStore,
FileSystem.FileSystem,
Crypto.Crypto,
],
};
export const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", {
...shared,
description:
"Create the app's signed upload URL. POST the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.",
parameters: Schema.Struct({ upload: AttachmentCreateUploadUrlInput }),
success: AttachmentCreateUploadUrlResult,
}).annotate(Tool.Destructive, true);
export const AttachmentDiscardTool = Tool.make("t3_attachment_discard", {
...shared,
description:
"Discard a pending upload. Already-delivered thread attachments are never deleted by this operation.",
parameters: AttachmentDeleteInput,
success: Schema.Struct({}),
}).annotate(Tool.Destructive, true);
export const AttachmentSendTool = Tool.make("t3_thread_send_attachments", {
...shared,
description:
"Send uploaded attachments to this thread or another thread in the calling project. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))),
attachments: Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).check(
Schema.isMinLength(1),
Schema.isMaxLength(8),
),
}),
success: Schema.Struct({
threadId: ThreadId,
messageId: MessageId,
runId: RunId,
status: OrchestrationV2RunStatus,
}),
})
.annotate(Tool.Destructive, true)
.annotate(Tool.OpenWorld, true);
export const AttachmentToolkit = Toolkit.make(
AttachmentUploadTool,
AttachmentDiscardTool,
AttachmentSendTool,
);
24 changes: 24 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { expect, it } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
ChatImageAttachment,
EnvironmentId,
ProviderInstanceId,
ThreadId,
Expand All @@ -20,6 +21,8 @@ import { PreviewControlsToolkit } from "./previewControls/tools.ts";
import { EnvironmentToolkit } from "./environment/tools.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import { ProjectToolkit } from "./project/tools.ts";
import { AttachmentToolkit } from "./attachment/tools.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
import { ThreadToolkit } from "./thread/tools.ts";
import { WorktreeToolkit } from "./worktree/tools.ts";

Expand All @@ -30,6 +33,7 @@ it("publishes unique tool names with object-root inputs", () => {
PreviewToolkit,
WorktreeToolkit,
ThreadToolkit,
AttachmentToolkit,
ProjectToolkit,
EnvironmentToolkit,
PreviewControlsToolkit,
Expand Down Expand Up @@ -136,3 +140,23 @@ it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
truncated: true,
});
});

it.effect("resolves reused attachment references from stored metadata", () =>
Effect.gen(function* () {
const stored = ChatImageAttachment.make({
type: "image",
id: "owned-image",
name: "original.png",
mimeType: "image/png",
sizeBytes: 12,
});
const forged = { ...stored, name: "changed.jpg", mimeType: "image/jpeg", sizeBytes: 99 };
const result = yield* AttachmentHandlers.resolveAttachmentReferences([forged], [stored]);
expect(result).toEqual([stored]);
const failure = yield* AttachmentHandlers.resolveAttachmentReferences(
[{ ...forged, id: "other-image" }],
[stored],
).pipe(Effect.flip);
expect(failure.code).toBe("invalid_request");
}),
);
3 changes: 3 additions & 0 deletions packages/shared/src/t3McpToolPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ const T3_MCP_TOOLS: Record<
t3_project_update: { displayName: "Update a project" },
t3_project_delete: { displayName: "Delete a project" },
t3_project_clone: { displayName: "Clone a repository" },
t3_attachment_prepare_upload: { displayName: "Prepare attachment upload" },
t3_attachment_discard: { displayName: "Discard pending attachment" },
t3_thread_send_attachments: { displayName: "Send attachments" },
preview_status: { displayName: "Get preview browser status" },
preview_open: { displayName: "Open a page in the preview browser" },
preview_navigate: { displayName: "Navigate the preview browser" },
Expand Down
Loading