feat: Allow MCP to access signed attachment urls through fetch tool (#12315)

* feat: Add ability for MCP to access signed attachment urls through fetch tool

* Potential fix for pull request finding

* fix: non-admin cannot fetch attachments
This commit is contained in:
Tom Moor
2026-05-11 22:16:20 -04:00
committed by GitHub
parent dab06d4dfa
commit 3109f49b40
2 changed files with 44 additions and 6 deletions
+3 -1
View File
@@ -26,7 +26,9 @@ const router = new Router();
const defaultInstructions = `Document markdown content must not begin with a top-level heading (H1) — the title is stored as a separate field, so set it via the title parameter and start the content with body text or a lower-level heading instead.
Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the list_users tool to find user IDs.`;
Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the "list_users" tool to find user IDs.
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.`;
/**
* Creates a fresh MCP server instance with tools filtered by the OAuth
+41 -5
View File
@@ -1,8 +1,9 @@
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Collection, Document, User } from "@server/models";
import { Attachment, Collection, Document, User } from "@server/models";
import { authorize, can } from "@server/policies";
import { AuthorizationError } from "@server/errors";
import {
presentCollection,
presentDocument,
@@ -32,8 +33,12 @@ const SELF_TOKENS = new Set(["self", "me", "current_user"]);
function extractId(value: string): string {
if (/^https?:\/\//.test(value)) {
try {
const pathname = new URL(value).pathname;
const segments = pathname.split("/").filter(Boolean);
const url = new URL(value);
const queryId = url.searchParams.get("id");
if (queryId) {
return queryId;
}
const segments = url.pathname.split("/").filter(Boolean);
return segments[segments.length - 1] ?? value;
} catch {
return value;
@@ -59,8 +64,17 @@ export function fetchTool(server: McpServer, scopes: string[]) {
scopes
);
const canReadUsers = AuthenticationHelper.canAccess("users.info", scopes);
const canReadAttachments = AuthenticationHelper.canAccess(
"attachments.info",
scopes
);
if (!canReadDocuments && !canReadCollections && !canReadUsers) {
if (
!canReadDocuments &&
!canReadCollections &&
!canReadUsers &&
!canReadAttachments
) {
return;
}
@@ -68,6 +82,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
...(canReadDocuments ? ["document"] : []),
...(canReadCollections ? ["collection"] : []),
...(canReadUsers ? ["user"] : []),
...(canReadAttachments ? ["attachment"] : []),
] as [string, ...string[]];
server.registerTool(
@@ -75,7 +90,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
{
title: "Fetch",
description:
'Fetches a document, collection, or user by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user.',
'Fetches a document, collection, user, or attachment by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly.',
annotations: {
idempotentHint: true,
readOnlyHint: true,
@@ -161,6 +176,27 @@ export function fetchTool(server: McpServer, scopes: string[]) {
);
}
case "attachment": {
const attachment = await Attachment.findByPk(id, {
rejectOnEmpty: true,
});
// Private attachments are accessible to any member of the workspace they
// belong to. This is intentional and not a permission bypass attachments
// are owned by the workspace (team), not by individual documents or users.
if (attachment.teamId !== actor?.teamId) {
throw AuthorizationError();
}
return success({
id: attachment.id,
name: attachment.name,
contentType: attachment.contentType,
size: attachment.size,
signedUrl: await attachment.signedUrl,
});
}
default:
return error(`Unknown resource: ${resource}`);
}