Files
Tom Moor 4058b54573 fix: Relative path returned from MCP (#12255)
* fix: relative path returned from MCP

* fix: MCP create_attachment uploadUrl and size validation

Make uploadUrl absolute against team.url so MCP clients can resolve it
without a base, tighten the size schema to match the REST endpoint
(int, nonnegative, finite), and stub cookies on the MCP API context so
LocalStorage's CSRF-aware getPresignedPost works for Bearer-authed
MCP requests. Adds tests covering the success path, persistence, size
limits, schema rejections, and read-only scope enforcement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:52:32 -04:00

136 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "crypto";
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ValidationError } from "@server/errors";
import { Attachment, Team } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { authorize } from "@server/policies";
import presentAttachment from "@server/presenters/attachment";
import FileStorage from "@server/storage/files";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { AttachmentPreset } from "@shared/types";
import { bytesToHumanReadable } from "@shared/utils/files";
import {
error,
success,
buildAPIContext,
pathToUrl,
withTracing,
} from "./util";
/**
* Registers attachment-related MCP tools on the given server, filtered by
* the OAuth scopes granted to the current token.
*
* @param server - the MCP server instance to register on.
* @param scopes - the OAuth scopes granted to the access token.
*/
export function attachmentTools(server: McpServer, scopes: string[]) {
if (AuthenticationHelper.canAccess("attachments.create", scopes)) {
server.registerTool(
"create_attachment",
{
title: "Create attachment upload",
description:
"Requests a pre-signed upload URL. Use the returned uploadUrl and form fields to upload a file directly via a multipart POST request (e.g. with curl). The returned attachment URL is returned for use in documents.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
},
inputSchema: {
contentType: z
.string()
.describe("The MIME type of the file, e.g. image/png, image/jpeg."),
name: z
.string()
.describe("The filename including extension, e.g. screenshot.png."),
size: z.coerce
.number()
.int()
.nonnegative()
.finite()
.describe("The file size in bytes."),
},
},
withTracing(
"create_attachment",
async ({ contentType, name, size }, extra) => {
try {
const ctx = buildAPIContext(extra);
const { user } = ctx.state.auth;
const team = await Team.findByPk(user.teamId, {
rejectOnEmpty: true,
});
authorize(user, "createAttachment", team);
const preset = AttachmentPreset.DocumentAttachment;
const maxUploadSize =
AttachmentHelper.presetToMaxUploadSize(preset);
if (size > maxUploadSize) {
throw ValidationError(
`Sorry, this file is too large the maximum size is ${bytesToHumanReadable(
maxUploadSize
)}`
);
}
const id = randomUUID();
const acl = AttachmentHelper.presetToAcl(preset);
const key = AttachmentHelper.getKey({
id,
name,
userId: user.id,
});
const attachment = await Attachment.createWithCtx(ctx, {
id,
key,
acl,
size,
contentType,
teamId: user.teamId,
userId: user.id,
});
const presignedPost = await FileStorage.getPresignedPost(
ctx,
key,
acl,
maxUploadSize,
contentType
);
const uploadUrl = new URL(FileStorage.getUploadUrl(), team.url)
.href;
const form = {
"Cache-Control": "max-age=31557600",
"Content-Type": contentType,
...presignedPost.fields,
};
// Build a ready-to-use curl command for the MCP client
const formArgs = Object.entries(form)
.map(([k, v]) => `-F '${k}=${v}'`)
.join(" ");
const curlCommand = `curl -X POST ${formArgs} -F 'file=@/path/to/file' '${uploadUrl}'`;
return success({
uploadUrl,
form,
maxUploadSize,
curlCommand,
attachment: pathToUrl(team, {
...presentAttachment(attachment),
url: attachment.redirectUrl,
}),
});
} catch (message) {
return error(message);
}
}
)
);
}
}