diff --git a/core-web/apps/mcp-server/src/main.ts b/core-web/apps/mcp-server/src/main.ts index a22c7c55c341..14156d64629e 100644 --- a/core-web/apps/mcp-server/src/main.ts +++ b/core-web/apps/mcp-server/src/main.ts @@ -7,6 +7,7 @@ import { registerContextTools } from './tools/context'; import { registerSearchTools } from './tools/search'; import { registerWorkflowTools } from './tools/workflow'; import { createContextCheckingServer } from './utils/context-checking-server'; +import { registerListFolderTools } from './tools/list-folder'; const originalServer = new McpServer({ name: 'DotCMS', @@ -43,6 +44,9 @@ registerSearchTools(server); // Register workflow tools (will be protected by context checking) registerWorkflowTools(server); +// Register custom tools +registerListFolderTools(server); + const transport = new StdioServerTransport(); (async () => { await server.connect(transport); diff --git a/core-web/apps/mcp-server/src/tools/list-folder/formatters.ts b/core-web/apps/mcp-server/src/tools/list-folder/formatters.ts new file mode 100644 index 000000000000..90c8ba941acf --- /dev/null +++ b/core-web/apps/mcp-server/src/tools/list-folder/formatters.ts @@ -0,0 +1,62 @@ +import type { Contentlet } from '../../types/search'; + +function getDotcmsBaseUrl(): string | undefined { + const url = process.env.DOTCMS_URL; + try { + if (url) { + // Validate URL format + // eslint-disable-next-line no-new + new URL(url); + return url.replace(/\/+$/, ''); + } + // eslint-disable-next-line no-empty + } catch {} + return undefined; +} + +export function formatListFolderResponse( + folderPath: string, + items: Contentlet[], + meta: { limit: number; offset: number; total: number } +): string { + const baseUrl = getDotcmsBaseUrl(); + const lines: string[] = []; + + if (items.length === 0) { + lines.push(`No items found in folder "${folderPath}".`); + lines.push(''); + lines.push('Tips:'); + lines.push('- Check the folder path (e.g., "/", "/images", "/docs").'); + lines.push('- Try adjusting limit/offset for pagination.'); + return lines.join('\n'); + } + + lines.push( + `Found ${items.length} item(s) in "${folderPath}" (showing ${meta.offset}–${ + meta.offset + items.length - 1 + } of ${meta.total}).` + ); + + lines.push(''); + lines.push('Items:'); + for (const c of items) { + const titleOrUrl = c.title || c.url || c.identifier; + const url = baseUrl && c.url ? `${baseUrl}${c.url}` : c.url || ''; + const entry = [ + `- ${titleOrUrl}`, + c.contentType ? `type=${c.contentType}` : '', + c.hostName ? `site=${c.hostName}` : '', + url ? `url=${url}` : '' + ] + .filter(Boolean) + .join(' | '); + lines.push(entry); + } + + lines.push(''); + lines.push('Next steps:'); + lines.push(`- Use offset=${meta.offset + items.length} to view the next page.`); + lines.push('- Use the content_search tool for advanced filters.'); + + return lines.join('\n'); +} diff --git a/core-web/apps/mcp-server/src/tools/list-folder/handlers.ts b/core-web/apps/mcp-server/src/tools/list-folder/handlers.ts new file mode 100644 index 000000000000..898887593131 --- /dev/null +++ b/core-web/apps/mcp-server/src/tools/list-folder/handlers.ts @@ -0,0 +1,73 @@ +import { Logger } from '../../utils/logger'; +import { executeWithErrorHandling, createSuccessResponse } from '../../utils/response'; +import { ContentSearchService } from '../../services/search'; +import type { ListFolderInput } from './index'; +import { formatListFolderResponse } from './formatters'; +import { getContextStore } from '../../utils/context-store'; + +const logger = new Logger('LIST_FOLDER_TOOL'); +const searchService = new ContentSearchService(); + +function normalizeFolderPath(raw: string): string { + const trimmed = raw.trim(); + if (trimmed === '') return '/'; + // Ensure leading slash + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + // Remove trailing slash except for root + if (withLeading.length > 1 && withLeading.endsWith('/')) { + return withLeading.slice(0, -1); + } + return withLeading; +} + +export async function listFolderContentsHandler(params: ListFolderInput) { + return executeWithErrorHandling(async () => { + const folderPath = normalizeFolderPath(params.folder); + const limit = params.limit ?? 100; + const offset = params.offset ?? 0; + logger.log('Listing folder contents', { folderPath, limit, offset }); + + // Use parentPath:"/folder/" which matches immediate children under that folder + const parentPath = folderPath.endsWith('/') ? folderPath : `${folderPath}/`; + + // Do NOT filter by site at the Lucene level; filter after fetching results + const query = `+parentPath:"${parentPath}"`; + + const searchResponse = await searchService.search({ + query, + limit, + offset, + // Provide defaults expected by the service type + depth: 1, + languageId: 1, + allCategoriesInfo: false + }); + + const contentlets = searchResponse.entity.jsonObjectView.contentlets; + + const text = formatListFolderResponse(folderPath, contentlets, { + limit, + offset, + total: contentlets.length + }); + + // Optionally store raw JSON in context for LLM-side filtering + const shouldStore = params.store_raw !== false; + if (shouldStore) { + const contextKey = params.context_key || `folder_list:${parentPath}`; + getContextStore().setData(contextKey, searchResponse); + logger.log('Stored raw search results in context', { contextKey, count: contentlets.length }); + } + + logger.log('Folder contents listed', { fetchedCount: contentlets.length }); + + const withHint = + shouldStore + ? `${text}\n\nContext:\n- Raw results stored for LLM filtering.` + : text; + + return createSuccessResponse(withHint); + }, 'Error listing folder contents'); +} + + diff --git a/core-web/apps/mcp-server/src/tools/list-folder/index.ts b/core-web/apps/mcp-server/src/tools/list-folder/index.ts new file mode 100644 index 000000000000..00606d11af82 --- /dev/null +++ b/core-web/apps/mcp-server/src/tools/list-folder/index.ts @@ -0,0 +1,55 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { listFolderContentsHandler } from './handlers'; + +const ListFolderInputSchema = z.object({ + folder: z + .string() + .min(1, 'Folder path is required') + .describe('Folder path on the site (e.g., /, /images, /docs)'), + store_raw: z + .boolean() + .optional() + .default(true) + .describe('Whether to store raw search JSON in context for later filtering'), + context_key: z + .string() + .optional() + .describe('Optional explicit context key under which to store raw results'), + limit: z.number().int().positive().max(1000).optional().default(100), + offset: z.number().int().min(0).optional().default(0) +}); + +export type ListFolderInput = z.infer; + +export function registerListFolderTools(server: McpServer) { + server.registerTool( + 'list_folder_contents', + { + title: 'List Folder Contents', + description: ` +Lists the content items contained in a specific folder path on the current site. + +Parameters: +- folder: Folder path (e.g., /, /images, /docs) +- limit (optional): Max number of items to return (default 100, max 1000) +- offset (optional): Pagination offset (default 0) +- store_raw (optional, default true): Store the full raw JSON response in context for LLM-side filtering +- context_key (optional): Key name to store/retrieve the raw results for subsequent filtering + +Notes: +- Results include contentlets found at the exact folder path (no recursion). +- Advanced filtering should be performed by the LLM using the raw JSON stored in context (when store_raw is true), not by issuing additional searches. + `.trim(), + annotations: { + title: 'List Folder Contents', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false + }, + inputSchema: ListFolderInputSchema.shape + }, + listFolderContentsHandler + ); +} \ No newline at end of file diff --git a/core-web/apps/mcp-server/src/utils/context-store.ts b/core-web/apps/mcp-server/src/utils/context-store.ts index 4d44f2a2c4c0..df5260e6cf90 100644 --- a/core-web/apps/mcp-server/src/utils/context-store.ts +++ b/core-web/apps/mcp-server/src/utils/context-store.ts @@ -11,6 +11,7 @@ export class ContextStore { private isInitialized = false; private initializationTimestamp: Date | null = null; private readonly logger: Logger; + private readonly dataStore: Map = new Map(); /** * Private constructor to enforce singleton pattern @@ -67,6 +68,8 @@ export class ContextStore { this.isInitialized = false; this.initializationTimestamp = null; this.logger.log('Context initialization state reset'); + this.dataStore.clear(); + this.logger.log('Context data store cleared'); } /** @@ -100,6 +103,41 @@ export class ContextStore { logStatus(): void { this.logger.log('Current context store status', this.getStatus()); } + + /** + * Store arbitrary contextual data for later tool usage and LLM filtering + * @param key unique key name + * @param value any serializable value + */ + setData(key: string, value: unknown): void { + this.dataStore.set(key, value); + this.logger.log('Context data stored', { key }); + } + + /** + * Retrieve contextual data by key + * @param key key used when storing the data + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getData(key: string): T | undefined { + return this.dataStore.get(key) as T | undefined; + } + + /** + * Remove contextual data by key + * @param key key to delete + */ + deleteData(key: string): void { + this.dataStore.delete(key); + this.logger.log('Context data deleted', { key }); + } + + /** + * List all keys currently stored + */ + listDataKeys(): string[] { + return Array.from(this.dataStore.keys()); + } } /** diff --git a/core-web/apps/mcp-server/tsconfig.json b/core-web/apps/mcp-server/tsconfig.json index eca1d0be560b..9a401ed0a017 100644 --- a/core-web/apps/mcp-server/tsconfig.json +++ b/core-web/apps/mcp-server/tsconfig.json @@ -12,6 +12,7 @@ ], "compilerOptions": { "strict": true, - "esModuleInterop": true + "esModuleInterop": true, + "importHelpers": false } }