diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 24cb856b..e13f6314 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -2,6 +2,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` { + "contextManagement": false, "contextPath": "/", "contextUrl": "file:///", "docsPathSlug": "documentation:", @@ -30,6 +31,14 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "max": 256, "min": 1, }, + "resourceSearches": { + "max": 15, + "min": 0, + }, + "sha1Hex": { + "max": 40, + "min": 40, + }, "toolSearches": { "max": 10, "min": 0, diff --git a/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap b/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap index e6a52184..ff369249 100644 --- a/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap +++ b/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap @@ -30,6 +30,7 @@ exports[`getPatternFlyMcpResources should return multiple organized facets: prop "pathIndex", "uriIndex", "hashIndex", + "versionIndex", "byPath", "byUri", "byVersion", diff --git a/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap b/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap index 2dd23cad..dbedd848 100644 --- a/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap +++ b/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap @@ -73,6 +73,8 @@ exports[`setMetaResources should attempt to return a resource, metaConfig is a t "title": "Test Metadata", }, [Function], + undefined, + undefined, ] `; @@ -107,6 +109,8 @@ exports[`setMetaResources should attempt to return a resource, metaConfig is a t "title": "Test Metadata", }, [Function], + undefined, + undefined, ] `; @@ -138,6 +142,8 @@ exports[`setMetaResources should attempt to return a resource, metaConfig is a t "title": "Test Metadata", }, [Function], + undefined, + undefined, ] `; @@ -151,6 +157,8 @@ exports[`setMetaResources should attempt to return a resource, metaConfig is alm "title": "Test Metadata", }, [Function], + undefined, + undefined, ] `; @@ -164,6 +172,8 @@ exports[`setMetaResources should attempt to return a resource, metaConfig is emp "title": "Test Metadata", }, [Function], + undefined, + undefined, ] `; @@ -193,5 +203,7 @@ exports[`setMetaResources should attempt to return a resource, metaConfig is uni "title": "Test Metadata", }, [Function], + undefined, + undefined, ] `; diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 554f5321..0731dbdb 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -82,13 +82,13 @@ describe('runServer', () => { it.each([ { description: 'use default tools, stdio', - options: { name: 'test-server-1', version: '1.0.0' }, + options: { name: 'test-server-1', version: '1.0.0', contextManagement: undefined }, tools: undefined, transportMethod: MockStdioServerTransport }, { description: 'use default tools, http', - options: { name: 'test-server-2', version: '1.0.0', isHttp: true }, + options: { name: 'test-server-2', version: '1.0.0', isHttp: true, contextManagement: false }, tools: undefined, transportMethod: MockStartHttpTransport }, diff --git a/src/mcpSdk.ts b/src/mcpSdk.ts index 7961aa5e..6ff2c9bc 100644 --- a/src/mcpSdk.ts +++ b/src/mcpSdk.ts @@ -4,14 +4,16 @@ import { type ResourceMetadata, type CompleteResourceTemplateCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { type Tool } from '@modelcontextprotocol/sdk/types.js'; import { type GlobalOptions } from './options'; import { listAllCombinations, listIncrementalCombinations, splitUri } from './server.helpers'; /** * A tool registered with the MCP server. * - * @note Use of `any` here is intentional as part of a pass-through policy around - * `inputSchema`. Input schemas are actually reconstructed as part of the + * @note Use of `any` here is intentional as part of this typing. This is part of a general + * pass-through policy around our SDK types. + * - `inputSchema`: Input schemas are actually reconstructed as part of the * tools-as-plugins architecture to help guarantee that a minimal tool schema is * always available and minimally valid. * @@ -20,15 +22,22 @@ import { listAllCombinations, listIncrementalCombinations, splitUri } from './se * - `schema.description` `{string}`: Concise description of functionality for the tool. * - `schema.inputSchema` `{*}`: Internally, a raw Zod schema. Externally, a JSON or raw Zod schema. External tools are * converted to Zod for user convenience. - * 2. `handler` `{Function}`: Tool handler function for returning content. + * - `schema.annotations` `{Object}`: Optional annotations for the tool. + * 2. `handler` `{Function}`: Resource handler function for returning content. + * 3. `_config` `{Object}`: Internal Tool configuration. + * - `config.shouldRegister`: Optional callback to determine if the tool should be registered. */ type McpTool = [ name: string, schema: { description: string; inputSchema: any; + annotations?: Tool['annotations'] | any; }, - handler: (arg?: unknown) => any | Promise + handler: (arg?: unknown) => any | Promise, + _config?: { + shouldRegister?: (options: GlobalOptions) => boolean | Promise; + } ]; /** @@ -85,19 +94,35 @@ interface McpResourceMetadata { /** * A resource registered with the MCP server. * - * 0. `name`: Registered name of the resource. - * 1. `uriOrTemplate`: URI string or template. {@link ResourceTemplate} - * 2. `config`: Resource configuration metadata. {@link ResourceMetadata} - * 3. `handler`: Resource handler function. - * 4. `metadata`: Optional **internal metadata** object, not used by the standard MCP SDK + * 0. `name` `{string}`: Registered name of the resource. + * 1. `uriOrTemplate` `{string}`: URI string or template. {@link ResourceTemplate} + * 2. `config` `{Object}`: Resource configuration metadata. {@link ResourceMetadata} + * 3. `handler` `{Function}`: Resource handler function. + * 4. `metadata` `{Object}`: Optional **internal metadata** object, not used by the standard MCP SDK * resource registry. {@link McpResourceMetadata} + * 5. `_config` `{Object}`: Internal Resource configuration. + * - `_config.shouldRegister` `{Function|Promise}`: Optional callback to determine if the resource should be registered. + * + * @note Annotations help with prioritizing resources and help manage context. They contain 3 primary properties: + * - `priority`: A ranking from `0.0` to `1.0`. `1.0` being the highest priority, and `0.0` being the lowest. + * - `audience`: This can be `user` or `assistant`, possibly both. + * - `lastModified': an ISO 8601 formatted string, representing the last time the resource was modified, helps invalidate caches. + * + * How to assign a priority: + * - `Indexes`: A resource index for directory nav is generally higher `0.8` to `1.0`, it's an anchor + * point if the model needs a map. + * - `Dynamic resource templates`: A resource template that contains dynamic content is generally lower `0.3` to `0.5`, + * it's a placeholder for a resource, and can generally shift. It can also be reattained by calling again. */ type McpResource = [ name: string, uriOrTemplate: string | ResourceTemplate, config: ResourceMetadata, handler: (...args: any[]) => any | Promise, - metadata?: McpResourceMetadata | undefined + metadata?: McpResourceMetadata | undefined, + _config?: { + shouldRegister?: (options: GlobalOptions) => boolean | Promise; + } | undefined ]; /** diff --git a/src/options.defaults.ts b/src/options.defaults.ts index e6f84186..3a14d337 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -10,6 +10,9 @@ import { getNodeMajorVersion } from './options.helpers'; * @interface DefaultOptions * * @template TLogOptions The logging options type, defaulting to LoggingOptions. + * @property contextManagement - Strategy for managing agent context and response sizes, primarily within MCP tools. + * - 'false': Default standard text-heavy responses. + * - 'true': High-efficiency mode for MCP tools, using McpResource links. * @property contextPath - Current working directory. * @property contextUrl - Current working directory URL. * @property docsPaths - List of allowed local documentation directories handled by `docsPathSlug` @@ -49,6 +52,7 @@ import { getNodeMajorVersion } from './options.helpers'; * @property xhrFetch - XHR and Fetch options. */ interface DefaultOptions { + contextManagement: boolean; contextPath: string; contextUrl: string; docsPaths: string[]; @@ -131,7 +135,9 @@ interface LoggingOptions { * @interface MinMax * * @property urlString Minimum and maximum length for URL strings. - * @property toolSearches Minimum and maximum number of tool searches. + * @property resourceSearches Minimum and maximum number of resource results for searches. + * @property sha1Hex Minimum and maximum length for SHA-1 hex strings. + * @property toolSearches Minimum and maximum number of tool results for searches. * @property inputStrings Minimum and maximum length for input strings. * @property docsToLoad Minimum and maximum number of docs to load. */ @@ -140,6 +146,14 @@ interface MinMax { min: number; max: number; } + resourceSearches: { + min: number; + max: number; + } + sha1Hex: { + min: number; + max: number; + } toolSearches: { min: number; max: number; @@ -319,12 +333,23 @@ const HTTP_OPTIONS: HttpOptions = { /** * Minimum and maximum ranges for various options. + * + * @note For resourceSearches you still have to take into account that for every result + * there could be multiple resources. */ const MIN_MAX: MinMax = { urlString: { min: 11, max: 1500 }, + resourceSearches: { + min: 0, + max: 15 + }, + sha1Hex: { + min: 40, + max: 40 + }, toolSearches: { min: 0, max: 10 @@ -498,6 +523,7 @@ const PLUGIN_ISOLATION: DefaultOptions['pluginIsolation'][] = ['none', 'strict'] * @type {DefaultOptions} Default options object. */ const DEFAULT_OPTIONS: DefaultOptions = { + contextManagement: false, contextPath: (process.env.NODE_ENV === 'local' && '/') || resolve(process.cwd()), contextUrl: pathToFileURL((process.env.NODE_ENV === 'local' && '/') || resolve(process.cwd())).href, docsPaths: [], diff --git a/src/options.parser.ts b/src/options.parser.ts index 86d45587..cf79da8a 100644 --- a/src/options.parser.ts +++ b/src/options.parser.ts @@ -224,6 +224,9 @@ const parseCliOptions = ( } } break; + case '--context-management': + result.contextManagement = true; + break; } }; diff --git a/src/options.registry.ts b/src/options.registry.ts index 20ec3a9b..3f36e2a8 100644 --- a/src/options.registry.ts +++ b/src/options.registry.ts @@ -1,8 +1,11 @@ import { type McpToolCreator, type McpResourceCreator } from './mcpSdk'; +import { searchPatternFlyTool } from './tool.searchPatternFly'; import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; import { searchPatternFlyDocsTool } from './tool.searchPatternFlyDocs'; +import { patternFlyComponentsResource } from './resource.patternFlyComponents'; import { patternFlyComponentsIndexResource } from './resource.patternFlyComponentsIndex'; import { patternFlyContextResource } from './resource.patternFlyContext'; +import { patternFlyDocsResource } from './resource.patternFlyDocs'; import { patternFlyDocsIndexResource } from './resource.patternFlyDocsIndex'; import { patternFlyDocsTemplateResource } from './resource.patternFlyDocsTemplate'; import { patternFlySchemasIndexResource } from './resource.patternFlySchemasIndex'; @@ -15,7 +18,8 @@ import { patternFlySchemasTemplateResource } from './resource.patternFlySchemasT */ const builtinTools: McpToolCreator[] = [ usePatternFlyDocsTool, - searchPatternFlyDocsTool + searchPatternFlyDocsTool, + searchPatternFlyTool ]; /** @@ -29,7 +33,9 @@ const builtinResources: McpResourceCreator[] = [ patternFlyDocsIndexResource, patternFlyDocsTemplateResource, patternFlySchemasIndexResource, - patternFlySchemasTemplateResource + patternFlySchemasTemplateResource, + patternFlyComponentsResource, + patternFlyDocsResource ]; export { builtinTools, builtinResources }; diff --git a/src/options.ts b/src/options.ts index 0f9e508e..749e0de6 100644 --- a/src/options.ts +++ b/src/options.ts @@ -86,7 +86,8 @@ const SET_OPTIONS = { docsPaths: defineOption({ cli: false })(), name: defineOption({ cli: false })(), toolModules: defineOption({ cli: true })(), - version: defineOption({ cli: false })() + version: defineOption({ cli: false })(), + contextManagement: defineOption({ cli: true, experimental: true })() } as const; /** diff --git a/src/patternFly.getResources.ts b/src/patternFly.getResources.ts index 5900b1c7..a3db0dc0 100644 --- a/src/patternFly.getResources.ts +++ b/src/patternFly.getResources.ts @@ -76,9 +76,12 @@ interface PatternFlyMcpComponentNames { * @property name - The name of document entry. * @property displayCategory - The display category of document entry. * @property uri - The parent resource's general URI that can reflect a grouping of document entries. - * @property uriId - The resource's exact URI for the document entry. + * @property uriId - The resource's query ID with exact URI for the document entry. + * @property uriHash - The resource's exact URI for the document entry. * @property uriSchemas - The parent resource's general URI for the related component schemas, if they exist. - * @property uriSchemasId - The resource's schemas URI for the component schemas, if they exist. Keyed by + * @property uriSchemasId - The resource's schemas URI with query ID for the component schemas, if they exist. Keyed with + * the parent resource's `groupId` since the URIs are the same for sibling entries. + * @property uriComponentHash - The resource's schemas exact URI for the component schemas, if they exist. Keyed with * the parent resource's `groupId` since the URIs are the same for sibling entries. */ type PatternFlyMcpDocsMeta = { @@ -86,10 +89,15 @@ type PatternFlyMcpDocsMeta = { groupId: string; name: string; displayCategory: string; + isSchemasAvailable: boolean; uri: string; uriId: string; + uriHash: string; uriSchemas?: string | undefined; uriSchemasId?: string | undefined; + uriComponent?: string | undefined; + uriComponentId?: string | undefined; + uriComponentHash?: string | undefined; }; /** @@ -133,16 +141,20 @@ type PatternFlyMcpKeywordsMap = Map>; * * Contextual properties * - Contextual properties are populated based on search and filtering. - * - Do not expect them to exist, make sure to conditionally load them. + * - Do not expect them to exist, make sure to confirm. You may need to conditionally load them. * * @property name - The name of component entry. * @property entries - All entry PatternFly documentation entries. * @property versions - Entry segmented by versions. Contains all the same properties. * @property groupId - The unique identifier for the document group. * @property isSchemasAvailable - see {@link PatternFlyMcpDocsMeta.isSchemasAvailable} **CONTEXTUAL**. + * @property id - see {@link PatternFlyMcpDocsMeta.id} **CONTEXTUAL**. * @property uri - see {@link PatternFlyMcpDocsMeta.uri} **CONTEXTUAL**. + * @property uriId - see {@link PatternFlyMcpDocsMeta.uriId} **CONTEXTUAL**. + * @property uriHash - see {@link PatternFlyMcpDocsMeta.uriHash} **CONTEXTUAL**. * @property uriSchemas - see {@link PatternFlyMcpDocsMeta.uriSchemas} **CONTEXTUAL**. * @property uriSchemasId - see {@link PatternFlyMcpDocsMeta.uriSchemasId} **CONTEXTUAL**. + * @property uriComponentHash - see {@link PatternFlyMcpDocsMeta.uriComponentHash} **CONTEXTUAL**. */ type PatternFlyMcpResourceMetadata = { name: string; @@ -151,9 +163,15 @@ type PatternFlyMcpResourceMetadata = { groupId: string; isSchemasAvailable: boolean | undefined; + id: string | undefined; uri: string | undefined; + uriId: string | undefined; + uriHash: string | undefined; uriSchemas: string | undefined; uriSchemasId: string | undefined; + uriComponent: string | undefined; + uriComponentId: string | undefined; + uriComponentHash: string | undefined; }; /** @@ -175,6 +193,7 @@ type PatternFlyMcpResourceMetadata = { * @property pathIndex - Patternfly documentation path->name map for helping refine search results. * @property uriIndex - Patternfly documentation uri->name map for helping refine search results. * @property hashIndex - Patternfly documentation hash->name map for helping refine search results. + * @property versionIndex - Patternfly documentation sort by the latest version first, then alphabetically by entries * @property byPath - Patternfly documentation by path with entries * @property byUri - `@deprecated Under review. Use uriIndex`. Patternfly documentation by uri with entries * @property byVersion - Patternfly documentation by version with entries @@ -190,6 +209,7 @@ interface PatternFlyMcpAvailableResources extends PatternFlyVersionContext { pathIndex: Map; uriIndex: Map; hashIndex: Map; + versionIndex: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; byPath: PatternFlyMcpResourcesByPath; byUri: PatternFlyMcpResourcesByUri; byVersion: PatternFlyMcpResourcesByVersion; @@ -488,6 +508,9 @@ const mutateKeyWordsMap = ( * are populated during search and filter services when `entries` are matched against PF versions. Review * separating the typings for clarity. * + * @note Need to re-eval component resources being "schema-dependent". Under the experimental-context-management + * we've temporarily made them dependent since schemas include specs. This may shift under related API work. + * * @param contextPathOverride - Context path for updating the returned PatternFly versions. * @returns A multifaceted documentation breakdown. Use the "memoized" property for performance. */ @@ -504,6 +527,7 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< const pathIndexMap = new Map(); const uriIndexMap = new Map(); const hashIndexMap = new Map(); + const versionIndex: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[] = []; const rawKeywordsMap: PatternFlyMcpKeywordsMap = new Map(); const catalog = [...Object.entries(originalDocs.docs), ...Array.from(componentNamesByDocs)]; @@ -521,10 +545,16 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< groupId, entries: [], versions: {}, + id: undefined, isSchemasAvailable: undefined, uri: undefined, + uriId: undefined, + uriHash: undefined, uriSchemas: undefined, - uriSchemasId: undefined + uriSchemasId: undefined, + uriComponent: undefined, + uriComponentId: undefined, + uriComponentHash: undefined }); } @@ -537,11 +567,16 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< const isSchemasAvailable = versionContext.latestSchemasVersion === version && componentNamesByVersion.get(version)?.[name]?.isSchemasAvailable; const path = entry.path; const uri = `patternfly://docs/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; - const uriId = `patternfly://docs/${encodeURIComponent(id)}`; + const uriId = `patternfly://docs/${encodeURIComponent(name)}${buildSearchString({ id }, { prefix: true })}`; + const uriHash = `patternfly://docs/${encodeURIComponent(id)}`; + // const uriComponent = `patternfly://components/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; + // const uriComponentId = `patternfly://components/${encodeURIComponent(name)}${buildSearchString({ id: groupId }, { prefix: true })}`; + // const uriComponentHash = `patternfly://components/${encodeURIComponent(groupId)}`; hashIndexMap.set(id.toLowerCase(), name); uriIndexMap.set(uri.toLowerCase(), name); uriIndexMap.set(uriId.toLowerCase(), name); + uriIndexMap.set(uriHash.toLowerCase(), name); if (path) { pathIndexMap.set(path.toLowerCase(), name); @@ -550,7 +585,13 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< resource.versions[version] ??= { groupId, isSchemasAvailable, + id, uri, + uriId, + uriHash, + uriComponent: undefined, + uriComponentId: undefined, + uriComponentHash: undefined, uriSchemas: undefined, uriSchemasId: undefined, entries: [] @@ -560,10 +601,13 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< const displayCategory = setCategoryDisplayLabel(entry as PatternFlyMcpDocsCatalogDoc); let uriSchemas; let uriSchemasId; + let uriComponent; + let uriComponentId; + let uriComponentHash; if (isSchemasAvailable) { uriSchemas = `patternfly://schemas/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; - uriSchemasId = `patternfly://schemas/${encodeURIComponent(groupId)}`; + uriSchemasId = `patternfly://schemas/${encodeURIComponent(name)}${buildSearchString({ id: groupId }, { prefix: true })}`; resource.versions[version].uriSchemas = uriSchemas; resource.versions[version].uriSchemasId = uriSchemasId; @@ -572,17 +616,32 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< uriIndexMap.set(uriSchemasId.toLowerCase(), name); } + if (entry.section === 'components' && entry.category === 'react') { + uriComponent = `patternfly://components/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; + uriComponentId = `patternfly://components/${encodeURIComponent(name)}${buildSearchString({ id: groupId }, { prefix: true })}`; + uriComponentHash = `patternfly://components/${encodeURIComponent(groupId)}`; + + uriIndexMap.set(uriComponent.toLowerCase(), name); + uriIndexMap.set(uriComponentId.toLowerCase(), name); + uriIndexMap.set(uriComponentHash.toLowerCase(), name); + } + const extendedEntry = { ...entry, id, groupId, + isSchemasAvailable, name, displayName, displayCategory, uri, uriId, + uriHash, uriSchemas, - uriSchemasId + uriSchemasId, + uriComponent, + uriComponentId, + uriComponentHash } as (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta); if (path) { @@ -597,6 +656,11 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< byUri[uriSchemas]?.push(extendedEntry); } + if (uriComponent) { + byUri[uriComponent] ??= []; + byUri[uriComponent]?.push(extendedEntry); + } + byVersion[version] ??= []; byVersion[version]?.push(extendedEntry); @@ -623,9 +687,15 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< }); }); - Object.entries(byVersion).forEach(([_version, entries]) => { - entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); - }); + Object.entries(byVersion) + .sort(([a], [b]) => b.localeCompare(a)) + .forEach(([_version, entries]) => { + entries + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + .forEach((entry, _index) => { + versionIndex.push(entry); + }); + }); const filteredKeywords = filterKeywords(rawKeywordsMap); @@ -645,6 +715,7 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< pathIndex: pathIndexMap, uriIndex: uriIndexMap, hashIndex: hashIndexMap, + versionIndex, byPath, // @deprecated byUri - Under review byUri, diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index 1ccf5089..acd63855 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -34,6 +34,7 @@ type PatternFlyMcpResourceFilteredMetadata = Omit} byResource - Map of filtered resources by resource name. + * @property byEntry - Array of filtered documentation entries, {@link FilterPatternFlyResultsByEntry} + * @property byResource - Map of filtered resources by resource name, {@link FilterPatternFlyResultsByResource} */ interface FilterPatternFlyResults { - byEntry: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; - byResource: Map; + byEntry: FilterPatternFlyResultsEntry[]; + byResource: Map; } /** @@ -229,8 +241,8 @@ const filterPatternFly = async ( } // Filter matching for resources and entries - const byResource = new Map(); - const byEntry: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[] = []; + const byResource = new Map(); + const byEntry: FilterPatternFlyResultsEntry[] = []; const filterMatch = (propertyValue: unknown, filterValue: string) => { const normalizePropertyValue = String(propertyValue).trim().toLowerCase(); @@ -273,15 +285,20 @@ const filterPatternFly = async ( const matchesCategory = !updatedFilters.category || filterMatch(entry.category, updatedFilters.category); const matchesSection = !updatedFilters.section || filterMatch(entry.section, updatedFilters.section); const matchesPath = !updatedFilters.path || filterMatch(entry.path, updatedFilters.path) || - filterMatch(entry.uriId, updatedFilters.path) || filterMatch(entry.uriSchemas, updatedFilters.path) || - filterMatch(entry.uriSchemasId, updatedFilters.path) || filterMatch(entry.uri, updatedFilters.path); + filterMatch(entry.uriId, updatedFilters.path) || filterMatch(entry.uriHash, updatedFilters.path) || + filterMatch(entry.uriSchemas, updatedFilters.path) || filterMatch(entry.uriComponent, updatedFilters.path) || + filterMatch(entry.uriSchemasId, updatedFilters.path) || filterMatch(entry.uriComponentId, updatedFilters.path) || + filterMatch(entry.uriComponentHash, updatedFilters.path) || filterMatch(entry.uri, updatedFilters.path); // Filter order matters specific id -> group id -> group name const matchesName = !updatedFilters.name || filterMatch(entry.id, updatedFilters.name) || filterMatch(entry.groupId, updatedFilters.name) || filterMatch(entry.name, updatedFilters.name); + const matchesId = !updatedFilters.id || filterMatch(entry.groupId, updatedFilters.id) || + filterMatch(entry.id, updatedFilters.id); + // Any missing filter registers as true. Only filters that are active run their check. - return matchesVersion && matchesCategory && matchesSection && matchesPath && matchesName; + return matchesVersion && matchesCategory && matchesSection && matchesPath && matchesName && matchesId; }); if (signal?.aborted) { @@ -303,8 +320,11 @@ const filterPatternFly = async ( versionContextualProperties = { isSchemasAvailable: versions[updatedFilters.version]?.isSchemasAvailable, uri: versions[updatedFilters.version]?.uri, + uriId: versions[updatedFilters.version]?.uriId, uriSchemas: versions[updatedFilters.version]?.uriSchemas, - uriSchemasId: versions[updatedFilters.version]?.uriSchemasId + uriSchemasId: versions[updatedFilters.version]?.uriSchemasId, + uriComponent: versions[updatedFilters.version]?.uriComponent, + uriComponentId: versions[updatedFilters.version]?.uriComponentId }; } @@ -646,6 +666,8 @@ export { searchPatternFly, type FilterPatternFlyFilters, type FilterPatternFlyResults, + type FilterPatternFlyResultsEntry, + type FilterPatternFlyResultsResource, type FilterPatternFlySettings, type SearchPatternFlyResult, type SearchPatternFlyResults diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index 670fa081..8d181de4 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -1,5 +1,183 @@ import { filterPatternFly, type FilterPatternFlyFilters } from './patternFly.search'; import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; +import { buildSearchString, stringJoin } from './server.helpers'; + +/** + * Returns a consistent summarized, or full version, of the input text with: + * - YAML front matter, if defined, is added to the front of the content. + * - Full and summary links, if a URL is provided, are added to the end of the content. + * - Summaries truncate to a configurable maximum length. + * - Full content is returned as-is + * + * @param content - Input text to summarize or format. + * @param [settings] - Optional settings object. + * @param [settings.descLinkSummary='Read summary documentation'] - Description for the summary link. + * @param [settings.descLinkFull='Read full documentation'] - Description for the full link. + * @param [settings.descTruncate='truncated content'] - Description for the truncated content link. + * @param [settings.descTruncateCode='truncated code block'] - Description for the truncated code block link. + * @param [settings.detailType] - Whether to return a full or summary version of the content. Defaults to 'full'. + * @param [settings.frontMatter] - YAML front matter to include in the content. + * @param [settings.summaryLength] - The maximum length of the summary. Defaults to 250 characters. + * @param [settings.url] - URL to link to. + * @returns Formatted content with optional YAML front matter and links. + */ +const formatSummaryFullContent = ( + content: string, + { + descLinkSummary = 'Read summary documentation', + descLinkFull = 'Read full documentation', + descTruncate = 'truncated content', + descTruncateCode = 'truncated code block', + detailType = 'full', + frontMatter, + summaryLength = 250, + url + }: { + descLinkSummary?: string; + descLinkFull?: string; + descTruncate?: string; + descTruncateCode?: string; + detailType?: 'full' | 'summary'; + frontMatter?: Record; + summaryLength?: number; + url?: string | undefined; + } = {} +) => { + const isSummary = detailType === 'summary'; + let detailLink; + let updatedLink; + + if (url) { + detailLink = isSummary + ? `full_uri: ${url}${buildSearchString({ detail: 'full' }, { base: url })}` + : `summary_uri: ${url}${buildSearchString({ detail: 'summary' }, { base: url })}`; + + updatedLink = isSummary && url + ? `[${descLinkFull}](${url})` + : `[${descLinkSummary}](${url})`; + } + + const updatedFrontMatter = stringJoin.newlineFiltered( + `---`, + ...Object.entries(frontMatter || {}).map(([key, value]) => (value && `${key}: ${value}`) || undefined), + detailLink, + `detail: ${(isSummary && 'full') || 'summary'}`, + `---` + ); + + if (detailType === 'full' || content.length <= summaryLength) { + return stringJoin.newlineFiltered( + updatedFrontMatter, + content, + updatedLink + ); + } + + let truncated = content.substring(0, summaryLength); + + // Protect Code Blocks: If we are inside a code block, close it or back out. + const codeBlockCount = (truncated.match(/```/g) || []).length; + + // If we are inside an odd number of code blocks, close it. + if (codeBlockCount % 2 === 1) { + const lastCodeBlock = truncated.lastIndexOf('```'); + + if (lastCodeBlock > summaryLength * 0.5) { + truncated = truncated.substring(0, lastCodeBlock).trim(); + } else { + truncated += '\n... [' + descTruncateCode + ']\n```'; + } + } + + // Protect Headers: Don't end on a trailing header line. + const lastNewline = truncated.lastIndexOf('\n'); + const lastLine = truncated.substring(lastNewline + 1); + + if (lastLine.trim().startsWith('#')) { + truncated = truncated.substring(0, lastNewline).trim(); + } + + // Breakpoint check + const lastPeriod = truncated.lastIndexOf('.'); + const breakPoint = Math.max(lastPeriod, truncated.lastIndexOf('\n')); + const updatedContent = breakPoint > summaryLength * 0.7 + ? truncated.substring(0, breakPoint + 1).trim() + : truncated.trim(); + + return stringJoin.newlineFiltered( + updatedFrontMatter, + `${updatedContent}... [${descTruncate}]`, + updatedLink + ); +}; + +/** + * Creates an object containing methods for encoding and decoding cursor values. + * - Cursor encoding and decoding is based on a configurable `salt` and `encoding`. + * - `encodeCursor` falls back to `0` if the provided offset is not a number or `undefined`. + * - `decodeCursor` returns `0` if the provided cursor is not a string or empty. + * + * @param [params] - Options + * @param [params.salt='offset'] - A string used as a prefix to encode cursor values. + * @param [params.encoding='hex'] - The desired buffer encoding format for cursor strings. + * @returns An object with methods for encoding and decoding cursors. + * - `encodeCursor`: Encodes a numeric offset into a string cursor. + * - `decodeCursor`: Decodes a string cursor back into a numeric offset. + */ +const encodeDecodeCursor = ({ salt = 'offset', encoding = 'hex' }: { salt?: string; encoding?: BufferEncoding } = {}) => ({ + encodeCursor: (offset?: number | undefined) => { + if (typeof offset !== 'number') { + return Buffer.from(`${salt}:0`).toString(encoding); + } + + return Buffer.from(`${salt}:${offset}`).toString(encoding); + }, + decodeCursor: (cursor?: string | undefined) => { + if (typeof cursor !== 'string' || !cursor) { + return 0; + } + + try { + const decrypted = Buffer.from(cursor, encoding).toString('utf8'); + const [_, offset] = decrypted.split(`${salt}:`); + + return (offset && parseInt(offset, 10)) || 0; + } catch { + return 0; + } + } +}); + +/** + * Calculates the next cursor for paginated data. + * + * If the calculated index exceeds the size of the data, it returns `undefined` + * to indicate the end of the available data. + * + * @param params - The parameter object. + * @param params.cursor - The current encoded cursor position. + * @param [params.pageSize=50] - The number of items per page. + * @param params.size - The total size of the data. + * @returns The encoded next cursor or `undefined` if there is no next page. + */ +const nextCursor = ({ cursor, pageSize = 50, size }: { cursor: string | undefined; pageSize: number, size: number }) => { + const { encodeCursor, decodeCursor } = encodeDecodeCursor(); + const index = decodeCursor(cursor); + + if (index + pageSize >= size) { + return { + next: undefined, + start: index, + end: size + }; + } + + return { + next: encodeCursor(index + pageSize), + start: index, + end: index + pageSize + }; +}; /** * Centralized completion logic for PatternFly resources. @@ -47,4 +225,4 @@ const paramCompletion = async (filters: FilterPatternFlyFilters) => { }; }; -export { paramCompletion }; +export { encodeDecodeCursor, formatSummaryFullContent, nextCursor, paramCompletion }; diff --git a/src/resource.patternFlyComponents.ts b/src/resource.patternFlyComponents.ts new file mode 100644 index 00000000..5db87ed6 --- /dev/null +++ b/src/resource.patternFlyComponents.ts @@ -0,0 +1,423 @@ +import { + ResourceTemplate, + type ListResourcesCallback, + type CompleteResourceTemplateCallback +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import { type McpResource } from './mcpSdk'; +import { memo } from './server.caching'; +import { buildSearchString, stringJoin } from './server.helpers'; +import { + assertInput, + assertInputStringLength, + assertInputStringShaHex +} from './server.assertions'; +import { findClosest } from './server.search'; +import { getOptions, runWithOptions } from './options.context'; +import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; +import { + getPatternFlyComponentSchema, + getPatternFlyMcpResources +} from './patternFly.getResources'; +import { filterPatternFly, type FilterPatternFlyResultsResource } from './patternFly.search'; +import { + type PatternFlyListResourceResult, + type ExtendedCompleteResourceTemplateCallback +} from './resource.patternFlyDocsIndex'; +import { + formatSummaryFullContent, + nextCursor, + paramCompletion +} from './resource.helpers'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-components'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://components/{name}{?id,version,category,detail}'; + +/** + * URI description for the resource. + */ +const URI_DESCRIPTION = `Filter by PatternFly version and category. ${URI_TEMPLATE}`; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Components Index', + description: `A list of all PatternFly components available for documentation retrieval. ${URI_DESCRIPTION}`, + mimeType: 'text/markdown', + annotations: { + priority: 0.9, + audience: ['assistant' as const] + } +}; + +/** + * List resources callback for the URI template. + * + * @param _extra + * @param cursor + * @note We use "byVersionComponentNames" instead of "byVersion" because it's specific to components. + * Docs resources don't necessarily contain all components. + * + * @returns {Promise} The list of available resources. + */ +const listResources = async (_extra: unknown, cursor?: string | undefined) => { + const pageSize = 50; + const { versionIndex } = await getPatternFlyMcpResources.memo(); + const { start, end, next } = nextCursor({ cursor, pageSize, size: versionIndex.length }); + const resources: PatternFlyListResourceResult[] = []; + + versionIndex + .filter(entry => entry.uriComponentId !== undefined) + .slice(start, end).forEach((entry, _index) => { + const actualIndex = start + 1; + + resources.push({ + uri: entry.uriComponentId as string, + name: `${entry.displayName} - ${entry.isSchemasAvailable ? 'Technical Specs' : 'Technical Overview'} (${entry.version}) (${actualIndex}/${versionIndex.length} components)`, + description: entry.description, + mimeType: 'text/markdown' + }); + }); + + return { + totalCount: versionIndex.length, + pageSize, + nextCursor: next, + resources + }; +}; + +/** + * Memoized version of listResources. + */ +listResources.memo = memo(listResources); + +/** + * Detail completion callback for the URI template. + * + * @param detail - The value to complete. + * @returns The list of available details. + */ +const uriDetailComplete: ExtendedCompleteResourceTemplateCallback = async (detail: string) => { + const levels = ['summary', 'full']; + const closest = findClosest.memo(detail, levels) as string | undefined; + + return closest ? [closest] : []; +}; + +/** + * Memoized version of uriDetailComplete. + */ +uriDetailComplete.memo = memo(uriDetailComplete); + +/** + * Category completion callback for the URI template. + * + * @param category - The value to filter-by/complete. + * @param context - The completion context containing arguments for the URI template. + * @returns The list of available categories, or an empty list. + */ +const uriCategoryComplete: ExtendedCompleteResourceTemplateCallback = async (category: string, context) => { + const { version, name } = context?.arguments || {}; + const section = 'components'; + const { categories } = await paramCompletion({ category, name, section, version }); + + return categories; +}; + +/** + * Memoized version of uriCategoryComplete. + */ +uriCategoryComplete.memo = memo(uriCategoryComplete); + +/** + * Version completion callback for the URI template. + * + * @param version - The value to complete. + * @param context - The completion context containing arguments for the URI template. + * @returns The list of available versions, or an empty list. + */ +const uriVersionComplete: ExtendedCompleteResourceTemplateCallback = async (version: string, context) => { + const { category, name } = context?.arguments || {}; + const section = 'components'; + const { versions } = await paramCompletion({ category, name, section, version }); + + return versions; +}; + +/** + * Memoized version of uriVersionComplete. + */ +uriVersionComplete.memo = memo(uriVersionComplete); + +/** + * Resource callback for the documentation index. + * + * @param passedUri - URI of the resource. + * @param variables - Variables for the resource. + * @param options - Options for the resource. + * @returns The resource contents. + */ +const resourceCallback = async (passedUri: URL, variables: Record, options = getOptions()) => { + const { version, category, id, name, detail = 'summary' } = variables || {}; + const normalizedDetail = (findClosest.memo(detail, ['summary', 'full']) || detail) as 'summary' | 'full'; + const section = 'components'; + let updatedId; + + assertInputStringLength(name, { + ...options.minMax.inputStrings, + inputDisplayName: 'name' + }); + + if (id) { + assertInputStringShaHex(id, { + ...options.minMax.sha1Hex, + inputDisplayName: 'id' + }); + + // Be lenient, only apply the ID if it's different from name. + if (id !== name) { + updatedId = id; + } + } + + if (version) { + assertInputStringLength(version, { + ...options.minMax.inputStrings, + inputDisplayName: 'version' + }); + } + + if (category) { + assertInputStringLength(category, { + ...options.minMax.inputStrings, + inputDisplayName: 'category' + }); + } + + const { availableVersions, availableSchemasVersions, latestVersion } = await getPatternFlyMcpResources.memo(); + const normalizedVersion = await normalizeEnumeratedPatternFlyVersion.memo(version); + + assertInput( + !version || Boolean(normalizedVersion), + `Invalid PatternFly version "${version?.trim()}". Available versions are: ${availableVersions.join(', ')}` + ); + + const updatedVersion = normalizedVersion || latestVersion; + const updatedName = name.trim(); + + const { byResource } = await filterPatternFly.memo({ + id: updatedId, + version: updatedVersion, + name: updatedName, + category, + section + }); + + const resource: FilterPatternFlyResultsResource | undefined = byResource.get(name); + + assertInput( + resource !== undefined, + () => { + let suggestionMessage = ''; + + if (id || version || category || section) { + const variableList = [ + (version && 'id') || undefined, + (version && 'version') || undefined, + (category && 'category') || undefined + ].filter(Boolean).join(', '); + + suggestionMessage = ` Try using different parameters for ${variableList}.`; + } + + return `No component found for "${updatedName}".${suggestionMessage}`; + } + ); + + /** + * Get the JSON schema for the component. + * + * @param name - Name of the component. + * @returns The JSON schema for the component. + */ + const getSchema = async (name: string) => { + const schema = await getPatternFlyComponentSchema.memo(name); + + assertInput( + schema !== undefined, + () => { + let suggestionMessage = ''; + + if (!availableSchemasVersions.includes(updatedVersion)) { + suggestionMessage = ` Component schemas are only available for PatternFly versions ${availableSchemasVersions.join(', ')}`; + } + + return `No component schema found for "${passedUri?.toString()}".${suggestionMessage}`; + } + ); + + return schema; + }; + + /** + * Get the JSON schema properties for the component. + * + * @param name - Name of the component. + * @returns The JSON schema properties. + */ + const getProps = async (name: string) => { + const { title, properties } = await getSchema(name); + + return { title, properties, isProps: Object.entries(properties).length > 0 }; + }; + + /** + * Get a summary of the component. + * + * @param res - The resource object. + * @returns The summary response object for the component. + */ + const getOverview = async (res: FilterPatternFlyResultsResource) => { + const { properties } = res.isSchemasAvailable ? await getProps(name) : { properties: {} }; + const propNames = Object.keys(properties).join(', ') || 'None'; + + // Cross-links to docs + const categories = new Set(res.entries.map(entry => entry.displayCategory)); + const categoryLinks = Array.from(categories).sort() + .map(category => `[${category}](${res.uriId}${buildSearchString({ category }, { prefix: true, base: res.uriComponentId })})`); + + const docsContent = categoryLinks.length + ? stringJoin.newline( + '', + '### Documentation & guidelines', + ...categoryLinks.map(category => ` - ${category}`) + ) + : ''; + + const content = stringJoin.newline( + `# ${updatedName} (Technical overview)`, + `- **Version:** ${updatedVersion}`, + `- **Available Props:** ${propNames}`, + docsContent + ); + + return { + uri: res.uriComponentId, + mimeType: 'text/markdown', + text: formatSummaryFullContent(content, { + descLinkSummary: 'View summary technical specs', + descLinkFull: 'View full technical specs', + url: res.isSchemasAvailable ? res.uriComponentId : undefined, + detailType: normalizedDetail, + frontMatter: { + document: res.uriComponentId, + name: updatedName, + version: updatedVersion + }, + summaryLength: 500 + }) + }; + }; + + const markdownOverview = await getOverview(resource); + + if (normalizedDetail === 'summary') { + return { + contents: [markdownOverview] + }; + } + + const updatedSchemas = []; + + if (resource.isSchemasAvailable) { + const schema = await getSchema(name); + const props = await getProps(name); + const uri = `${resource.uriComponentId}${buildSearchString({ detail: 'full' }, { prefix: true, base: resource.uriComponentId })}`; + + if (props.isProps) { + updatedSchemas.push({ + uri, + mimeType: 'application/json', + text: JSON.stringify(schema?.properties, null, 2) + }); + } + + updatedSchemas.push({ + uri, + mimeType: 'application/json', + text: JSON.stringify(schema, null, 2) + }); + } + + return { + + contents: [ + markdownOverview, + ...updatedSchemas + ] + }; +}; + +/** + * Resource creator for components and metadata resources. + * + * @note The `metaConfig` determines if a metadata resource is generated. Remove + * the config to disable it. + * + * @param options - Global options + * @returns {McpResource} The resource definition tuple + */ +const patternFlyComponentsResource = (options = getOptions()): McpResource => { + const list: ListResourcesCallback = async (...args) => runWithOptions(options, async () => listResources.memo(...args)); + + const complete: { [callback: string]: CompleteResourceTemplateCallback } = { + detail: async (...args) => runWithOptions(options, async () => uriDetailComplete.memo(...args)), + category: async (...args) => runWithOptions(options, async () => uriCategoryComplete.memo(...args)), + version: async (...args) => runWithOptions(options, async () => uriVersionComplete.memo(...args)) + }; + + const callback: McpResource[3] = async (uri, variables) => + runWithOptions(options, async () => resourceCallback(uri, variables)); + + return [ + NAME, + new ResourceTemplate(URI_TEMPLATE, { + list, + complete + }), + CONFIG, + callback, + { + complete, + metaConfig: { + uri: 'patternfly://components/meta{?version}', + title: `${CONFIG.title} Metadata`, + description: 'Use these parameters to filter the list of PatternFly components.' + } + }, + { + shouldRegister: opts => opts.contextManagement === true + } + ]; +}; + +export { + patternFlyComponentsResource, + listResources, + uriDetailComplete, + resourceCallback, + uriCategoryComplete, + uriVersionComplete, + NAME, + URI_TEMPLATE, + URI_DESCRIPTION, + CONFIG +}; diff --git a/src/resource.patternFlyComponentsIndex.ts b/src/resource.patternFlyComponentsIndex.ts index eddcbdab..3697edd0 100644 --- a/src/resource.patternFlyComponentsIndex.ts +++ b/src/resource.patternFlyComponentsIndex.ts @@ -216,6 +216,9 @@ const patternFlyComponentsIndexResource = (options = getOptions()): McpResource title: `${CONFIG.title} Metadata`, description: 'Use these parameters to filter the list of PatternFly components.' } + }, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined } ]; }; diff --git a/src/resource.patternFlyContext.ts b/src/resource.patternFlyContext.ts index 79912c8c..424caaab 100644 --- a/src/resource.patternFlyContext.ts +++ b/src/resource.patternFlyContext.ts @@ -44,6 +44,18 @@ const resourceCallback = async (passedUri: URL, options = getOptions()) => { options.repoBugs && `- **Report bugs:** ${options.repoBugs}` ); + let availableMcpResources = `- **MCP resources:** Can be used to list, filter, and read available documentation resources.`; + + if (options.contextManagement) { + availableMcpResources = stringJoin.newline( + availableMcpResources, + ' - Use `patternfly://docs/{name}{?detail}` for usage design and example patterns, accessibility guidelines, and more. (Default: `detail=summary`)', + ' - Use `patternfly://components/{name}{?detail}` for component documentation, prop names, and technical specifications. (Default: `detail=summary`)', + ' - **Important**: Always start with `detail=summary` for discovery. Only use `detail=full` when you are ready to implement code.' + ); + } + + const availableToolFunctions = options.contextManagement ? 'search, list and access' : 'search, fetch and display'; const context = `PatternFly is an open-source design system for building consistent, accessible user interfaces. **What is PatternFly?** @@ -57,13 +69,14 @@ PatternFly provides React components, design guidelines, and development tools f **PatternFly MCP Server:** This MCP server provides tools and resources to access all PatternFly documentation resources ranging from design to development. -- **MCP tools:** Can be used to search, fetch and display available documentation resources. -- **MCP resources:** Can be used to list, filter and display available documentation resources. +- **MCP tools:** Can be used to ${availableToolFunctions} available documentation resources. +${availableMcpResources} **Environment:** - **MCP Server Mode:** ${options.mode} - **MCP Server Version:** ${options.version || 'Unknown'} - **Node.js Major Version:** ${options.nodeVersion || 'Unknown'} +- **Context Management:** ${options.contextManagement} ${(troubleshooting && stringJoin.newline('**Troubleshooting:**', troubleshooting)) || ''} `; @@ -92,7 +105,15 @@ const patternFlyContextResource = (options = getOptions()): McpResource => { return [ NAME, URI_TEMPLATE, - CONFIG, + options?.contextManagement + ? { + ...CONFIG, + annotations: { + priority: 0.5, + audience: ['assistant' as const] + } + } + : CONFIG, callback ]; }; diff --git a/src/resource.patternFlyDocs.ts b/src/resource.patternFlyDocs.ts new file mode 100644 index 00000000..6bd70e3d --- /dev/null +++ b/src/resource.patternFlyDocs.ts @@ -0,0 +1,431 @@ +import { + ResourceTemplate, + type ListResourcesCallback, + type CompleteResourceTemplateCallback +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { type McpResource } from './mcpSdk'; +import { memo } from './server.caching'; +import { assertInput, assertInputStringLength, assertInputStringShaHex } from './server.assertions'; +import { findClosest } from './server.search'; +import { processDocsFunction } from './server.getResources'; +import { getOptions, runWithOptions } from './options.context'; +import { getPatternFlyMcpResources } from './patternFly.getResources'; +import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; +import { filterPatternFly } from './patternFly.search'; +import { + formatSummaryFullContent, + nextCursor, + paramCompletion +} from './resource.helpers'; + +/** + * Extended callback type that combines the `CompleteResourceTemplateCallback` type + * and an additional `memo` property. + * + * @extends CompleteResourceTemplateCallback + */ +type ExtendedCompleteResourceTemplateCallback = { memo: CompleteResourceTemplateCallback } & CompleteResourceTemplateCallback; + +/** + * List resources result type. + * + * @note This is temporary until MCP SDK exports ListResourcesResult. + * + * @property uri - The fully qualified URI of the resource. + * @property name - A human-readable name for the resource. + * @property [mimeType] - The MIME type of the content. + * @property [description] - A brief hint for the model. + */ +type PatternFlyListResourceResult = { + uri: string; + name: string; + mimeType?: string; + description?: string; +}; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-docs'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://docs/{name}{?id,version,category,section,detail}'; + +/** + * URI description for the resource. + */ +const URI_DESCRIPTION = `Filter by PatternFly ID or version, category, section. ${URI_TEMPLATE}`; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Documentation Index', + description: `A list of PatternFly documentation links including accessibility, components, charts, development, writing, and AI guidance files. ${URI_DESCRIPTION}`, + mimeType: 'text/markdown', + annotations: { + priority: 1.0, + audience: ['assistant' as const] + } +}; + +/** + * Index list. List resources callback for the URI template. + * + * @note It's important to keep lists focused and concise, use paging to avoid + * listing all resources. + * + * @param _extra + * @param cursor - The passed back cursor/page for pagination. + * @returns {Promise} The list of available resources. + */ +const listResources = async (_extra: unknown, cursor?: string | undefined) => { + const pageSize = 50; + const { versionIndex } = await getPatternFlyMcpResources.memo(); + const { start, end, next } = nextCursor({ cursor, pageSize, size: versionIndex.length }); + const resources: PatternFlyListResourceResult[] = []; + + versionIndex.slice(start, end).forEach((entry, _index) => { + const actualIndex = start + 1; + + resources.push({ + uri: entry.uriId, + name: `${entry.displayName} - ${entry.displayCategory} (${entry.version}) (${actualIndex}/${versionIndex.length} resources)`, + description: entry.description, + mimeType: 'text/markdown' + }); + }); + + return { + totalCount: versionIndex.length, + pageSize, + nextCursor: next, + resources + }; +}; + +/** + * Memoized version of listResources. + */ +listResources.memo = memo(listResources); + +/** + * Detail completion callback for the URI template. + * + * @param detail - The value to complete. + * @returns The list of available details. + */ +const uriDetailComplete: ExtendedCompleteResourceTemplateCallback = async (detail: string) => { + const levels = ['summary', 'full']; + const closest = findClosest.memo(detail, levels) as string | undefined; + + return closest ? [closest] : []; +}; + +/** + * Memoized version of uriDetailComplete. + */ +uriDetailComplete.memo = memo(uriDetailComplete); + +/** + * Name completion callback for the URI template. + * + * @note If version is not available, the latest version is used to refine the search results + * since it aligns with the default behavior of the PatternFly documentation. + * + * @param name - The value to complete. + * @param context - The completion context. + * @returns The list of available names. + */ +const uriNameComplete: ExtendedCompleteResourceTemplateCallback = async (name: string, context) => { + const { version, category, section } = context?.arguments || {}; + const { names } = await paramCompletion({ category, name, section, version }); + + return names; +}; + +/** + * Memoized version of uriNameComplete. + */ +uriNameComplete.memo = memo(uriNameComplete); + +/** + * Category completion callback for the URI template. + * + * @param category - The value to filter-by/complete. + * @param context - The completion context containing arguments for the URI template. + * @returns The list of available categories, or an empty list. + */ +const uriCategoryComplete: ExtendedCompleteResourceTemplateCallback = async (category: string, context) => { + const { version, section, name } = context?.arguments || {}; + const { categories } = await paramCompletion({ category, name, section, version }); + + return categories; +}; + +/** + * Memoized version of uriCategoryComplete. + */ +uriCategoryComplete.memo = memo(uriCategoryComplete); + +/** + * Section completion callback for the URI template. + * + * @param section - The value to filter-by/complete. + * @param context - The completion context containing arguments for the URI template. + * @returns The list of available sections, or an empty list. + */ +const uriSectionComplete: ExtendedCompleteResourceTemplateCallback = async (section: string, context) => { + const { version, category, name } = context?.arguments || {}; + const { sections } = await paramCompletion({ category, name, section, version }); + + return sections; +}; + +/** + * Memoized version of uriSectionComplete. + */ +uriSectionComplete.memo = memo(uriSectionComplete); + +/** + * Name completion callback for the URI template. + * + * @param version - The value to complete. + * @param context - The completion context containing arguments for the URI template. + * @returns The list of available versions, or an empty list. + */ +const uriVersionComplete: ExtendedCompleteResourceTemplateCallback = async (version: string, context) => { + const { section, category, name } = context?.arguments || {}; + const { versions } = await paramCompletion({ category, name, section, version }); + + return versions; +}; + +/** + * Memoized version of uriVersionComplete. + */ +uriVersionComplete.memo = memo(uriVersionComplete); + +/** + * Return content. Resource callback for the documentation template. + * + * @param passedUri - URI of the resource. + * @param variables - Variables for the resource. + * @param options - Global options + * @returns The resource contents. + */ +const resourceCallback = async (passedUri: URL, variables: Record, options = getOptions()) => { + const { category, detail = 'summary', name, section, id, version } = variables || {}; + const normalizedDetail = (findClosest.memo(detail, ['full', 'summary']) || detail) as 'full' | 'summary'; + let updatedId; + + assertInputStringLength(name, { + ...options.minMax.inputStrings, + inputDisplayName: 'name' + }); + + if (id) { + assertInputStringShaHex(id, { + ...options.minMax.sha1Hex, + inputDisplayName: 'id' + }); + + // Be lenient, only apply the ID if it's different from name. + if (id !== name) { + updatedId = id; + } + } + + if (version) { + assertInputStringLength(version, { + ...options.minMax.inputStrings, + inputDisplayName: 'version' + }); + } + + if (section) { + assertInputStringLength(section, { + ...options.minMax.inputStrings, + inputDisplayName: 'section' + }); + } + + if (category) { + assertInputStringLength(category, { + ...options.minMax.inputStrings, + inputDisplayName: 'category' + }); + } + + const { + availableVersions, + latestVersion + } = await getPatternFlyMcpResources.memo(); + const normalizedVersion = await normalizeEnumeratedPatternFlyVersion.memo(version); + + assertInput( + !version || Boolean(normalizedVersion), + `Invalid PatternFly version "${version?.trim()}". Available versions are: ${availableVersions.join(', ')}` + ); + + const updatedVersion = normalizedVersion || latestVersion; + const updatedName = name.trim(); + + const { byEntry } = await filterPatternFly.memo({ + id: updatedId, + version: updatedVersion, + name: updatedName, + category, + section + }); + + assertInput( + byEntry.length > 0, + () => { + let suggestionMessage = ''; + + if (id || version || category || section) { + const variableList = [ + (version && 'id') || undefined, + (version && 'version') || undefined, + (category && 'category') || undefined, + (section && 'section') || undefined + ].filter(Boolean).join(', '); + + suggestionMessage = ` Try using different parameters for ${variableList}.`; + } + + return `No documentation found for "${updatedName}".${suggestionMessage}`; + } + ); + + const docs = []; + + try { + const docPaths = byEntry + .filter(({ path }) => path) + .map(({ path, uriId }) => ({ doc: path, uri: uriId })); + + if (docPaths.length > 0) { + // `processDocsFunction` has de-dup docs baked in + const processedDocs = await processDocsFunction.memo(docPaths); + + // Failures are `log.debugged` in `processDocsFunction`. + for (const response of processedDocs) { + if (response.isSuccess) { + docs.push({ + ...response + }); + } + } + } + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Failed to fetch documentation: ${error}` + ); + } + + assertInput( + docs.length > 0, + () => { + let suggestionMessage = ''; + + if (version || category || section) { + const variableList = [ + (version && 'version') || undefined, + (category && 'category') || undefined, + (section && 'section') || undefined + ].filter(Boolean).join(', '); + + suggestionMessage = ` Try using different parameters for ${variableList}.`; + } + + return `"${updatedName}" was found, but no documentation resources are available for it.${suggestionMessage}`; + } + ); + + return { + contents: docs.map(({ uri, path, resolvedPath, content }) => ({ + uri, + mimeType: 'text/markdown', + text: formatSummaryFullContent(content, { + url: uri, + detailType: normalizedDetail, + frontMatter: { + document: resolvedPath || path, + name: updatedName, + version: updatedVersion + } + }) + })) + }; +}; + +/** + * Resource creator for the documentation index and metadata resources. + * + * @note The `metaConfig` determines if a metadata resource is generated. Remove + * the config to disable it. + * + * @param options - Global options + * @returns {McpResource} The resource definition tuple + */ +const patternFlyDocsResource = (options = getOptions()): McpResource => { + const list: ListResourcesCallback = async (...args) => runWithOptions(options, async () => listResources.memo(...args)); + + const complete: { [callback: string]: CompleteResourceTemplateCallback } = { + detail: async (...args) => runWithOptions(options, async () => uriDetailComplete.memo(...args)), + category: async (...args) => runWithOptions(options, async () => uriCategoryComplete.memo(...args)), + section: async (...args) => runWithOptions(options, async () => uriSectionComplete.memo(...args)), + version: async (...args) => runWithOptions(options, async () => uriVersionComplete.memo(...args)) + }; + + const callback: McpResource[3] = async (uri, variables) => + runWithOptions(options, async () => resourceCallback(uri, variables, options)); + + return [ + NAME, + new ResourceTemplate(URI_TEMPLATE, { + list, + complete + }), + CONFIG, + callback, + { + complete, + registerAllSearchCombinations: true, + indexConfig: { + uri: 'patternfly://docs/index{?version}' + }, + metaConfig: { + uri: 'patternfly://docs/meta{?version}', + title: `${CONFIG.title} Metadata`, + description: 'Use these parameters to filter the PatternFly documentation index.' + } + }, + { + shouldRegister: opts => opts.contextManagement === true + } + ]; +}; + +export { + patternFlyDocsResource, + listResources, + resourceCallback, + uriCategoryComplete, + uriDetailComplete, + uriNameComplete, + uriSectionComplete, + uriVersionComplete, + NAME, + URI_TEMPLATE, + URI_DESCRIPTION, + CONFIG, + type ExtendedCompleteResourceTemplateCallback, + type PatternFlyListResourceResult +}; diff --git a/src/resource.patternFlyDocsIndex.ts b/src/resource.patternFlyDocsIndex.ts index ef4bf31c..f8ac3760 100644 --- a/src/resource.patternFlyDocsIndex.ts +++ b/src/resource.patternFlyDocsIndex.ts @@ -185,9 +185,8 @@ uriVersionComplete.memo = memo(uriVersionComplete); * Resource callback for the documentation index. * * @note The callback response is a high-level index potentially grouping multiple "entries" - * by a single URI. This is an optimization already, but we can review moving responses over - * to using resource IDs instead of the current grouping uri mechanism IF we opt to review - * pagination. + * by a single URI. See {@link ./resource.patternFlyDocs} for the final combined format + * under `contextManagement`. * * @param passedUri - URI of the resource. * @param variables - Variables for the resource. @@ -289,6 +288,11 @@ const resourceCallback = async (passedUri: URL, variables: Record { title: `${CONFIG.title} Metadata`, description: 'Use these parameters to filter the PatternFly documentation index.' } + }, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined } ]; }; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index 3fd230d0..8575fcdf 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -205,6 +205,9 @@ const patternFlyDocsTemplateResource = (options = getOptions()): McpResource => { complete, registerAllSearchCombinations: true + }, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined } ]; }; diff --git a/src/resource.patternFlySchemasIndex.ts b/src/resource.patternFlySchemasIndex.ts index 1ab28319..9c1302b9 100644 --- a/src/resource.patternFlySchemasIndex.ts +++ b/src/resource.patternFlySchemasIndex.ts @@ -149,7 +149,9 @@ const resourceCallback = async (passedUri: URL, variables: Record title: `${CONFIG.title} Metadata`, description: 'Use these parameters to filter the list of PatternFly component schemas.' } + }, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined } ]; }; diff --git a/src/resource.patternFlySchemasTemplate.ts b/src/resource.patternFlySchemasTemplate.ts index 57bbd751..18ab2ba0 100644 --- a/src/resource.patternFlySchemasTemplate.ts +++ b/src/resource.patternFlySchemasTemplate.ts @@ -168,6 +168,9 @@ const patternFlySchemasTemplateResource = (options = getOptions()): McpResource callback, { complete + }, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined } ]; }; diff --git a/src/server.assertions.ts b/src/server.assertions.ts index db4d47f8..5f73d0e4 100644 --- a/src/server.assertions.ts +++ b/src/server.assertions.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { isWhitelistedUrl, stringJoin } from './server.helpers'; +import { isShaHexLike, isWhitelistedUrl, stringJoin } from './server.helpers'; import { DEFAULT_OPTIONS, type WhitelistUrl } from './options.defaults'; /** @@ -137,6 +137,28 @@ function assertInputStringNumberEnumLike( mcpAssert(isValid, updatedDescription, errorCode); } +/** + * Assert/validate that a given input string is an SHA-1 hex string with a specified length. + * + * @param input - Input string to validate. + * @param options - Validation options + * @param options.max - Maximum length of each string in the array. `Required` + * @param options.min - Minimum length of each string in the array. `Required` + * @param [options.inputDisplayName] - Display name for the input. Used in the default error messages. Defaults to 'Input'. + * @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided. + * + * @throws McpError If input is not an SHA-1 hex string, and it does not meet length requirements. + */ +function assertInputStringShaHex( + input: unknown, + { max, min, inputDisplayName, message }: { max: number; min: number; inputDisplayName?: string; message?: string } +): asserts input is string { + const isValid = isShaHexLike(input, { maxLength: max, minLength: min }); + const updatedDescription = max === min ? `with a length of ${min} characters` : `with a length from ${min} to ${max} characters`; + + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a SHA-1 hex ${updatedDescription}`); +} + /** * Assert/validate that a given input URL string, or array of URL strings, is whitelisted against a provided list of URLs. * @@ -184,5 +206,6 @@ export { assertInputStringLength, assertInputStringArrayEntryLength, assertInputStringNumberEnumLike, + assertInputStringShaHex, assertInputUrlWhiteListed }; diff --git a/src/server.helpers.ts b/src/server.helpers.ts index fd3cd775..bcc726ba 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -413,6 +413,40 @@ const generateHash = (anyValue: unknown, { isLowercase = false }: { isLowercase? return hashCode(isLowercase ? stringify.toLowerCase() : stringify); }; +/** + * Check if a value is an SHA-1 hex string. + * + * @param value - Value to check. + * @param [options] - Options. + * @param [options.minLength] - Minimum length of the SHA-1 hex string. + * @param [options.maxLength] - Maximum length of the SHA-1 hex string. + * @returns `true` if the value is an SHA-1 hex-like string + */ +const isShaHexLike = ( + value: unknown, + { + minLength = 8, + maxLength = 40 + }: { minLength?: number; maxLength?: number } = {} +): boolean => { + const updatedValue = typeof value === 'string' ? value.trim() : ''; + const shaHexLeading = /^[a-f0-9]{1}/i; + + if (!updatedValue || updatedValue.length < minLength || updatedValue.length > maxLength || !shaHexLeading.test(updatedValue)) { + return false; + } + + const shaHexFull = /^[a-f0-9]{40}$/i; + + if (shaHexFull.test(updatedValue)) { + return true; + } + + const shaHexPartial = /^[a-f0-9]{4,39}$/i; + + return shaHexPartial.test(updatedValue); +}; + /** * Check if a string URL matches a whitelist entry * @@ -764,6 +798,7 @@ export { isPlainObject, isPromise, isReferenceLike, + isShaHexLike, isUrl, isUrlObject, isWhitelistedUrl, diff --git a/src/server.resourceMeta.ts b/src/server.resourceMeta.ts index 81376f23..c93a4db3 100644 --- a/src/server.resourceMeta.ts +++ b/src/server.resourceMeta.ts @@ -368,6 +368,7 @@ const setMetaResources = (resources: McpResourceCreator[], options = getOptions( // Create a new meta-resource const metaResource = (opts = options): McpResource => { + const _config = resourceCreator(opts)[5]; const metaCallback: McpResource[3] = async (passedUri, variables) => runWithOptions(opts, async () => { const updatedText = await resolveMetaText(variables); @@ -391,12 +392,15 @@ const setMetaResources = (resources: McpResourceCreator[], options = getOptions( description: metaDescription, mimeType: metaMimeType }, - metaCallback + metaCallback, + undefined, + _config ]; }; // Add the meta-resource enhancement to the existing resource const enhancedResource = (opts = options): McpResource => { + const _config = resourceCreator(opts)[5]; const metaEnhancedCallback: McpResource[3] = async (passedUri, variables) => runWithOptions(opts, async () => { const result = await callback(passedUri, variables); @@ -422,7 +426,7 @@ const setMetaResources = (resources: McpResourceCreator[], options = getOptions( }; }); - return [name, uriOrTemplate, config, metaEnhancedCallback, metadata]; + return [name, uriOrTemplate, config, metaEnhancedCallback, metadata, _config]; }; // Add the resources back in diff --git a/src/server.ts b/src/server.ts index a637c509..a3adc48d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -117,7 +117,18 @@ interface ServerInstance { */ const registerServerResources = async (resources: McpResourceCreator[], server: McpServer, options = getOptions(), session = getSessionOptions()) => { for (const resourceCreator of resources) { - const [name, uri, config, callback, metadata] = resourceCreator(options); + const [name, uri, config, callback, metadata, _config] = resourceCreator(options); + + const shouldRegister = _config?.shouldRegister; + + if (shouldRegister) { + const status = await shouldRegister(options); + + if (!status) { + log.debug(`Skipping resource registration: ${name}`); + continue; + } + } try { registerResource(server, name, uri, config, (...args: unknown[]) => @@ -156,7 +167,17 @@ const registerServerResources = async (resources: McpResourceCreator[], server: */ const registerServerTools = async (tools: McpToolCreator[], server: McpServer, options = getOptions(), session = getSessionOptions()) => { for (const toolCreator of tools) { - const [name, schema, callback] = toolCreator(options); + const [name, schema, callback, _config] = toolCreator(options); + const shouldRegister = _config?.shouldRegister; + + if (shouldRegister) { + const status = await shouldRegister(options); + + if (!status) { + log.debug(`Skipping tool registration: ${name}`); + continue; + } + } // Do NOT normalize schemas here. This is by design and is a fallback check for malformed schemas. const isZod = isZodSchema(schema?.inputSchema) || isZodRawShape(schema?.inputSchema); diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index 687c6609..243f3f3f 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -242,7 +242,10 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { .optional().describe(`Filter results by a specific PatternFly version (e.g. ${options.patternflyOptions.availableSearchVersions.map(value => `"${value}"`).join(', ')})`) } }, - callback + callback, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined + } ]; }; diff --git a/src/tool.searchPatternFly.ts b/src/tool.searchPatternFly.ts new file mode 100644 index 00000000..bc9c4b53 --- /dev/null +++ b/src/tool.searchPatternFly.ts @@ -0,0 +1,184 @@ +import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { type McpTool } from './mcpSdk'; +import { stringJoin } from './server.helpers'; +import { assertInput, assertInputStringLength, assertInputStringNumberEnumLike } from './server.assertions'; +import { getOptions } from './options.context'; +import { searchPatternFly } from './patternFly.search'; +import { getPatternFlyMcpResources } from './patternFly.getResources'; +import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; +import { findClosest } from './server.search'; + +/** + * searchPatternFly tool function + * + * Searches for PatternFly resources using fuzzy search. + * Returns MCP Resource Links when contextManagement: 'token-saver' is active. + * + * @note Review not filtering out resources without a path. These resources could be + * inlined or handled with the upcoming on-demand session resource loader. + * + * @param options - Optional configuration options (defaults to OPTIONS) + * @returns MCP tool tuple [name, schema, callback] + */ +const searchPatternFlyTool = (options = getOptions()): McpTool => { + const callback = async (args: any) => { + const { query: searchQuery, version } = args; + const isVersion = typeof version === 'string' && version.length > 0; + + assertInputStringLength(searchQuery, { + ...options.minMax.inputStrings, + inputDisplayName: 'searchQuery' + }); + + if (isVersion) { + assertInputStringLength(version, { + max: options.minMax.inputStrings.max, + min: 2, + inputDisplayName: 'version' + }); + + assertInputStringNumberEnumLike(version, options.patternflyOptions.availableSearchVersions, { + inputDisplayName: 'version' + }); + } + + const { latestVersion, keywordsIndex } = await getPatternFlyMcpResources.memo(); + const normalizedVersion = await normalizeEnumeratedPatternFlyVersion(version); + const updatedVersion = normalizedVersion || latestVersion; + + const { isSearchWildCardAll, exactMatches, remainingMatches, searchResults, totalPotentialMatches } = await searchPatternFly.memo( + searchQuery, + { version: updatedVersion }, + { allowWildCardAll: true, dynamicFilter: true, maxResults: options.minMax.resourceSearches.max } + ); + + assertInput( + !isSearchWildCardAll || (isSearchWildCardAll && searchResults.length > 0), + stringJoin.newline( + `Internal Search Error: The server failed to retrieve PatternFly resources for query "${searchQuery}"`, + 'Ensure documentation resources are loaded or restart the server.' + ), + ErrorCode.InternalError + ); + + if (!isSearchWildCardAll && searchResults.length === 0) { + const suggestion = findClosest.memo(searchQuery, keywordsIndex.reverse(), { maxDistance: 5 }); + const hint = suggestion ? `Try a search for "${suggestion}".` : `Try a broader search.`; + + return { + content: [{ + type: 'text', + text: stringJoin.newlineFiltered( + `No PatternFly resources found matching "${searchQuery}". ${hint}` + ) + }] + }; + } + + // Default to parsing all remainingMatches + let parseResults = remainingMatches; + + // Focus the result set. If there are exact matches, use those. + if (isSearchWildCardAll || exactMatches.length > 0) { + parseResults = exactMatches; + + // Focus the result set. If there aren't any exactMatches, but we have "distance 1" matches, use those. + } else if (searchResults.some(result => result.distance === 1)) { + parseResults = searchResults.filter(result => result.distance === 1); + } + + const results = new Map>(); + + parseResults + .map(result => result.entries) + .flat() + .filter(entry => entry.path) + .forEach(entry => { + if (entry.uriId && !entry.uriComponentId && !results.has(entry.uriId)) { + results.set(entry.uriId, { + type: 'resource_link', + uri: entry.uriId, + name: `${entry.displayName} - ${entry.displayCategory} (${entry.version})`, + description: entry.description, + mimeType: 'text/markdown' + }); + } + + if (entry.uriComponentId && !results.has(entry.uriComponentId)) { + let updatedName = `${entry.displayName} - Technical Overview`; + let updatedDesc = `Component API reference for ${entry.displayName}.`; + + if (entry.isSchemasAvailable) { + updatedName = `${entry.displayName} - Technical Specs`; + updatedDesc = `Component API reference, property definitions, and JSON schema for ${entry.displayName}.`; + } + + results.set(entry.uriComponentId, { + type: 'resource_link', + uri: entry.uriComponentId, + name: `${updatedName} (${entry.version})`, + description: updatedDesc, + mimeType: 'text/markdown' + }); + } + }); + + const resultValues = Array.from(results.values()); + + const summaryTitlePatternFly = updatedVersion + ? `Search results for PatternFly version "${updatedVersion}" and` + : `Search results for`; + + let summaryTitle = stringJoin.newline( + `# ${summaryTitlePatternFly} "${searchQuery}".`, + `Found ${resultValues.length} related ${resultValues.length === 1 ? 'resource' : 'resources'}. Use the attached resources to access and read full content.` + ); + + if (isSearchWildCardAll) { + summaryTitle = stringJoin.newline( + `# ${summaryTitlePatternFly} "all" resources.`, + `Only showing ${resultValues.length} ${resultValues.length === 1 ? 'resource' : 'resources'} out of ${totalPotentialMatches} potential matches. Use a more specific query.` + ); + } else if (exactMatches.length > 0) { + summaryTitle = stringJoin.newline( + `# ${summaryTitlePatternFly} "${searchQuery}".`, + `Found ${resultValues.length} ${resultValues.length === 1 ? 'resource' : 'resources'}. Use the attached resources to access and read full content.` + ); + } + + return { + content: [ + { + type: 'text', + text: summaryTitle + }, + ...resultValues + ] + }; + }; + + return [ + 'searchPatternFly', + { + description: `Search PatternFly components, documentation, guidelines, and resource links by keywords or '*' for all.`, + inputSchema: { + query: z.string() + .min(options.minMax.inputStrings.min) + .max(options.minMax.inputStrings.max) + .describe('Case-insensitive, full or partial keyword query (e.g., "button", "react", "*")'), + version: z.enum(options.patternflyOptions.availableSearchVersions) + .optional() + .describe(`Filter results by a specific PatternFly version (e.g. ${options.patternflyOptions.availableSearchVersions.map(value => `"${value}"`).join(', ')})`) + } + }, + callback, + { + shouldRegister: opts => opts.contextManagement === true + } + ]; +}; + +searchPatternFlyTool.toolName = 'searchPatternFly'; + +export { searchPatternFlyTool }; diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts index a913ccdc..74f240f1 100644 --- a/src/tool.searchPatternFlyDocs.ts +++ b/src/tool.searchPatternFlyDocs.ts @@ -170,7 +170,10 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { .describe(`Filter results by a specific PatternFly version (e.g. ${options.patternflyOptions.availableSearchVersions.map(value => `"${value}"`).join(', ')})`) } }, - callback + callback, + { + shouldRegister: opts => opts.contextManagement === false || opts.contextManagement === undefined + } ]; }; diff --git a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap index 098cc273..ab3f573a 100644 --- a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap @@ -181,6 +181,10 @@ exports[`Logging should allow setting logging options, stderr 1`] = ` ] `; +exports[`Logging should allow setting logging options, with experimental flag default 1`] = `[]`; + +exports[`Logging should allow setting logging options, with experimental flag set 1`] = `[]`; + exports[`Logging should allow setting logging options, with log level filtering 1`] = `[]`; exports[`Logging should allow setting logging options, with mcp protocol 1`] = ` diff --git a/tests/e2e/httpTransport.test.ts b/tests/e2e/httpTransport.test.ts index facb63ac..ff290c73 100644 --- a/tests/e2e/httpTransport.test.ts +++ b/tests/e2e/httpTransport.test.ts @@ -413,7 +413,7 @@ describe('Builtin resources, HTTP transport', () => { }); const content = response?.result.contents[0]; - expect(content.uri).toBe('patternfly://docs/19b2a9418c744e70da9e3dd0965d1948ec1ebbe4'); + expect(content.uri).toBe('patternfly://docs/button?id=19b2a9418c744e70da9e3dd0965d1948ec1ebbe4'); expect(content.text).toContain('This is a test document for mocking remote HTTP requests'); }); @@ -523,3 +523,52 @@ describe('Inline tools, HTTP transport', () => { await CLIENT.close(); }); }); + +describe('token-saver mode, HTTP transport', () => { + let CLIENT: HttpTransportClient | undefined; + + beforeAll(async () => { + CLIENT = await startServer({ + isHttp: true, + experimentalContextManagement: true + }); + }); + + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + } + }); + + it('should only expose searchPatternFly tool', async () => { + const response = await CLIENT?.send({ + method: 'tools/list', + params: {} + }); + const tools = response?.result?.tools || []; + const toolNames = tools.map((tool: any) => tool.name); + + expect(toolNames).toEqual(['searchPatternFly']); + }); + + it('should return McpResource links from searchPatternFly', async () => { + const response = await CLIENT?.send({ + method: 'tools/call', + params: { + name: 'searchPatternFly', + arguments: { + query: 'Button' + } + } + }); + + const [summary, ...resources] = response?.result?.content || []; + + expect(summary.type).toBe('text'); + + resources.forEach((item: any) => { + expect(item.type).toBe('resource_link'); + expect(item.uri).toMatch(/^patternfly:\/\/(docs|schemas|components)\//); + }); + }); +}); diff --git a/tests/e2e/stdioTransport.test.ts b/tests/e2e/stdioTransport.test.ts index 7e4d0ddd..9c215901 100644 --- a/tests/e2e/stdioTransport.test.ts +++ b/tests/e2e/stdioTransport.test.ts @@ -234,6 +234,14 @@ describe('Builtin tools, STDIO', () => { '**button**' ] }, + { + description: 'uri search id query', + searchQuery: 'patternfly://docs/button?id=19b2a9418c744e70da9e3dd0965d1948ec1ebbe4', + contains: [ + 'Showing 1 exact match', + '**button**' + ] + }, { description: 'partial uri search query', searchQuery: 'patternfly://docs/19b2a94', @@ -442,7 +450,7 @@ describe('Builtin resources, STDIO', () => { }); const content = response?.result.contents[0]; - expect(content.uri).toBe('patternfly://docs/19b2a9418c744e70da9e3dd0965d1948ec1ebbe4'); + expect(content.uri).toBe('patternfly://docs/button?id=19b2a9418c744e70da9e3dd0965d1948ec1ebbe4'); expect(content.text).toContain('This is a test document for mocking remote HTTP requests'); }); @@ -476,6 +484,14 @@ describe('Logging', () => { { description: 'with mcp protocol', args: ['--log-protocol'] + }, + { + description: 'with experimental flag default', + args: ['--experimental-context-management', 'default'] + }, + { + description: 'with experimental flag set', + args: ['--experimental-context-management', 'token-saver'] } ])('should allow setting logging options, $description', async ({ args }) => { const serverArgs = [...args]; @@ -556,3 +572,54 @@ describe('Tools', () => { expect(resp.result.isError).toBeUndefined(); }); }); + +describe('token-saver mode', () => { + let CLIENT: StdioTransportClient; + + beforeAll(async () => { + CLIENT = await startServer({ + args: ['--experimental-context-management', 'token-saver'] + }); + }); + + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + } + }); + + it('should only expose searchPatternFly tool', async () => { + const response = await CLIENT.send({ + method: 'tools/list', + params: {} + }); + const tools = response?.result?.tools || []; + const toolNames = tools.map((tool: any) => tool.name); + + expect(toolNames).toEqual(['searchPatternFly']); + }); + + it('should return McpResource links from searchPatternFly', async () => { + const response = await CLIENT.send({ + method: 'tools/call', + params: { + name: 'searchPatternFly', + arguments: { + query: 'Button' + } + } + }); + + const [summary, ...resources] = response?.result?.content || []; + + console.warn(summary); + console.warn(resources); + + expect(summary.type).toBe('text'); + + resources.forEach((item: any) => { + expect(item.type).toBe('resource_link'); + expect(item.uri).toMatch(/^patternfly:\/\/(docs|schemas|components)\//); + }); + }); +}); diff --git a/tests/e2e/utils/httpTransportClient.ts b/tests/e2e/utils/httpTransportClient.ts index 05ef6179..d4bcb0a1 100644 --- a/tests/e2e/utils/httpTransportClient.ts +++ b/tests/e2e/utils/httpTransportClient.ts @@ -25,6 +25,7 @@ export type StartHttpServerOptions = { logging?: Partial & { level?: LoggingLevel }; toolModules?: PfMcpOptions['toolModules']; modeOptions?: PfMcpOptions['modeOptions']; + experimentalContextManagement?: PfMcpOptions['experimentalContextManagement']; }; export type StartHttpServerSettings = PfMcpSettings;