diff --git a/docs/experimental.md b/docs/experimental.md index 654052b3..6869c79e 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -36,11 +36,15 @@ Context management (also called **token-saver mode**) switches the server from t | Mode | Registered tools | Typical workflow | |-------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------| -| **Default** _(off)_ | `searchPatternFlyDocs`, `usePatternFlyDocs` | Search returns text with URLs and names; fetch content with `usePatternFlyDocs`. | +| **Default** _(off)_ | `searchPatternFlyDocs`, `usePatternFlyDocs` | Search returns text with URLs, names, and URIs; fetch content with `usePatternFlyDocs`. | | **Context management** | `searchPatternFly` only | Search returns `resource_link` items; read content with `resources/read`. | Built-in MCP resources (`patternfly://docs/...`, `patternfly://schemas/...`, indexes, and `patternfly://context`) remain available in both modes. +> **Important!** +> +> In **default** mode, `searchPatternFlyDocs` returns and `usePatternFlyDocs` accepts `patternfly://` URIs. This is a compatibility bridge for limited MCP clients. This is a transitional allowance: context management is the architecture the PatternFly MCP tools are moving towards, where URIs are returned as links and read through MCP resources. However, this does not mean the older technique of returning Markdown will go away; there is still a possible future where the PatternFly MCP retains the older Markdown response techniques to purposefully support limited and agentless MCP clients. + ### Tool: searchPatternFly Registered only when context management is enabled. diff --git a/docs/usage.md b/docs/usage.md index 47535d1a..0f3c3178 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -18,7 +18,9 @@ Core server tools provide a resource library for PatternFly. They are extensible ### Tool: searchPatternFlyDocs -Use this to search for PatternFly documentation URLs and component names. Accepts partial string matches or `*` to list all available components. From the content, you can select specific URLs and component names to use with `usePatternFlyDocs`. +Use this to search for PatternFly documentation URLs, `patternfly://` resource URIs, and component names. Accepts partial string matches or `*` to list all available components. From the content, you can select specific URLs, URIs, and component names to use with `usePatternFlyDocs`. + +> **Transitional URI support**: The default tools also return and accept `patternfly://` URIs for compatibility. Using and passing URIs through these tools is supported as a compatibility bridge for the intended workflow; see [experimental context management](./experimental.md#contextmanagement) for details on the transitional allowance for limited MCP clients. **Parameters:** - `searchQuery`: `string` (required) - Full or partial component name to search for (e.g., "button", "table", "*" for all components) @@ -32,13 +34,15 @@ Use this to search for PatternFly documentation URLs and component names. Accept ### Tool: usePatternFlyDocs -Fetch full documentation and component JSON schemas for specific PatternFly URLs or component names. +Fetch full documentation and component JSON schemas for specific PatternFly URLs, `patternfly://` URIs, or component names. > **Feature**: This tool automatically detects if a URL belongs to a component (or if a "name" is provided) and appends its machine-readable JSON schema (props, types, validation) to the response, combining human-readable documentation with technical specifications. **Parameters:** _Parameters are mutually exclusive. Provide either `name` OR `urlList`, not both._ -- `name`: `string` (optional) - The name of the PatternFly component (e.g., "Button", "Modal"). **Recommended** for known component lookups. -- `urlList`: `string[]` (optional) - A list of specific documentation URLs discovered via `searchPatternFlyDocs` (max 15 at a time). +- `name`: `string` (optional) - A PatternFly component or resource name (e.g., `"Button"`, `"Modal"`), or a `patternfly://` URI (e.g., `"patternfly://docs/button"`). Names are **recommended** for known component lookups. +- `urlList`: `string[]` (optional) - A list of documentation URLs and/or `patternfly://` URIs from `searchPatternFlyDocs` (max 15 at a time). + +> **Transitional URI support**: Prefer reading `patternfly://` URIs with MCP `resources/read` when your client supports it. Passing URIs through this tool is supported as a compatibility bridge for the intended workflow; see [experimental context management](./experimental.md#contextmanagement) for details on the transitional allowance for limited MCP clients. **Example with name:** ```json diff --git a/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap index 549f0931..ba2d0534 100644 --- a/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap @@ -33,7 +33,7 @@ ipsum documentation content --- -# Component Schema for button (v6) +# Component Schema for Button (v6) This machine-readable JSON schema defines the component's props, types, and validation rules. \`\`\`json { diff --git a/src/__tests__/tool.patternFlyDocs.test.ts b/src/__tests__/tool.patternFlyDocs.test.ts index 9aa86ed9..800f9202 100644 --- a/src/__tests__/tool.patternFlyDocs.test.ts +++ b/src/__tests__/tool.patternFlyDocs.test.ts @@ -4,6 +4,7 @@ import { getPatternFlyComponentSchema, getPatternFlyMcpResources, setCategoryDis import { searchPatternFly } from '../patternFly.search'; import { isPlainObject } from '../server.helpers'; import { usePatternFlyDocsTool } from '../tool.patternFlyDocs'; +import { DEFAULT_OPTIONS } from '../options.defaults'; // Mock dependencies jest.mock('../server.getResources'); @@ -17,6 +18,7 @@ const mockProcessDocs = processDocsFunction as jest.MockedFunction; const mockGetResources = getPatternFlyMcpResources as jest.MockedFunction; const mockSearch = searchPatternFly as jest.MockedFunction; +const mockSchema = getPatternFlyComponentSchema as jest.MockedFunction; const mockSetCategoryLabel = setCategoryDisplayLabel as jest.MockedFunction; describe('usePatternFlyDocsTool', () => { @@ -176,4 +178,50 @@ describe('usePatternFlyDocsTool, callback', () => { expect(result.content).toMatchSnapshot('Button'); }); + + it('usePatternFlyDocs resolves patternfly:// URIs in name', async () => { + mockSearch.mockResolvedValue({ + exactMatches: [{ + name: 'ToolbarFilter', + isSchemasAvailable: true, + entries: [{ name: 'ToolbarFilter', version: 'v6', path: '' }] + }], + searchResults: [] + } as any); + + mockSchema.mockResolvedValue({ + name: 'ToolbarFilter', + schema: { type: 'object' } + } as any); + + mockProcessDocs.mockResolvedValue([]); + + const [_name, _schema, callback] = usePatternFlyDocsTool(DEFAULT_OPTIONS); + const result = await callback({ name: 'patternfly://docs/ToolbarFilter' }) as any; + + expect(result.content[0].text).toContain('Component Schema for ToolbarFilter'); + }); + + it('usePatternFlyDocs resolves patternfly:// URIs in urlList', async () => { + mockSearch.mockResolvedValue({ + exactMatches: [{ + name: 'ToolbarFilter', + isSchemasAvailable: true, + entries: [{ name: 'ToolbarFilter', version: 'v6', path: '' }] + }], + searchResults: [] + } as any); + + mockSchema.mockResolvedValue({ + name: 'ToolbarFilter', + schema: { type: 'object' } + } as any); + + mockProcessDocs.mockResolvedValue([]); + + const [_name, _schema, callback] = usePatternFlyDocsTool(DEFAULT_OPTIONS); + const result = await callback({ urlList: ['patternfly://docs/ToolbarFilter'] }) as any; + + expect(result.content[0].text).toContain('Component Schema for ToolbarFilter'); + }); }); diff --git a/src/__tests__/tool.searchPatternFlyDocs.test.ts b/src/__tests__/tool.searchPatternFlyDocs.test.ts index caa3cb58..84d00541 100644 --- a/src/__tests__/tool.searchPatternFlyDocs.test.ts +++ b/src/__tests__/tool.searchPatternFlyDocs.test.ts @@ -143,5 +143,7 @@ describe('searchPatternFlyDocsTool, callback', () => { const result = await callback({ searchQuery: 'button' }); expect(result.content).toMatchSnapshot('Button'); + expect(result.content[0].text).toContain('patternfly://docs/button'); + expect(result.content[0].text).toContain('patternfly://schemas/button'); }); }); diff --git a/src/docs.filterWords.ts b/src/docs.filterWords.ts index da897df4..1350d35d 100644 --- a/src/docs.filterWords.ts +++ b/src/docs.filterWords.ts @@ -12,7 +12,7 @@ const INDEX_BLOCKLIST_WORDS = ['patternfly', 'component', 'components', 'documen * @note If "AI" starts producing noisy or overly broad matches in search, remove it from this * list and consider adding it to the noise words or blocklist. */ -const INDEX_EXCEPTION_WORDS = ['cli', 'css', 'ai', 'rtl', 'ltr']; +const INDEX_EXCEPTION_WORDS = ['cli', 'css', 'ai', 'rtl', 'ltr', 'theming']; /** * Noise words that are common and do not add significant value to search results. diff --git a/src/patternFly.support.ts b/src/patternFly.support.ts new file mode 100644 index 00000000..26000e11 --- /dev/null +++ b/src/patternFly.support.ts @@ -0,0 +1,13 @@ +import { isUrl } from './server.helpers'; + +/** + * Check if a value is a valid PatternFly URI. + * + * @param uri - URI to check + * @returns `true` if the string is a valid PatternFly URI. + */ +const isPatternFlyUri = (uri: unknown): uri is string => isUrl(uri, { allowedProtocols: ['patternfly'] }); + +export { + isPatternFlyUri +}; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index 41f689db..2dcfff14 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -140,8 +140,10 @@ const resourceCallback = async (passedUri: URL, variables: Record entry.uriSchemasId); + assertInput( - docs.length > 0, + docs.length > 0 || hasSchemas, () => { let suggestionMessage = ''; @@ -159,6 +161,16 @@ const resourceCallback = async (passedUri: URL, variables: Record entry.uriSchemasId).map(entry => ({ + uri: entry.uriId, + mimeType: 'text/markdown', + text: `# ${entry.displayName}\n\nNo documentation is available for this component. But a [JSON schema is available](${entry.uriSchemasId}).` + })) + }; + } + return { contents: docs.map(({ uri, path, resolvedPath, content }) => ({ uri, diff --git a/src/server.assertions.ts b/src/server.assertions.ts index db4d47f8..2edcf62d 100644 --- a/src/server.assertions.ts +++ b/src/server.assertions.ts @@ -1,6 +1,11 @@ import assert from 'node:assert'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { isWhitelistedUrl, stringJoin } from './server.helpers'; +import { + isUrl, + isWhitelistedUrl, + stringJoin +} from './server.helpers'; +import { isPatternFlyUri } from './patternFly.support'; import { DEFAULT_OPTIONS, type WhitelistUrl } from './options.defaults'; /** @@ -160,8 +165,13 @@ function assertInputUrlWhiteListed( updatedInput.forEach(url => { const isRemote = typeof url === 'string' && allowedProtocols.some(protocol => url.startsWith(protocol)); + const isPfUri = isPatternFlyUri(url); - if (isRemote && !isWhitelistedUrl(url, whitelist, { allowedProtocols })) { + if (isRemote) { + if (!isWhitelistedUrl(url, whitelist, { allowedProtocols })) { + invalidUrls.push(url); + } + } else if (isUrl(url, { isStrict: false }) && !isPfUri) { invalidUrls.push(url); } }); diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index 243f3f3f..1e3d8723 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -11,9 +11,10 @@ import { assertInputUrlWhiteListed } from './server.assertions'; import { getOptions } from './options.context'; -import { searchPatternFly } from './patternFly.search'; +import { searchPatternFly, type SearchPatternFlyResult } from './patternFly.search'; import { getPatternFlyMcpResources, getPatternFlyComponentSchema, setCategoryDisplayLabel } from './patternFly.getResources'; import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; +import { isPatternFlyUri } from './patternFly.support'; /** * usePatternFlyDocs tool function @@ -38,14 +39,6 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { ...options.minMax.inputStrings, inputDisplayName: 'name' }); - - assertInput( - !new RegExp('patternfly://', 'i').test(name), - stringJoin.basic( - 'Direct "patternfly://" URIs are not currently supported as tool inputs, and are intended to be used with MCP resources directly.', - 'Use a component or resource "name" or provide a "urlList" of raw documentation URLs.' - ) - ); } if (isUrlList) { @@ -59,14 +52,6 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { `"urlList" must be an array with a maximum length of ${options.minMax.docsToLoad.max} items.` ); - assertInput( - !urlList.some(url => new RegExp('patternfly://', 'i').test(url)), - stringJoin.basic( - 'Direct "patternfly://" URIs are not currently supported as tool inputs, and are intended to be used with MCP resources directly.', - 'Use a component or resource "name" or provide a "urlList" of raw documentation URLs.' - ) - ); - if (options.mode !== 'test') { assertInputUrlWhiteListed( urlList, @@ -94,32 +79,82 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { const updatedVersion = normalizedVersion || latestVersion; const updatedName = name?.trim(); - if (updatedName) { - const { searchResults, exactMatches } = await searchPatternFly.memo(updatedName, { version: updatedVersion }); + const pfUris: string[] = []; + const rawUrls: string[] = []; - assertInput( - exactMatches.length > 0 && exactMatches.every(match => match.entries.some(entry => Boolean(entry.path))), - () => { - const suggestions = searchResults.map(result => result.item).slice(0, 3); - const suggestionMessage = suggestions.length - ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` - : 'No similar resources found.'; - - return `Resource "${updatedName}" not found. ${suggestionMessage}`; - }, - ErrorCode.InvalidParams - ); + updatedUrlList.forEach(url => { + if (isPatternFlyUri(url)) { + pfUris.push(url); + } else { + rawUrls.push(url); + } + }); + + const allMatches: SearchPatternFlyResult[] = []; + const searchInputs = [updatedName, ...pfUris].filter(Boolean) as string[]; + + for (const input of searchInputs) { + const { searchResults, exactMatches } = await searchPatternFly.memo(input, { version: updatedVersion }); + + if (input === updatedName) { + assertInput( + exactMatches.length > 0 && exactMatches.every(match => match.isSchemasAvailable || match.entries.some(entry => Boolean(entry.path))), + () => { + const suggestions = searchResults.map(result => result.item).slice(0, 3); + const suggestionMessage = suggestions.length + ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` + : 'No similar resources found.'; + + return `Resource "${updatedName}" not found. ${suggestionMessage}`; + }, + ErrorCode.InvalidParams + ); + } - updatedUrlList.push(...exactMatches.flatMap(match => match.entries.map(entry => entry.path)).filter(Boolean)); + allMatches.push(...exactMatches); } + const finalUrlList = new Set([ + ...rawUrls, + ...allMatches.flatMap(match => match.entries.map(entry => entry.path)).filter(Boolean) + ]); + const docs: ProcessedDoc[] = []; const schemasSeen = new Set(); - const schemaResults = []; - const docResults = []; + const schemaResults: string[] = []; + const docResults: string[] = []; + + const addSchemaResult = async ( + { name: componentName, displayName, version }: { name: string; displayName: string; version: string } + ) => { + if (schemasSeen.has(componentName)) { + return; + } + + schemasSeen.add(componentName); + const schema = await getPatternFlyComponentSchema.memo(componentName); + + if (schema) { + schemaResults.push(stringJoin.newline( + `# Component Schema for ${displayName} (${version})`, + `This machine-readable JSON schema defines the component's props, types, and validation rules.`, + '```json', + JSON.stringify(schema, null, 2), + '```' + )); + } + }; + + for (const match of allMatches) { + if (match.isSchemasAvailable) { + const displayName = match.entries[0]?.displayName || match.name; + + await addSchemaResult({ name: match.name, displayName: displayName, version: updatedVersion }); + } + } try { - const processedDocs = await processDocsFunction.memo(updatedUrlList); + const processedDocs = await processDocsFunction.memo([...finalUrlList]); const primaryDocs: ProcessedDoc[] = []; const secondaryDocs: ProcessedDoc[] = []; const tertiaryDocs: ProcessedDoc[] = []; @@ -152,10 +187,32 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { ); } - if (docs.length === 0) { + for (const doc of docs) { + const patternFlyEntry = doc.path ? byPath[doc.path] : undefined; + const entryName = patternFlyEntry?.name; + const entryVersion = patternFlyEntry?.version; + const entryVersionDisplay = (entryVersion && ` (${entryVersion})`) || ''; + + const docTitle = patternFlyEntry + ? `# Documentation for ${patternFlyEntry?.displayName || entryName}${entryVersionDisplay} [${setCategoryDisplayLabel(patternFlyEntry)}]` + : `# Content for ${doc.path}`; + + docResults.push(stringJoin.newline( + docTitle, + `Source: ${doc.path}`, + '', + doc.content + )); + + if (latestSchemasVersion === entryVersion && entryName) { + await addSchemaResult({ ...patternFlyEntry }); + } + } + + if (docResults.length === 0 && schemaResults.length === 0) { const nameFilter = `**Name**: ${updatedName || '*'}`; const versionFilter = `**PatternFly Version**: ${updatedVersion || '*'}`; - const urlListBlock = updatedUrlList.map((url: string, index: number) => ` ${index + 1}. ${url}`).join('\n'); + const urlListBlock = [...pfUris, ...finalUrlList].map((url: string, index: number) => ` ${index + 1}. ${url}`).join('\n'); const urlListFilter = stringJoin.newline( `**URL List**:`, urlListBlock || ' - None' @@ -179,38 +236,6 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { }; } - for (const doc of docs) { - const patternFlyEntry = doc.path ? byPath[doc.path] : undefined; - const entryName = patternFlyEntry?.name; - const entryVersion = patternFlyEntry?.version; - const entryVersionDisplay = (entryVersion && `(${entryVersion}) `) || ''; - const docTitle = patternFlyEntry - ? `# Documentation for ${patternFlyEntry.displayName || entryName} ${entryVersionDisplay}[${setCategoryDisplayLabel(patternFlyEntry)}]` - : `# Content for ${doc.path}`; - - docResults.push(stringJoin.newline( - docTitle, - `Source: ${doc.path}`, - '', - doc.content - )); - - if (latestSchemasVersion === entryVersion && entryName && !schemasSeen.has(entryName)) { - schemasSeen.add(entryName); - const componentSchema = await getPatternFlyComponentSchema.memo(entryName); - - if (componentSchema) { - schemaResults.push(stringJoin.newline( - `# Component Schema for ${entryName} ${entryVersionDisplay}`, - `This machine-readable JSON schema defines the component's props, types, and validation rules.`, - '```json', - JSON.stringify(componentSchema, null, 2), - '```' - )); - } - } - } - return { content: [ { @@ -227,7 +252,7 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { description: `Get markdown documentation and component JSON schemas for PatternFly resources and components. **Usage**: - 1. Input a component or resource name (e.g., "Button", "Writing") or a list of up to ${options.minMax.docsToLoad.max} documentation URLs at a time (typically from searchPatternFlyDocs results). + 1. Input a component or resource name (e.g., "Button", "Writing") OR a list of up to ${options.minMax.docsToLoad.max} patternfly:// URIs or documentation URLs at a time (typically from searchPatternFlyDocs results). **Returns**: - Markdown documentation @@ -235,9 +260,9 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { `, inputSchema: { urlList: z.array(z.url().min(options.minMax.urlString.min).max(options.minMax.urlString.max)).max(options.minMax.docsToLoad.max) - .optional().describe(`The list of URLs to fetch the documentation from (max ${options.minMax.docsToLoad.max} at a time)`), + .optional().describe(`The list of patternfly:// URIs or URLs to fetch the documentation from (max ${options.minMax.docsToLoad.max} at a time)`), name: z.string().max(options.minMax.inputStrings.max) - .optional().describe('The name of a PatternFly component or resource to fetch documentation for (e.g., "Button", "Table", "Writing")'), + .optional().describe('The name of a PatternFly component or patternfly:// URI resource to fetch documentation for (e.g., "Button", "patternfly://docs/Button")'), version: z.enum(options.patternflyOptions.availableSearchVersions) .optional().describe(`Filter results by a specific PatternFly version (e.g. ${options.patternflyOptions.availableSearchVersions.map(value => `"${value}"`).join(', ')})`) } diff --git a/src/tool.searchPatternFly.ts b/src/tool.searchPatternFly.ts index a158f80a..aa6982d9 100644 --- a/src/tool.searchPatternFly.ts +++ b/src/tool.searchPatternFly.ts @@ -131,7 +131,15 @@ const searchPatternFlyTool = (options = getOptions()): McpTool => { } }); - if (!result.entries.length) { + /** + * FIXME: Schemas are intended to be part of collections. We're temporarily expanding our limit condition + * to include `!result.entries.some(entry => Boolean(entry.path)` to make sure if a collection only has JSON + * schemas available, the "collection" doesn't appear; this is specifically to work with the current MCP + * resource structure. In the future when we've focused our MCP resources down to "collections" and + * "records" we would review dropping this part of the check and allow a "collection" grouping to appear + * if all it had were JSON schemas. + */ + if (!result.entries.length || !result.entries.some(entry => Boolean(entry.path))) { return; } diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts index 74f240f1..affe0491 100644 --- a/src/tool.searchPatternFlyDocs.ts +++ b/src/tool.searchPatternFlyDocs.ts @@ -12,6 +12,10 @@ import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; /** * searchPatternFlyDocs tool function * + * @note Currently, we are purposefully not updating the descriptions around lines 126 - 150 on + * the use of URIs. During low-level auditing, the model actually performed better with limited + * choices in responses. We'll review this periodically. + * * Searches for PatternFly component documentation URLs using fuzzy search. * Returns URLs only (does not fetch content). Use usePatternFlyDocs to fetch the actual content. * @@ -153,12 +157,13 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { description: `Search PatternFly resources and get component names with documentation and guidance URLs. Supports case-insensitive partial and all ("*") matches. **Usage**: - 1. Input a "searchQuery" to find PatternFly documentation and guideline URLs, and component names. - 2. Use the returned resource names OR URLs OR version with the "usePatternFlyDocs" tool to get markdown documentation, guidelines, and component JSON schemas. + 1. Input a "searchQuery" to find PatternFly documentation and guideline URLs, resource URIs, and component names. + 2. Use the returned resource names OR URLs OR URIs OR version with the "usePatternFlyDocs" tool to get markdown documentation, guidelines, and component JSON schemas. **Returns**: - Component and resource names that can be used with "usePatternFlyDocs" - Documentation and guideline URLs that can be used with "usePatternFlyDocs" + - Resource URIs that can be used with "usePatternFlyDocs" or directly as MCP resources `, inputSchema: { searchQuery: z.string()