rspace-online/server/mcp-tools/rfiles.ts

162 lines
5.3 KiB
TypeScript

/**
* 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<FilesDoc>(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<FilesDoc>(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<FilesDoc>(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) }] };
},
);
}