mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user