162 lines
5.3 KiB
TypeScript
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) }] };
|
|
},
|
|
);
|
|
}
|