mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
4058b54573
* 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>
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
)
|
||
);
|
||
}
|
||
}
|