/** * MCP tools for rFiles (file metadata & memory cards). * * Tools: rfiles_list_files, rfiles_get_file, rfiles_list_cards * Read-only. Omits storagePath and fileHash from all responses. */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import type { FilesDoc } from "../../modules/rfiles/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; const FILES_PREFIX = ":files:cards:"; /** Find all files docIds for a space. */ function findFilesDocIds(syncServer: SyncServer, space: string): string[] { const prefix = `${space}${FILES_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } export function registerFilesTools(server: McpServer, syncServer: SyncServer) { server.tool( "rfiles_list_files", "List file metadata in a space (omits storagePath for security)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), mime_type: z.string().optional().describe("Filter by MIME type prefix (e.g. 'image/', 'application/pdf')"), search: z.string().optional().describe("Search in filename/title/tags"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, mime_type, search, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findFilesDocIds(syncServer, space); let files: Array<{ id: string; originalFilename: string; title: string | null; mimeType: string | null; fileSize: number; tags: string[]; uploadedBy: string | null; createdAt: number; updatedAt: number; }> = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.files) continue; for (const f of Object.values(doc.files)) { files.push({ id: f.id, originalFilename: f.originalFilename, title: f.title, mimeType: f.mimeType, fileSize: f.fileSize, tags: f.tags, uploadedBy: f.uploadedBy, createdAt: f.createdAt, updatedAt: f.updatedAt, }); } } if (mime_type) { files = files.filter(f => f.mimeType && f.mimeType.startsWith(mime_type)); } if (search) { const q = search.toLowerCase(); files = files.filter(f => f.originalFilename.toLowerCase().includes(q) || (f.title && f.title.toLowerCase().includes(q)) || f.tags.some(t => t.toLowerCase().includes(q)), ); } files.sort((a, b) => b.updatedAt - a.updatedAt); files = files.slice(0, limit || 50); return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] }; }, ); server.tool( "rfiles_get_file", "Get detailed metadata for a specific file (omits storagePath)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), file_id: z.string().describe("File ID"), }, async ({ space, token, file_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); for (const docId of findFilesDocIds(syncServer, space)) { const doc = syncServer.getDoc(docId); const file = doc?.files?.[file_id]; if (file) { // Omit storagePath and fileHash for security const { storagePath, fileHash, ...safe } = file; return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] }; } } return { content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }] }; }, ); server.tool( "rfiles_list_cards", "List memory cards (knowledge cards) in a space", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), type: z.string().optional().describe("Filter by card type"), search: z.string().optional().describe("Search in title/body/tags"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, type, search, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findFilesDocIds(syncServer, space); let cards: Array<{ id: string; title: string; body: string; cardType: string | null; tags: string[]; position: number; createdAt: number; updatedAt: number; }> = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.memoryCards) continue; for (const c of Object.values(doc.memoryCards)) { cards.push({ id: c.id, title: c.title, body: c.body.slice(0, 500), cardType: c.cardType, tags: c.tags, position: c.position, createdAt: c.createdAt, updatedAt: c.updatedAt, }); } } if (type) cards = cards.filter(c => c.cardType === type); if (search) { const q = search.toLowerCase(); cards = cards.filter(c => c.title.toLowerCase().includes(q) || c.body.toLowerCase().includes(q) || c.tags.some(t => t.toLowerCase().includes(q)), ); } cards.sort((a, b) => b.updatedAt - a.updatedAt); cards = cards.slice(0, limit || 50); return { content: [{ type: "text", text: JSON.stringify(cards, null, 2) }] }; }, ); }