From 1b606b49a756650d4976bdbfc3abe19d7347dfec Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 16 Mar 2026 15:06:12 -0400 Subject: [PATCH] feat: allow meta resource responses --- .../__snapshots__/server.test.ts.snap | 90 +++++++++ .../tool.searchPatternFlyDocs.test.ts.snap | 4 +- src/__tests__/docsDataIntegrity.test.ts | 43 +++++ src/__tests__/server.resourceMeta.test.ts | 72 ++++++++ src/__tests__/server.test.ts | 9 +- src/docs.json | 4 +- src/mcpSdk.ts | 19 +- src/resource.patternFlyComponentsIndex.ts | 21 ++- src/resource.patternFlyDocsIndex.ts | 26 ++- src/resource.patternFlyDocsTemplate.ts | 2 +- src/resource.patternFlySchemasIndex.ts | 21 ++- src/resource.patternFlySchemasTemplate.ts | 2 +- src/server.helpers.ts | 34 ++++ src/server.resourceMeta.ts | 171 ++++++++++++++++++ src/server.ts | 34 ++-- .../__snapshots__/stdioTransport.test.ts.snap | 6 + 16 files changed, 515 insertions(+), 43 deletions(-) create mode 100644 src/__tests__/docsDataIntegrity.test.ts create mode 100644 src/__tests__/server.resourceMeta.test.ts create mode 100644 src/server.resourceMeta.ts diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index 5c86cd9f..70a8f108 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -21,15 +21,24 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -77,15 +86,24 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -133,15 +151,24 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -197,15 +224,24 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -256,15 +292,24 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -320,15 +365,24 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -395,15 +449,24 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -480,15 +543,24 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -544,15 +616,24 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], @@ -621,15 +702,24 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-template", ], [ "Registered resource: patternfly-schemas-index", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-template", ], diff --git a/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap index 8412728d..bd90285d 100644 --- a/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap @@ -53,9 +53,9 @@ exports[`searchPatternFlyDocsTool, callback should have a specific markdown form exports[`searchPatternFlyDocsTool, callback should parse parameters, default: search 1`] = `"# Search results for PatternFly version "v6" and "Button". Showing 2 exact matches."`; -exports[`searchPatternFlyDocsTool, callback should parse parameters, with "*" searchQuery all: search 1`] = `"# Search results for PatternFly version "v6" and "all" resources. Only showing the first 10 results. There are 806 potential match variations. Try searching with a more specific query."`; +exports[`searchPatternFlyDocsTool, callback should parse parameters, with "*" searchQuery all: search 1`] = `"# Search results for PatternFly version "v6" and "all" resources. Only showing the first 10 results. There are 805 potential match variations. Try searching with a more specific query."`; -exports[`searchPatternFlyDocsTool, callback should parse parameters, with "all" searchQuery all: search 1`] = `"# Search results for PatternFly version "v6" and "all" resources. Only showing the first 10 results. There are 806 potential match variations. Try searching with a more specific query."`; +exports[`searchPatternFlyDocsTool, callback should parse parameters, with "all" searchQuery all: search 1`] = `"# Search results for PatternFly version "v6" and "all" resources. Only showing the first 10 results. There are 805 potential match variations. Try searching with a more specific query."`; exports[`searchPatternFlyDocsTool, callback should parse parameters, with explicit valid version: search 1`] = `"# Search results for PatternFly version "v6" and "Button". Showing 2 exact matches."`; diff --git a/src/__tests__/docsDataIntegrity.test.ts b/src/__tests__/docsDataIntegrity.test.ts new file mode 100644 index 00000000..98aec9bb --- /dev/null +++ b/src/__tests__/docsDataIntegrity.test.ts @@ -0,0 +1,43 @@ +import { distance } from 'fastest-levenshtein'; +import docsJson from '../docs.json'; + +describe('Documentation Data Integrity', () => { + const allEntries = Object.values(docsJson.docs).flat(); + const uniqueCategories = [...new Set(allEntries.map(entry => entry.category).filter(Boolean))]; + const uniqueSections = [...new Set(allEntries.map(entry => (entry as any).section).filter(Boolean))]; + + const checkSimilarity = (list: string[], type: string) => { + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + const str1 = list[i]!.toLowerCase(); + const str2 = list[j]!.toLowerCase(); + + // Check for near-duplicates using Levenshtein distance + const dist = distance(str1, str2); + + if (dist <= 2) { + throw new Error(`Potential duplicate ${type} found: "${list[i]}" and "${list[j]}" (distance: ${dist})`); + } + + // Check if one is a substring of another (e.g., "component" and "components") + if (str1.includes(str2) || str2.includes(str1)) { + throw new Error(`Potential overlapping ${type} found: "${list[i]}" and "${list[j]}"`); + } + } + } + }; + + test('categories should be unique and distinct', () => { + expect(() => checkSimilarity(uniqueCategories as string[], 'category')).not.toThrow(); + }); + + test('sections should be unique and distinct', () => { + expect(() => checkSimilarity(uniqueSections as string[], 'section')).not.toThrow(); + }); + + test('no section should be named "getting-started"', () => { + const hasGettingStarted = allEntries.some(entry => (entry as any).section === 'getting-started'); + + expect(hasGettingStarted).toBe(false); + }); +}); diff --git a/src/__tests__/server.resourceMeta.test.ts b/src/__tests__/server.resourceMeta.test.ts new file mode 100644 index 00000000..a54a5e0b --- /dev/null +++ b/src/__tests__/server.resourceMeta.test.ts @@ -0,0 +1,72 @@ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerResourceMeta } from '../server.resourceMeta'; +import { type McpResource } from '../server'; +import { getOptions, initializeSession } from '../options.context'; + +describe('registerResourceMeta', () => { + let server: McpServer; + const options = getOptions(); + const session = initializeSession(); + + beforeEach(() => { + server = new McpServer({ name: 'test', version: '1.0.0' }); + jest.spyOn(server, 'registerResource').mockImplementation(() => ({} as any)); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should return original resource if enableMeta is false', () => { + const callback = jest.fn(); + const resource: McpResource = [ + 'test-resource', + 'test://uri', + { title: 'Test', description: 'Test' }, + callback, + { enableMeta: false } + ]; + + const result = registerResourceMeta(server, ...resource, options, session); + + expect(result).toEqual(resource); + expect(server.registerResource).not.toHaveBeenCalled(); + }); + + test('should register meta resource and enhance callback if enableMeta is true', async () => { + const callback = jest.fn().mockResolvedValue({ contents: [{ uri: 'test://uri', text: 'original' }] }); + const metaHandler = jest.fn().mockResolvedValue({ + title: 'Meta Title', + description: 'Meta Desc', + params: [], + exampleUris: [] + }); + + const resource: McpResource = [ + 'test-resource', + new ResourceTemplate('test://uri{?a}', { list: undefined }), + { title: 'Test', description: 'Test' }, + callback, + { enableMeta: true, metaHandler } + ]; + + const result = registerResourceMeta(server, ...resource, options, session); + + // Should have registered the meta resource + expect(server.registerResource).toHaveBeenCalledWith( + 'test-resource-meta', + expect.any(ResourceTemplate), + expect.objectContaining({ title: 'Test Metadata' }), + expect.any(Function) + ); + + // Enhanced callback should return 2 contents + const enhancedCallback = result[3]; + const callResult = await enhancedCallback(new URL('test://uri'), { a: 'val' }); + + expect(callResult.contents).toHaveLength(2); + expect(callResult.contents[0].text).toBe('original'); + expect(callResult.contents[1].uri).toBe('test://uri/meta'); + expect(callResult.contents[1].text).toContain('# Resource Metadata: Meta Title'); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 96eab4be..d34ce9df 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -6,7 +6,14 @@ import { startHttpTransport, type HttpServerHandle } from '../server.http'; import { DEFAULT_OPTIONS } from '../options.defaults'; // Mock dependencies -jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); +jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => { + const actual = jest.requireActual('@modelcontextprotocol/sdk/server/mcp.js'); + + return { + ...actual, + McpServer: jest.fn() + }; +}); jest.mock('@modelcontextprotocol/sdk/server/stdio.js'); jest.mock('../logger'); jest.mock('../server.logger', () => ({ diff --git a/src/docs.json b/src/docs.json index e694b249..5d75568d 100644 --- a/src/docs.json +++ b/src/docs.json @@ -3438,7 +3438,7 @@ "displayName": "Design with PatternFly 6", "description": "To start designing with PatternFly 6 you will need to install our PatternFly 6 design kit.", "pathSlug": "design-with-patternfly", - "section": "getting-started", + "section": "get-started", "category": "design-guidelines", "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/2d5fec39ddb8aa32ce78c9a63cdfc1653692b193/packages/documentation-site/patternfly-docs/content/get-started/design.md", @@ -3448,7 +3448,7 @@ "displayName": "Develop with PatternFly 6", "description": "To start developing with PatternFly 6 learn about our design system and tokens.", "pathSlug": "develop-with-patternfly", - "section": "getting-started", + "section": "get-started", "category": "development-guidelines", "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/2d5fec39ddb8aa32ce78c9a63cdfc1653692b193/packages/documentation-site/patternfly-docs/content/get-started/develop.md", diff --git a/src/mcpSdk.ts b/src/mcpSdk.ts index d32f0970..101cd969 100644 --- a/src/mcpSdk.ts +++ b/src/mcpSdk.ts @@ -1,5 +1,6 @@ import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { type McpResource } from './server'; +import { listAllCombinations, listIncrementalCombinations } from './server.helpers'; /** * Register an MCP resource. @@ -80,25 +81,11 @@ const registerResource = ( server.registerResource(newName, resourceTemplate, config, callback); }; - // Variation for all combos, including empty - const paramAllCombinations = (params: string[]) => - params.reduce((acc, val) => acc.concat(acc.map(prev => [...prev, val])), [[]] as string[][]); - - // Variation for incremental combos, including empty - const paramIncrementalCombinations = (params: string[]) => - params.reduce((acc, val) => { - const lastArray = acc[acc.length - 1] || []; - - acc.push([...lastArray, val]); - - return acc; - }, [[]] as string[][]); - // Register the remaining combinations // Reverse order, limitation with the MCP SDK, most params match first const combinations = metadata?.registerAllSearchCombinations - ? paramAllCombinations(searchParams) - : paramIncrementalCombinations(searchParams); + ? listAllCombinations(searchParams) + : listIncrementalCombinations(searchParams); combinations .filter(combination => combination.length < searchParams.length) diff --git a/src/resource.patternFlyComponentsIndex.ts b/src/resource.patternFlyComponentsIndex.ts index 0dd5737b..861e8774 100644 --- a/src/resource.patternFlyComponentsIndex.ts +++ b/src/resource.patternFlyComponentsIndex.ts @@ -29,7 +29,7 @@ const URI_TEMPLATE = 'patternfly://components/index{?version,category}'; /** * URI description for the resource. */ -const URI_DESCRIPTION = `Filter by PatternFly version, and category, ${URI_TEMPLATE}`; +const URI_DESCRIPTION = `Filter by PatternFly version and category. ${URI_TEMPLATE}`; /** * Resource configuration. @@ -184,6 +184,21 @@ const resourceCallback = async (passedUri: URL, variables: Record ({ + title: 'Components Index', + description: 'Use these parameters to filter the list of PatternFly components.', + params: [ + { name: 'version', values: versions, description: 'Specify the PatternFly version.' }, + { name: 'category', values: categories, description: 'Filter content by topical category.' } + ] +}); + /** * Resource creator for the component schemas index. * @@ -210,7 +225,9 @@ const patternFlyComponentsIndexResource = (options = getOptions()): McpResource CONFIG, callback, { - complete + complete, + enableMeta: true, + metaHandler: getComponentsMetaContent } ]; }; diff --git a/src/resource.patternFlyDocsIndex.ts b/src/resource.patternFlyDocsIndex.ts index b2cefda6..e986211b 100644 --- a/src/resource.patternFlyDocsIndex.ts +++ b/src/resource.patternFlyDocsIndex.ts @@ -50,7 +50,7 @@ const URI_TEMPLATE = 'patternfly://docs/index{?version,category,section}'; /** * URI description for the resource. */ -const URI_DESCRIPTION = `Filter by PatternFly version, category, and section, ${URI_TEMPLATE}`; +const URI_DESCRIPTION = `Filter by PatternFly version, category, and section. ${URI_TEMPLATE}`; /** * Resource configuration. @@ -290,6 +290,26 @@ const resourceCallback = async (passedUri: URL, variables: Record ({ + title: 'Documentation Index', + description: 'Use these parameters to filter the PatternFly documentation index.', + params: [ + { name: 'version', values: versions, description: 'Specify the PatternFly version.' }, + { name: 'category', values: categories, description: 'Filter content by topical category.' }, + { name: 'section', values: sections, description: 'Filter content by organizational area.' } + ] +}); + /** * Resource creator for the documentation index. * @@ -318,7 +338,9 @@ const patternFlyDocsIndexResource = (options = getOptions()): McpResource => { callback, { complete, - registerAllSearchCombinations: true + registerAllSearchCombinations: true, + enableMeta: true, + metaHandler: getDocsMetaContent } ]; }; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index bd76cc86..46d869f1 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -31,7 +31,7 @@ const URI_TEMPLATE = 'patternfly://docs/{name}{?version,category,section}'; /** * URI description for the resource. */ -const URI_DESCRIPTION = `Filter by PatternFly version, category, and section, ${URI_TEMPLATE}`; +const URI_DESCRIPTION = `Filter by PatternFly version, category, and section. ${URI_TEMPLATE}`; /** * Resource configuration. diff --git a/src/resource.patternFlySchemasIndex.ts b/src/resource.patternFlySchemasIndex.ts index 7df946aa..8ca6aa2b 100644 --- a/src/resource.patternFlySchemasIndex.ts +++ b/src/resource.patternFlySchemasIndex.ts @@ -26,7 +26,7 @@ const URI_TEMPLATE = 'patternfly://schemas/index{?version,category}'; /** * URI description for the resource. */ -const URI_DESCRIPTION = `Filter by PatternFly version, and category, ${URI_TEMPLATE}`; +const URI_DESCRIPTION = `Filter by PatternFly version and category. ${URI_TEMPLATE}`; /** * Resource configuration. @@ -151,6 +151,21 @@ const resourceCallback = async (passedUri: URL, variables: Record ({ + title: 'Component Schemas Index', + description: 'Use these parameters to filter the list of PatternFly component schemas.', + params: [ + { name: 'version', values: versions, description: 'Specify the PatternFly version.' }, + { name: 'category', values: categories, description: 'Filter content by topical category.' } + ] +}); + /** * Resource creator for the component schemas index. * @@ -180,7 +195,9 @@ const patternFlySchemasIndexResource = (options = getOptions()): McpResource => CONFIG, callback, { - complete + complete, + enableMeta: true, + metaHandler: getSchemasMetaContent } ]; }; diff --git a/src/resource.patternFlySchemasTemplate.ts b/src/resource.patternFlySchemasTemplate.ts index 7d76f41b..d72036b6 100644 --- a/src/resource.patternFlySchemasTemplate.ts +++ b/src/resource.patternFlySchemasTemplate.ts @@ -30,7 +30,7 @@ const URI_TEMPLATE = 'patternfly://schemas/{name}{?version,category}'; /** * URI description for the resource. */ -const URI_DESCRIPTION = `Filter by PatternFly version, and category, ${URI_TEMPLATE}`; +const URI_DESCRIPTION = `Filter by PatternFly version, and category. ${URI_TEMPLATE}`; /** * Resource configuration. diff --git a/src/server.helpers.ts b/src/server.helpers.ts index 0aeeaa97..a53256c8 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -461,6 +461,38 @@ const isWhitelistedUrl = (url: string, whitelist: WhitelistUrl[], { allowedProto } }; +/** + * Generates all possible string combinations from a list of strings. + * + * @example Recombine a list of values into all possible combinations + * // [a, b, c] + * [[], [a], [a, b], [a, b, c], [b], [b, c], [c], [c, a]] + * + * @param values - List of string values. + * @returns Array of string combinations. + */ +const listAllCombinations = (values: string[]): string[][] => + values.reduce((acc, val) => acc.concat(acc.map(prev => [...prev, val])), [[]] as string[][]); + +/** + * Generates incremental combinations of a list of strings, preserving order. + * + * @example Recombine a list of values into all incremental combinations + * // [a, b, c] + * [[], [a], [a, b], [a, b, c]] + * + * @param values - List of string values. + * @returns Array of incremental string combinations. + */ +const listIncrementalCombinations = (values: string[]): string[][] => + values.reduce((acc, val) => { + const lastArray = acc[acc.length - 1] || []; + + acc.push([...lastArray, val]); + + return acc; + }, [[]] as string[][]); + /** * Join an array of values with a separator, optionally filtering out falsy values. * @@ -551,6 +583,8 @@ export { isReferenceLike, isUrl, isWhitelistedUrl, + listAllCombinations, + listIncrementalCombinations, mergeObjects, portValid, stringJoin, diff --git a/src/server.resourceMeta.ts b/src/server.resourceMeta.ts new file mode 100644 index 00000000..826c8f4d --- /dev/null +++ b/src/server.resourceMeta.ts @@ -0,0 +1,171 @@ +import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { log } from './logger'; +import { type McpResource } from './server'; +import { + listAllCombinations, + listIncrementalCombinations, + stringJoin +} from './server.helpers'; +import { paramCompletion } from './resource.helpers'; +import { type GlobalOptions, type AppSession } from './options'; +import { runWithOptions, runWithSession } from './options.context'; + +/** + * Generate a standardized metadata table for resource discovery. + * + * @param options + * @param options.title + * @param options.description + * @param options.params + * @param options.exampleUris + */ +const generateMetaTable = (options: { + title: string; + description: string; + params: { name: string; values: string[]; description: string }[]; + exampleUris: { label: string; uri: string }[]; +}) => { + const tableRows = options.params.map( + param => `| \`${param.name}\` | ${param.values.map(value => `\`${value}\``).join(', ')} | ${param.description} |` + ); + + const exampleLines = options.exampleUris.map(example => `- **${example.label}**: \`${example.uri}\``); + + return stringJoin.newline( + `# Resource Metadata: ${options.title}`, + options.description, + '', + '### Available Parameters', + '', + '| Parameter | Valid Values | Description |', + '| :--- | :--- | :--- |', + ...tableRows, + '', + '### Example URIs', + 'If your client does not support interactive completions, use these patterns:', + ...exampleLines + ); +}; + +/** + * Get all registered URI variations for a template. + * + * @param {string} baseUri - The base URI string. + * @param {string[]} params - The variable names. + * @param {boolean} [allCombos=false] - Whether to generate all permutations. + * @returns {string[]} Array of formatted URI examples. + */ +const getUriVariations = (baseUri: string, params: string[], allCombos = false): string[] => { + const combinations = allCombos ? listAllCombinations(params) : listIncrementalCombinations(params); + + return combinations.map(combo => (combo.length ? `${baseUri}?${combo.map(param => `${param}=...`).join('&')}` : baseUri)); +}; + +/** + * Wrap a resource registration with automatic metadata registration. + * If `metadata.enableMeta` is true, it registers a /meta resource and enhances the callback. + * + * @param {McpServer} server - MCP Server instance + * @param name - Resource name + * @param uriOrTemplate - URI or ResourceTemplate + * @param config - Resource metadata configuration + * @param callback - Callback function for resource read operations + * @param metadata - McpResource metadata + * @param {GlobalOptions} options - Global options + * @param {AppSession} session - App session + * @returns {McpResource} The (potentially enhanced) resource details + */ +const registerResourceMeta = ( + server: McpServer, + name: McpResource[0], + uriOrTemplate: McpResource[1], + config: McpResource[2], + callback: McpResource[3], + metadata: McpResource[4], + options: GlobalOptions, + session: AppSession +): McpResource => { + if (metadata?.enableMeta && metadata.metaHandler) { + const baseUri = (uriOrTemplate instanceof ResourceTemplate + ? uriOrTemplate.uriTemplate?.toString()?.split('{?')[0] + : (uriOrTemplate as string).split('?')[0]) || ''; + + const metaName = `${name}-meta`; + const metaUri = `${baseUri}/meta{?version}`; + + const searchParams = uriOrTemplate instanceof ResourceTemplate ? uriOrTemplate.uriTemplate?.variableNames || [] : []; + + // Generate possible combinations of URIs + const exampleUris = getUriVariations(baseUri, searchParams, Boolean(metadata.registerAllSearchCombinations)).map(uri => ({ + label: uri === baseUri ? 'Base View' : `Filtered View (${uri.split('?')[1]})`, + uri + })); + + // Register the sibling /meta resource + const metaResourceTemplate = new ResourceTemplate(metaUri, { + list: undefined, + ...(metadata.complete ? { complete: metadata.complete } : {}) + }); + + log.info(`Registered resource: ${metaName}`); + server.registerResource( + metaName, + metaResourceTemplate, + { + title: `${config.title} Metadata`, + description: `Discovery manual for ${config.title}.` + }, + async (uri: URL, variables: any) => + runWithSession(session, async () => + runWithOptions(options, async () => { + const { version } = variables as { version?: string }; + const params = await paramCompletion({ version }); + const metaTableOptions = await metadata.metaHandler!(version, params); + + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'text/markdown', + text: generateMetaTable({ + ...metaTableOptions, + exampleUris + }) + } + ] + }; + })) + ); + + // Enhance the primary callback to bundle metadata + const enhancedCallback = async (uri: URL, variables: any) => { + const result = await callback(uri, variables); + + return runWithSession(session, async () => + runWithOptions(options, async () => { + const { version } = variables as { version?: string }; + const params = await paramCompletion({ version }); + const metaTableOptions = await metadata.metaHandler!(version, params); + + if (result.contents) { + result.contents.push({ + uri: `${baseUri}/meta${version ? `?version=${version}` : ''}`, + mimeType: 'text/markdown', + text: generateMetaTable({ + ...metaTableOptions, + exampleUris + }) + }); + } + + return result; + })); + }; + + return [name, uriOrTemplate, config, enhancedCallback, metadata]; + } + + return [name, uriOrTemplate, config, callback, metadata]; +}; + +export { generateMetaTable, getUriVariations, registerResourceMeta }; diff --git a/src/server.ts b/src/server.ts index 97db791b..b5e8e6ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { registerResource } from './mcpSdk'; +import { registerResourceMeta } from './server.resourceMeta'; import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; import { searchPatternFlyDocsTool } from './tool.searchPatternFlyDocs'; import { componentSchemasTool } from './tool.componentSchemas'; @@ -75,6 +76,8 @@ type McpResource = [ handler: (...args: any[]) => any | Promise, metadata?: { registerAllSearchCombinations?: boolean | undefined; + enableMeta?: boolean | undefined; + metaHandler?: ((version: string | undefined, params: any) => any | Promise) | undefined; complete?: { [key: string]: CompleteResourceTemplateCallback; } | undefined; @@ -315,26 +318,29 @@ const runServer = async (options: ServerOptions = getOptions(), { } updatedResources.forEach(resourceCreator => { - const [name, uri, config, callback, metadata] = resourceCreator(options); + const resource = resourceCreator(options); + const [name, uri, config, callback, metadata] = resource; log.info(`Registered resource: ${name}`); - if (server) { - registerResource(server, name, uri, config, (...args: unknown[]) => - runWithSession(session, async () => - runWithOptions(options, async () => { - log.debug( - `Running resource "${name}"`, - `isArgs = ${args?.length > 0}` - ); + const baseCallback = (...args: any[]) => + runWithSession(session, async () => + runWithOptions(options, async () => { + log.debug( + `Running resource "${name}"`, + `isArgs = ${args?.length > 0}` + ); - const timedReport = stat.traffic(); - const resourceResult = await callback(...args); + const timedReport = stat.traffic(); + const resourceResult = await callback(...args); - timedReport({ resource: name }); + timedReport({ resource: name }); - return resourceResult; - })), metadata); + return resourceResult; + })); + + if (server) { + registerResource(server, ...registerResourceMeta(server, name, uri, config, baseCallback, metadata, options, session)); } }); diff --git a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap index 6b165bb8..fd539c93 100644 --- a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap @@ -51,12 +51,18 @@ exports[`Logging should allow setting logging options, stderr 1`] = ` "[INFO]: Registered resource: patternfly-context ", "[INFO]: Registered resource: patternfly-components-index +", + "[INFO]: Registered resource: patternfly-components-index-meta ", "[INFO]: Registered resource: patternfly-docs-index +", + "[INFO]: Registered resource: patternfly-docs-index-meta ", "[INFO]: Registered resource: patternfly-docs-template ", "[INFO]: Registered resource: patternfly-schemas-index +", + "[INFO]: Registered resource: patternfly-schemas-index-meta ", "[INFO]: Registered resource: patternfly-schemas-template ",