diff --git a/core-web/apps/mcp-server/src/services/search.spec.ts b/core-web/apps/mcp-server/src/services/search.spec.ts index 89522082cfb5..7df84f3962e4 100644 --- a/core-web/apps/mcp-server/src/services/search.spec.ts +++ b/core-web/apps/mcp-server/src/services/search.spec.ts @@ -1,4 +1,4 @@ -import { ContentSearchService } from './search'; +import { ContentSearchService, type SearchForm } from './search'; import { mockFetch } from '../test-setup'; @@ -10,51 +10,49 @@ describe('ContentSearchService', () => { }); describe('search', () => { - it('should search content successfully with published content', async () => { + it('should post to /api/v1/drive/search with the provided payload and return parsed results', async () => { const mockResponse = { json: jest.fn().mockResolvedValue({ entity: { - contentTook: 1, - resultsSize: 1, - jsonObjectView: { - contentlets: [ - { - title: 'Test Result', - hostName: 'localhost', - modDate: '1764284834965', - publishDate: '1764284834994', - baseType: 'CONTENT', - inode: 'inode1', - archived: false, - host: 'host1', - ownerUserName: 'admin', - working: true, - locked: false, - stInode: 'stinode1', - contentType: 'Blog', - live: true, - owner: 'admin', - identifier: 'id1', - publishUserName: 'admin', - publishUser: 'admin', - languageId: 1, - creationDate: '1764284749322', - shortyId: 'shorty1', - url: '/test', - titleImage: 'TITLE_IMAGE_NOT_FOUND', - modUserName: 'admin', - hasLiveVersion: true, - folder: '/test', - hasTitleImage: false, - sortOrder: 1, - modUser: 'admin', - __icon__: 'icon', - contentTypeIcon: 'icon', - variant: 'default' - } - ] - }, - queryTook: 1 + contentCount: 1, + contentTotalCount: 1, + folderCount: 0, + list: [ + { + title: 'Test Result', + hostName: 'localhost', + modDate: '1764284834965', + publishDate: '1764284834994', + baseType: 'CONTENT', + inode: 'inode1', + archived: false, + host: 'host1', + ownerUserName: 'admin', + working: true, + locked: false, + stInode: 'stinode1', + contentType: 'Blog', + live: true, + owner: 'admin', + identifier: 'id1', + publishUserName: 'admin', + publishUser: 'admin', + languageId: 1, + creationDate: '1764284749322', + shortyId: 'shorty1', + url: '/test', + titleImage: 'TITLE_IMAGE_NOT_FOUND', + modUserName: 'admin', + hasLiveVersion: true, + folder: '/test', + hasTitleImage: false, + sortOrder: 1, + modUser: 'admin', + __icon__: 'icon', + contentTypeIcon: 'icon', + variant: 'default' + } + ] }, errors: [], messages: [], @@ -64,275 +62,74 @@ describe('ContentSearchService', () => { }; mockFetch.mockResolvedValue(mockResponse); - const params = { - query: '+title:Test', - limit: 1, - languageId: 1, - depth: 1, - allCategoriesInfo: false + const params: SearchForm = { + assetPath: '//SiteName/', + includeSystemHost: true, + filters: { text: 'footer', filterFolders: true }, + contentTypes: [], + offset: 0, + maxResults: 20, + sortBy: 'modDate:desc', + archived: false, + showFolders: false }; const result = await service.search(params); - expect(mockFetch).toHaveBeenCalledWith( - '/api/content/_search?rememberQuery=false', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('+title:Test') - }) - ); - expect(result.entity?.jsonObjectView?.contentlets).toHaveLength(1); - expect(result.entity?.jsonObjectView?.contentlets?.[0].title).toBe('Test Result'); - // Verify dates are converted to numbers - expect(typeof result.entity?.jsonObjectView?.contentlets?.[0].modDate).toBe('number'); - expect(typeof result.entity?.jsonObjectView?.contentlets?.[0].creationDate).toBe( - 'number' - ); - expect(typeof result.entity?.jsonObjectView?.contentlets?.[0].publishDate).toBe( - 'number' - ); - }); - - it('should search content successfully with unpublished content', async () => { - const mockResponse = { - json: jest.fn().mockResolvedValue({ - entity: { - contentTook: 1, - resultsSize: 1, - jsonObjectView: { - contentlets: [ - { - title: 'Unpublished Result', - hostName: 'localhost', - modDate: '1764284834965', - baseType: 'CONTENT', - inode: 'inode2', - archived: false, - host: 'host1', - ownerUserName: 'admin', - working: true, - locked: false, - stInode: 'stinode1', - contentType: 'Blog', - live: false, - owner: 'admin', - identifier: 'id2', - languageId: 1, - creationDate: '1764284749322', - shortyId: 'shorty2', - url: '/test2', - titleImage: 'TITLE_IMAGE_NOT_FOUND', - modUserName: 'admin', - hasLiveVersion: false, - folder: '/test', - hasTitleImage: false, - sortOrder: 0, - modUser: 'admin', - __icon__: 'icon', - contentTypeIcon: 'icon', - variant: 'default' - } - ] - }, - queryTook: 1 - }, - errors: [], - messages: [], - i18nMessagesMap: {}, - permissions: [] - }) - }; - mockFetch.mockResolvedValue(mockResponse); - - const params = { - query: '+title:Unpublished', - limit: 1, - languageId: 1, - depth: 1, - allCategoriesInfo: false - }; - const result = await service.search(params); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/drive/search'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual(params); - expect(result.entity?.jsonObjectView?.contentlets).toHaveLength(1); - expect(result.entity?.jsonObjectView?.contentlets?.[0].title).toBe( - 'Unpublished Result' - ); - // Verify publishDate is null/undefined for unpublished content - expect(result.entity?.jsonObjectView?.contentlets?.[0].publishDate).toBeUndefined(); - // Verify dates are converted to numbers - expect(typeof result.entity?.jsonObjectView?.contentlets?.[0].modDate).toBe('number'); - expect(typeof result.entity?.jsonObjectView?.contentlets?.[0].creationDate).toBe( - 'number' - ); + expect(result.entity.contentCount).toBe(1); + expect(result.entity.list).toHaveLength(1); + expect(result.entity.list[0].title).toBe('Test Result'); }); - it('should handle numeric date values', async () => { - const mockResponse = { - json: jest.fn().mockResolvedValue({ - entity: { - contentTook: 1, - resultsSize: 1, - jsonObjectView: { - contentlets: [ - { - title: 'Numeric Dates', - hostName: 'localhost', - modDate: 1764284834965, - publishDate: 1764284834994, - baseType: 'CONTENT', - inode: 'inode3', - archived: false, - host: 'host1', - ownerUserName: 'admin', - working: true, - locked: false, - stInode: 'stinode1', - contentType: 'Blog', - live: true, - owner: 'admin', - identifier: 'id3', - publishUserName: 'admin', - publishUser: 'admin', - languageId: 1, - creationDate: 1764284749322, - shortyId: 'shorty3', - url: '/test3', - titleImage: 'TITLE_IMAGE_NOT_FOUND', - modUserName: 'admin', - hasLiveVersion: true, - folder: '/test', - hasTitleImage: false, - sortOrder: 0, - modUser: 'admin', - __icon__: 'icon', - contentTypeIcon: 'icon', - variant: 'default' - } - ] - }, - queryTook: 1 - }, - errors: [], - messages: [], - i18nMessagesMap: {}, - permissions: [] - }) - }; - mockFetch.mockResolvedValue(mockResponse); - - const params = { - query: '+title:Numeric', - limit: 1, - languageId: 1, - depth: 1, - allCategoriesInfo: false - }; - const result = await service.search(params); - - expect(result.entity?.jsonObjectView?.contentlets).toHaveLength(1); - expect(result.entity?.jsonObjectView?.contentlets?.[0].modDate).toBe(1764284834965); - expect(result.entity?.jsonObjectView?.contentlets?.[0].creationDate).toBe( - 1764284749322 + it('should handle invalid drive search parameters', async () => { + const invalidParams = { assetPath: 123 } as unknown as SearchForm; + await expect(service.search(invalidParams)).rejects.toThrow( + 'Invalid drive search parameters' ); - expect(result.entity?.jsonObjectView?.contentlets?.[0].publishDate).toBe(1764284834994); }); - it('should handle null publishDate', async () => { + it('should handle invalid drive search response format', async () => { const mockResponse = { - json: jest.fn().mockResolvedValue({ - entity: { - contentTook: 1, - resultsSize: 1, - jsonObjectView: { - contentlets: [ - { - title: 'Null Publish Date', - hostName: 'localhost', - modDate: '1764284834965', - publishDate: null, - baseType: 'CONTENT', - inode: 'inode4', - archived: false, - host: 'host1', - ownerUserName: 'admin', - working: true, - locked: false, - stInode: 'stinode1', - contentType: 'Blog', - live: false, - owner: 'admin', - identifier: 'id4', - languageId: 1, - creationDate: '1764284749322', - shortyId: 'shorty4', - url: '/test4', - titleImage: 'TITLE_IMAGE_NOT_FOUND', - modUserName: 'admin', - hasLiveVersion: false, - folder: '/test', - hasTitleImage: false, - sortOrder: 0, - modUser: 'admin', - __icon__: 'icon', - contentTypeIcon: 'icon', - variant: 'default' - } - ] - }, - queryTook: 1 - }, - errors: [], - messages: [], - i18nMessagesMap: {}, - permissions: [] - }) + json: jest.fn().mockResolvedValue({ entity: 'not-a-valid-entity' }) }; mockFetch.mockResolvedValue(mockResponse); - const params = { - query: '+title:Null', - limit: 1, - languageId: 1, - depth: 1, - allCategoriesInfo: false + const params: SearchForm = { + assetPath: '//SiteName/', + includeSystemHost: true, + filters: { text: 'footer', filterFolders: true }, + contentTypes: [], + offset: 0, + maxResults: 20, + sortBy: 'modDate:desc', + archived: false, + showFolders: false }; - const result = await service.search(params); - - expect(result.entity?.jsonObjectView?.contentlets).toHaveLength(1); - expect(result.entity?.jsonObjectView?.contentlets?.[0].publishDate).toBeNull(); - }); - - it('should handle invalid search parameters', async () => { - const invalidParams = { query: 123 } as unknown as import('./search').SearchForm; - await expect(service.search(invalidParams)).rejects.toThrow( - 'Invalid search parameters' - ); - }); - it('should handle invalid search response format', async () => { - const mockResponse = { - json: jest.fn().mockResolvedValue({ entity: 'not-an-array' }) - }; - mockFetch.mockResolvedValue(mockResponse); - const params = { - query: '+title:Test', - limit: 1, - languageId: 1, - depth: 1, - allCategoriesInfo: false - }; - await expect(service.search(params)).rejects.toThrow('Invalid search response'); + await expect(service.search(params)).rejects.toThrow('Invalid drive search response'); }); - it('should handle fetch errors', async () => { + it('should propagate fetch errors', async () => { const error = new Error('Network error'); mockFetch.mockRejectedValue(error); - const params = { - query: '+title:Test', - limit: 1, - languageId: 1, - depth: 1, - allCategoriesInfo: false + + const params: SearchForm = { + assetPath: '//SiteName/', + includeSystemHost: true, + filters: { text: 'footer', filterFolders: true }, + contentTypes: [], + offset: 0, + maxResults: 20, + sortBy: 'modDate:desc', + archived: false, + showFolders: false }; + await expect(service.search(params)).rejects.toThrow('Network error'); }); }); diff --git a/core-web/apps/mcp-server/src/services/search.ts b/core-web/apps/mcp-server/src/services/search.ts index 443ca7b4bfad..6f4ecaac11dd 100644 --- a/core-web/apps/mcp-server/src/services/search.ts +++ b/core-web/apps/mcp-server/src/services/search.ts @@ -5,16 +5,27 @@ import { AgnosticClient } from './client'; import { SearchResponse, SearchResponseSchema } from '../types/search'; import { Logger } from '../utils/logger'; +/** + * Drive Search input schema + * Matches /api/v1/drive/search expected payload + */ export const SearchFormSchema = z.object({ - query: z.string(), - sort: z.string().optional(), - limit: z.number().int().optional().default(10), - offset: z.number().int().optional(), - userId: z.string().optional(), - render: z.string().optional(), - depth: z.number().int().optional().default(1), - languageId: z.number().int().optional().default(1), - allCategoriesInfo: z.boolean().optional().default(false) + assetPath: z.string().describe('Root path to search from, e.g. "//" or "//SiteName/"'), + includeSystemHost: z.boolean().default(true), + filters: z + .object({ + text: z.string().describe('Free text to search for'), + filterFolders: z.boolean().default(true) + }) + .describe('Search filters'), + language: z.array(z.string()).optional().describe('Array of languageIds as strings'), + contentTypes: z.array(z.string()).optional().default([]), + baseTypes: z.array(z.string()).optional().describe('e.g. ["HTMLPAGE","FILEASSET","CONTENT"]'), + offset: z.number().int().min(0).default(0), + maxResults: z.number().int().positive().default(20), + sortBy: z.string().default('modDate:desc'), + archived: z.boolean().default(false), + showFolders: z.boolean().default(false) }); export type SearchForm = z.infer; @@ -28,27 +39,26 @@ export class ContentSearchService extends AgnosticClient { } /** - * Search content using lucene query and SearchForm fields. - * @param body - SearchForm object - * @param rememberQuery - Optional, whether to remember the query (default: false) - * @returns Promise with the API response (unknown structure for now) + * Performs Drive Search using the /api/v1/drive/search endpoint. + * @param body - Drive Search form parameters + * @returns Promise with the API response */ - async search(body: SearchForm, rememberQuery = false): Promise { - this.serviceLogger.log('Starting content search operation', { body, rememberQuery }); + async search(body: SearchForm): Promise { + this.serviceLogger.log('Starting drive search operation', { body }); const validated = SearchFormSchema.safeParse(body); if (!validated.success) { - this.serviceLogger.error('Invalid search parameters', validated.error); + this.serviceLogger.error('Invalid drive search parameters', validated.error); throw new Error( - 'Invalid search parameters: ' + JSON.stringify(validated.error.format()) + 'Invalid drive search parameters: ' + JSON.stringify(validated.error.format()) ); } - this.serviceLogger.log('Search parameters validated successfully', validated.data); + this.serviceLogger.log('Drive search parameters validated successfully', validated.data); - const url = `/api/content/_search?rememberQuery=${rememberQuery}`; + const url = `/api/v1/drive/search`; try { - this.serviceLogger.log('Sending search request to dotCMS', { + this.serviceLogger.log('Sending drive search request to dotCMS', { url, params: validated.data }); @@ -58,22 +68,22 @@ export class ContentSearchService extends AgnosticClient { }); const data = await response.json(); - this.serviceLogger.log('Received response from dotCMS', data); + this.serviceLogger.log('Received response from dotCMS (drive search)', data); const parsed = SearchResponseSchema.safeParse(data); if (!parsed.success) { - this.serviceLogger.error('Invalid search response format', parsed.error); + this.serviceLogger.error('Invalid drive search response format', parsed.error); throw new Error( - 'Invalid search response: ' + JSON.stringify(parsed.error.format()) + 'Invalid drive search response: ' + JSON.stringify(parsed.error.format()) ); } - this.serviceLogger.log('Search response validated successfully', parsed.data); + this.serviceLogger.log('Drive search response validated successfully', parsed.data); return parsed.data; } catch (error) { - this.serviceLogger.error('Error during content search', error); + this.serviceLogger.error('Error during drive search', error); throw error; } } diff --git a/core-web/apps/mcp-server/src/tools/search/description.ts b/core-web/apps/mcp-server/src/tools/search/description.ts index da124d06cea4..5ac922479c67 100644 --- a/core-web/apps/mcp-server/src/tools/search/description.ts +++ b/core-web/apps/mcp-server/src/tools/search/description.ts @@ -1,157 +1,134 @@ export const searchDescription = ` -Searches content using Lucene syntax. Use this tool to query dotCMS content using Lucene queries. Only indexed fields can be searched. See the documentation for Lucene syntax instructions. +Universal search function for dotCMS content. -Only fields that are indexed can be searched. System fields like "title" or "modDate" are always indexed. For custom fields, the field must have the System Indexed option checked in your Content Type definition. To search a custom field, you must prefix it with the content type variable name. For example, if you have a "Products" content type and a "productType" field, you would write: +Use this function for ALL search operations. +It performs a Drive Search API query (no Lucene syntax required) and returns raw API results. -"+products.productType:etf" +The function posts structured parameters directly to: +POST /api/v1/drive/search -For system fields, you do not need the content type prefix. For example: +If context_initialization has not been called, call context_initialization first and then resume with this function. -"+title:bond" +IMPORTANT: +- Do NOT filter, collapse, summarize, or omit results unless explicitly instructed. +- If the user asks for "everything", ALL returned items must be listed. +- Internal content, file assets, widgets, dotAssets, and non-URL-mapped items MUST be included. -instead of +---------------------------------------- +Required / Common Parameters +---------------------------------------- -"+products.title:bond". +- assetPath (string, required) + Root path to search from. + Examples: + "//SiteName/" → specific site -A basic search term is written as "field:value". To make sure a term must be present, add "+" in front of it. To exclude a term, add "-". For example: + When searching items inside a folder, use the folder path as the assetPath. + Examples: + "//SiteName/folder/subfolder/" → specific folder -"+contentType:Blog +Blog.body:(+"investment" "JetBlue")" +- filters.text (string, case insensitive) + Free-text query string. May be empty for broad listings. -This means: must be a Blog, body must contain investment, body may contain JetBlue. +---------------------------------------- +Optional Parameters +---------------------------------------- -Operators include plus for required, minus for prohibited, AND written as "&&", OR written as "||", and NOT written as "!". +- includeSystemHost (boolean) + Whether to include the system host in results. -Example using AND: +- filters.filterFolders (boolean) + If true, excludes folders from results. -"+Blog.body:("business" && "Apple")" +- showFolders (boolean) + If true, explicitly includes folders in results. -This finds blog posts with both the word business and Apple in the body. +- language (string[]) + Language IDs as strings, e.g. ["1", "4600065"]. + Defaults to the system default language if known, otherwise "1" (English). + Examples: ["1", "4600065"] -Example using OR: +- contentTypes (string[]) + Optional list of content type variable names to restrict results. + Examples: ["article", "blog", "product"] -"+Blog.body:("analyst" || "investment")" +- baseTypes (string[], optional) + dotCMS base types to include. Refer to the section 'dotCMS Base Types Reference' for available values. If no baseTypes are queried, leave empty. + Examples: ["HTMLPAGE", "FILEASSET", "CONTENT"] -This finds blog posts with either analyst or investment in the body. +- archived (boolean) + Whether to include archived content. -Example using NOT: +---------------------------------------- +Pagination & Sorting +---------------------------------------- -"+Blog.body:("investment" !"JetBlue")" +- offset (number) + Starting index for results (default: 0). -This finds posts that have investment but do not have JetBlue. +- maxResults (number) + Maximum number of results to return (default: 20). -Wildcards are supported. Use "*" for multiple characters and "?" for a single character. +- sortBy (string) + Sort expression, e.g. "modDate:desc". -Example multiple character wildcard: +---------------------------------------- +dotCMS Base Types Reference +---------------------------------------- -"+Employee.firstName:R*" +0: HTMLPAGE + Page-based content types. -This finds employees whose first name starts with R. +1: FILEASSET + Uploaded files and media assets. -Example single character wildcard: +2: CONTENT + Standard structured content types. -"+Employee.firstName:Mari?" +3: WIDGET + Reusable widget instances sharing core code. -This finds employees whose first name is Maria or Marie. +4: KEY_VALUE + Key/value pair content types. -Fuzzy search is supported using a tilde "~". +5: VANITY_URL + Vanity URL content types with extensible fields. -Example: +6: DOTASSET + Internal dotCMS assets without standard URL paths. -"+title:dotCMS~" +7: PERSONA + Persona content types used for personalization. -This finds matches that are close to dotCMS, like dotcms or dotCMSs. +---------------------------------------- +Return Value +---------------------------------------- -Use square brackets with TO for a value range. Use the strict date format "yyyyMMddHHmmss" for dates. +Returns the raw Drive Search API response. +No post-processing or filtering is applied by this tool. -Example number range: +DEFAULT OUTPUT EXPECTATION: +- When returning search results, list ALL items. +- Prefer human-readable fields in this order when available: + 1. Title / Name + 2. Content Type / Base Type + 3. Short descriptor (e.g. Blog, Asset, Widget) +- URLs are optional and should NOT be assumed necessary. -"+News.title_dotraw:[A TO C]*" +CONSISTENCY RULE: +- If the API returns N results, the response MUST account for all N. +- If fewer than N items are displayed, the agent must explain why BEFORE responding. -This finds News items with a title starting A through C. +MIXED RESULT HANDLING: +- If results include multiple baseTypes (HTMLPAGE, FILEASSET, CONTENT, etc.), + group them by baseType unless the user asks otherwise. +- Assets are valid search results and must not be discarded. -Example date range: +CORRECTION BEHAVIOR: +- If the user corrects a misunderstanding ("I never asked for URLs"), + discard previous assumptions and re-answer from scratch using the tool output. -"+News.sysPublishDate:[20240101000000 TO 20241231235959]" - -This finds News items published in 2024. - -Use "catchall" to search all fields. - -Example: - -"+catchall:*seo*" - -This searches for seo anywhere in the content. - -Use a caret "^" to boost some terms so they rank higher. - -Example: - -"+News.title:("American"^10 "invest"^2)" - -This gives more weight to results with American than invest. - -Lucene treats a space between terms as OR by default. Always use explicit "||" or "&&" for clarity if needed. - -Example: - -"+Blog.body:("finance" "investment")" - -This means finance OR investment. - -Better: - -"+Blog.body:("finance" && "investment")" - -if you want both. - -When you want to match multiple values for the same field, wrap them in parentheses. - -Example: - -"+field:(term1 || term2)" - -or - -"+(field:term1 field:term2)" - -When you need an exact phrase, use double quotes. - -Example: - -"+"Blog.body":"final exam"" - -This matches final exam as a whole phrase in that order. - -Lucene does not directly check for null or empty values. If you need to find records with any value in a field, you can use a range hack. For example: - -"+typeVar.binaryVal:([0 TO 9] [a TO z] [A TO Z])" - -Always escape special characters so they are not treated as operators. Use a backslash. For example. - -Lucene syntax does not let you search relationships directly. If you need to work with related content, use "$dotcontent.pullRelated()" in Velocity or use the Content API "depth" parameter to pull related contentlets. - -Example queries you can use for reference: - -Find blog posts about investment but not JetBlue: - -"+contentType:Blog +Blog.body:("investment" !"JetBlue")" - -Find news with title starting A through C: - -"+contentType:News +News.title_dotraw:[A TO C]*" - -Find employees whose first name starts with R but last name does not start with A: - -"+contentType:Employee +(Employee.firstName:R*) -(Employee.lastName:A*)" - -Do a fuzzy search for dotCMS in the title: - -"+title:dotCMS~" - -Find news published in 2024: - -"+News.sysPublishDate:[20240101000000 TO 20241231235959]" - -When writing dotCMS Lucene queries, always use the correct content type and field format, explicit operators, escape special characters, stick to the strict date format, and do not generate raw OpenSearch JSON unless you really need advanced features. +MENTAL MODEL: +The MCP server is the source of truth. +The agent's role is to faithfully expose its results, not reinterpret them. `; diff --git a/core-web/apps/mcp-server/src/types/search.ts b/core-web/apps/mcp-server/src/types/search.ts index 68247b53c5be..bc12d3c9d820 100644 --- a/core-web/apps/mcp-server/src/types/search.ts +++ b/core-web/apps/mcp-server/src/types/search.ts @@ -1,85 +1,25 @@ import { z } from 'zod'; -// Dynamic contentlet: known fields + arbitrary fields -export const ContentletSchema = z - .object({ - hostName: z.string(), - modDate: z.preprocess((val) => { - if (typeof val === 'number') return val; - if (typeof val === 'string' && val) { - const num = Number(val); - return isNaN(num) ? 0 : num; - } - return 0; - }, z.number()), - publishDate: z.preprocess((val) => { - if (val === null || val === undefined) return val; - if (typeof val === 'number') return val; - if (typeof val === 'string' && val) { - const num = Number(val); - return isNaN(num) ? null : num; - } - return null; - }, z.number().nullish()), - title: z.string(), - baseType: z.string(), - inode: z.string(), - archived: z.boolean(), - host: z.string(), - ownerUserName: z.string(), - working: z.boolean(), - locked: z.boolean(), - stInode: z.string(), - contentType: z.string(), - live: z.boolean(), - owner: z.string(), - identifier: z.string(), - publishUserName: z.string().nullish(), - publishUser: z.string().nullish(), - languageId: z.number(), - creationDate: z.preprocess((val) => { - if (typeof val === 'number') return val; - if (typeof val === 'string' && val) { - const num = Number(val); - return isNaN(num) ? 0 : num; - } - return 0; - }, z.number()), - shortyId: z.string(), - url: z.string(), - titleImage: z.string(), - modUserName: z.string(), - hasLiveVersion: z.boolean(), - folder: z.string(), - hasTitleImage: z.boolean(), - sortOrder: z.number(), - modUser: z.string(), - __icon__: z.string(), - contentTypeIcon: z.string(), - variant: z.string() - }) - .catchall(z.unknown()); +/** + * Drive Search response schema + * Matches the structure returned by /api/v1/drive/search + */ +export const DriveSearchItemSchema = z.record(z.string(), z.unknown()); -export const JsonObjectViewSchema = z.object({ - contentlets: z.array(ContentletSchema) -}); - -export const EntitySchema = z.object({ - contentTook: z.number(), - jsonObjectView: JsonObjectViewSchema, - queryTook: z.number(), - resultsSize: z.number() +export const DriveSearchEntitySchema = z.object({ + contentCount: z.number(), + contentTotalCount: z.number(), + folderCount: z.number(), + list: z.array(DriveSearchItemSchema) }); export const SearchResponseSchema = z.object({ - entity: EntitySchema, - errors: z.array(z.unknown()), - i18nMessagesMap: z.record(z.string(), z.unknown()), - messages: z.array(z.unknown()), - pagination: z.unknown().nullable(), - permissions: z.array(z.unknown()) + entity: DriveSearchEntitySchema, + errors: z.array(z.unknown()).default([]), + i18nMessagesMap: z.record(z.string(), z.unknown()).default({}), + messages: z.array(z.unknown()).default([]), + pagination: z.unknown().nullable().optional(), + permissions: z.array(z.unknown()).default([]) }); -export type Contentlet = z.infer; - export type SearchResponse = z.infer; 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 } }