-
Notifications
You must be signed in to change notification settings - Fork 480
Added new 'List Folder Contents' functionality #34200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
| // Do NOT filter by site at the Lucene level; filter after fetching results | |
| // Note: we do not apply any site-level filtering here; query is restricted only by parentPath |
Copilot
AI
Jan 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Lucene query does not escape special characters in the folder path, which could lead to query errors or unexpected results if the folder path contains characters like quotes, backslashes, or other Lucene special characters. The parentPath value should be properly escaped before being inserted into the query string.
Copilot
AI
Jan 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'total' field is incorrectly set to the number of items returned (contentlets.length), not the actual total number of items available. This should use searchResponse.entity.resultsSize to reflect the true total count, which is important for accurate pagination information.
| const text = formatListFolderResponse(folderPath, contentlets, { | |
| limit, | |
| offset, | |
| total: contentlets.length | |
| const total = searchResponse.entity.resultsSize ?? contentlets.length; | |
| const text = formatListFolderResponse(folderPath, contentlets, { | |
| limit, | |
| offset, | |
| total |
Copilot
AI
Jan 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test coverage is missing for the new list-folder tool functionality. The codebase has comprehensive test coverage for services (search.spec.ts, contenttype.spec.ts, etc.) and utils (context-store.spec.ts, logger.spec.ts, etc.), but no tests exist for this new tool's handlers and formatters. Tests should be added to verify: folder path normalization, pagination logic, context storage, error handling, and response formatting.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof ListFolderInputSchema>; | ||
|
|
||
| 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 | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ export class ContextStore { | |||||||
| private isInitialized = false; | ||||||||
| private initializationTimestamp: Date | null = null; | ||||||||
| private readonly logger: Logger; | ||||||||
| private readonly dataStore: Map<string, unknown> = 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<T = any>(key: string): T | undefined { | ||||||||
|
Comment on lines
+121
to
+122
|
||||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| getData<T = any>(key: string): T | undefined { | |
| getData<T = unknown>(key: string): T | undefined { |
Copilot
AI
Jan 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test coverage is missing for the new ContextStore data management methods (setData, getData, deleteData, listDataKeys). While the existing ContextStore initialization methods have test coverage in context-store.spec.ts, these new methods for arbitrary data storage lack tests. Tests should verify correct storage, retrieval, deletion, key listing, and integration with the reset method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The pagination message uses 'total' which equals items.length, resulting in incorrect pagination information. When showing "showing 0–99 of 100" but there are actually 500 total items available, the message is misleading. The message should use the actual total from the search response to accurately inform users about available data.