From 3109f49b40e59378870309188040540f5561abea Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 11 May 2026 22:16:20 -0400 Subject: [PATCH] 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 --- server/routes/mcp/index.ts | 4 +++- server/tools/fetch.ts | 46 +++++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/server/routes/mcp/index.ts b/server/routes/mcp/index.ts index 07885ca17e..36d81e56e3 100644 --- a/server/routes/mcp/index.ts +++ b/server/routes/mcp/index.ts @@ -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 diff --git a/server/tools/fetch.ts b/server/tools/fetch.ts index b75ce348c0..ffb490c604 100644 --- a/server/tools/fetch.ts +++ b/server/tools/fetch.ts @@ -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}`); }