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. 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 * 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 { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.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 { authorize, can } from "@server/policies";
import { AuthorizationError } from "@server/errors";
import { import {
presentCollection, presentCollection,
presentDocument, presentDocument,
@@ -32,8 +33,12 @@ const SELF_TOKENS = new Set(["self", "me", "current_user"]);
function extractId(value: string): string { function extractId(value: string): string {
if (/^https?:\/\//.test(value)) { if (/^https?:\/\//.test(value)) {
try { try {
const pathname = new URL(value).pathname; const url = new URL(value);
const segments = pathname.split("/").filter(Boolean); const queryId = url.searchParams.get("id");
if (queryId) {
return queryId;
}
const segments = url.pathname.split("/").filter(Boolean);
return segments[segments.length - 1] ?? value; return segments[segments.length - 1] ?? value;
} catch { } catch {
return value; return value;
@@ -59,8 +64,17 @@ export function fetchTool(server: McpServer, scopes: string[]) {
scopes scopes
); );
const canReadUsers = AuthenticationHelper.canAccess("users.info", 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; return;
} }
@@ -68,6 +82,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
...(canReadDocuments ? ["document"] : []), ...(canReadDocuments ? ["document"] : []),
...(canReadCollections ? ["collection"] : []), ...(canReadCollections ? ["collection"] : []),
...(canReadUsers ? ["user"] : []), ...(canReadUsers ? ["user"] : []),
...(canReadAttachments ? ["attachment"] : []),
] as [string, ...string[]]; ] as [string, ...string[]];
server.registerTool( server.registerTool(
@@ -75,7 +90,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
{ {
title: "Fetch", title: "Fetch",
description: 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: { annotations: {
idempotentHint: true, idempotentHint: true,
readOnlyHint: 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: default:
return error(`Unknown resource: ${resource}`); return error(`Unknown resource: ${resource}`);
} }