Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core-web/apps/mcp-server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 62 additions & 0 deletions core-web/apps/mcp-server/src/tools/list-folder/formatters.ts
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}${
Copy link

Copilot AI Jan 5, 2026

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.

Suggested change
`Found ${items.length} item(s) in "${folderPath}" (showing ${meta.offset}–${
`Found ${meta.total} item(s) in "${folderPath}" (showing ${meta.offset}–${

Copilot uses AI. Check for mistakes.
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');
}
73 changes: 73 additions & 0 deletions core-web/apps/mcp-server/src/tools/list-folder/handlers.ts
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
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment indicates results should not be filtered by site, but the actual comment says "Do NOT filter by site at the Lucene level; filter after fetching results". However, there is no site filtering performed after fetching results. This comment is misleading as no site filtering is implemented at all. Either implement the site filtering logic or update the comment to accurately reflect the current behavior.

Suggested change
// 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 uses AI. Check for mistakes.
const query = `+parentPath:"${parentPath}"`;
Copy link

Copilot AI Jan 5, 2026

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 uses AI. Check for mistakes.

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
Comment on lines +47 to +51
Copy link

Copilot AI Jan 5, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
});

// 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');
}


Comment on lines +1 to +73
Copy link

Copilot AI Jan 5, 2026

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.

Copilot uses AI. Check for mistakes.
55 changes: 55 additions & 0 deletions core-web/apps/mcp-server/src/tools/list-folder/index.ts
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
);
}
38 changes: 38 additions & 0 deletions core-web/apps/mcp-server/src/utils/context-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
}

/**
Expand Down Expand Up @@ -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
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type annotation uses 'any' for the generic type parameter, which bypasses TypeScript's type safety. While the eslint-disable comment acknowledges this, a better approach would be to use 'unknown' as the default type, which maintains type safety while still allowing flexibility. This would require callers to perform type narrowing before using the returned value.

Suggested change
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getData<T = any>(key: string): T | undefined {
getData<T = unknown>(key: string): T | undefined {

Copilot uses AI. Check for mistakes.
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());
}
Comment on lines +107 to +140
Copy link

Copilot AI Jan 5, 2026

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.

Copilot uses AI. Check for mistakes.
}

/**
Expand Down
3 changes: 2 additions & 1 deletion core-web/apps/mcp-server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
],
"compilerOptions": {
"strict": true,
"esModuleInterop": true
"esModuleInterop": true,
"importHelpers": false
}
}
Loading