Files
2026-06-09 07:43:21 -04:00

246 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
Attachment,
Collection,
Document,
Template,
User,
} from "@server/models";
import { authorize, can } from "@server/policies";
import { AuthorizationError } from "@server/errors";
import {
presentCollection,
presentNavigationNode,
presentUser,
} from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { presentDocument } from "./documents";
import { presentTemplate } from "./templates";
import {
error,
success,
getActorFromContext,
getDocumentBreadcrumb,
pathToUrl,
withTracing,
} from "./util";
const SELF_TOKENS = new Set(["self", "me", "current_user"]);
/**
* Extracts a resource identifier from a value that may be a URL or a plain ID.
* When a URL is detected the last non-empty path segment is returned as the
* slug, which the model's findByPk override can resolve.
*
* @param value - a URL string or plain identifier.
* @returns the extracted identifier.
*/
function extractId(value: string): string {
if (/^https?:\/\//.test(value)) {
try {
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;
}
}
return value;
}
/**
* Registers the unified "fetch" MCP tool on the given server. The tool is
* only registered when at least one of the underlying info scopes is granted.
*
* @param server - the MCP server instance to register on.
* @param scopes - the OAuth scopes granted to the access token.
*/
export function fetchTool(server: McpServer, scopes: string[]) {
const canReadDocuments = AuthenticationHelper.canAccess(
"documents.info",
scopes
);
const canReadCollections = AuthenticationHelper.canAccess(
"collections.info",
scopes
);
const canReadUsers = AuthenticationHelper.canAccess("users.info", scopes);
const canReadAttachments = AuthenticationHelper.canAccess(
"attachments.info",
scopes
);
const canReadTemplates = AuthenticationHelper.canAccess(
"templates.info",
scopes
);
if (
!canReadDocuments &&
!canReadCollections &&
!canReadUsers &&
!canReadAttachments &&
!canReadTemplates
) {
return;
}
const allowedTypes = [
...(canReadDocuments ? ["document"] : []),
...(canReadCollections ? ["collection"] : []),
...(canReadUsers ? ["user"] : []),
...(canReadAttachments ? ["attachment"] : []),
...(canReadTemplates ? ["template"] : []),
] as [string, ...string[]];
server.registerTool(
"fetch",
{
title: "Fetch",
description:
'Fetches a document, collection, user, attachment, or template 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. For templates, the response includes the template body as markdown.',
annotations: {
idempotentHint: true,
readOnlyHint: true,
},
inputSchema: {
resource: z.enum(allowedTypes).describe("The resource to fetch."),
id: z
.string()
.describe(
'The unique identifier or URL. For users, "current_user" returns the authenticated user.'
),
},
},
withTracing("fetch", async ({ resource, id: rawId }, extra) => {
try {
const actor = getActorFromContext(extra);
const id = extractId(rawId);
switch (resource) {
case "document": {
const document = await Document.findByPk(id, {
userId: actor.id,
rejectOnEmpty: true,
});
authorize(actor, "read", document);
const [{ text, ...attributes }, breadcrumb] = await Promise.all([
presentDocument(document, {
includeData: false,
includeText: true,
includeUpdatedAt: true,
includeCommentCount: true,
}),
getDocumentBreadcrumb(document, actor),
]);
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
document: pathToUrl(actor.team, attributes),
...(breadcrumb !== undefined && { breadcrumb }),
}),
},
{
type: "text" as const,
text: typeof text === "string" ? text : "",
},
],
} satisfies CallToolResult;
}
case "collection": {
const collection = await Collection.findByPk(id, {
userId: actor.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
});
authorize(actor, "read", collection);
const presented = await presentCollection(undefined, collection);
return success([
pathToUrl(actor.team, presented),
(collection.documentStructure ?? []).map((node) =>
presentNavigationNode(actor.team, node)
),
]);
}
case "user": {
const user = SELF_TOKENS.has(id.toLowerCase())
? actor
: await User.findByPk(id, { rejectOnEmpty: true });
authorize(actor, "read", user);
return success(
presentUser(user, {
includeEmail: !!can(actor, "readEmail", user),
includeDetails: !!can(actor, "readDetails", user),
})
);
}
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,
});
}
case "template": {
const template = await Template.findByPk(id, {
userId: actor.id,
rejectOnEmpty: true,
});
authorize(actor, "read", template);
const { text, ...attributes } = await presentTemplate(template);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(pathToUrl(actor.team, attributes)),
},
{
type: "text" as const,
text,
},
],
} satisfies CallToolResult;
}
default:
return error(`Unknown resource: ${resource}`);
}
} catch (message) {
return error(message);
}
})
);
}