diff --git a/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap b/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap new file mode 100644 index 0000000..2dd23ca --- /dev/null +++ b/src/__tests__/__snapshots__/server.resourceMeta.test.ts.snap @@ -0,0 +1,197 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`generateMarkdownTable should generate a markdown table, simple table 1`] = ` +"| Col1 | Col2 | +| :--- | :--- | +| Val1 | Val2 | +| Val3 | Val4 |" +`; + +exports[`generateMarkdownTable should generate a markdown table, with wrapped contents 1`] = ` +"| Param | Values | +| :--- | :--- | +| \`p1\` | \`v1\`, \`v2\` |" +`; + +exports[`generateMetaContent should generate standardized meta content: meta-content 1`] = ` +"# Test Title +Test Description + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`c1\`, \`c2\` | Filter by category | + +## Available Patterns +- **Base**: \`test://uri\`" +`; + +exports[`getUriVariations should get URI variations, allCombos=false 1`] = ` +[ + "test://uri", + "test://uri?v1=...", + "test://uri?v1=...&v2=...", +] +`; + +exports[`getUriVariations should get URI variations, allCombos=true 1`] = ` +[ + "test://uri", + "test://uri?v1=...", +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is a template 1`] = ` +[ + "test-resource-meta", + ResourceTemplate { + "_callbacks": { + "complete": { + "version": [MockFunction], + }, + "list": undefined, + }, + "_uriTemplate": UriTemplate { + "parts": [ + "test://uri/meta", + { + "exploded": false, + "name": "version", + "names": [ + "version", + ], + "operator": "?", + }, + ], + "template": "test://uri/meta{?version}", + }, + }, + { + "description": "Discovery manual for Test.", + "mimeType": "text/markdown", + "title": "Test Metadata", + }, + [Function], +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is a template string with complete 1`] = ` +[ + "test-resource-meta", + ResourceTemplate { + "_callbacks": { + "complete": { + "version": [MockFunction], + }, + "list": undefined, + }, + "_uriTemplate": UriTemplate { + "parts": [ + "test://uri/meta", + { + "exploded": false, + "name": "version", + "names": [ + "version", + ], + "operator": "?", + }, + ], + "template": "test://uri/meta{?version}", + }, + }, + { + "description": "Discovery manual for Test.", + "mimeType": "text/markdown", + "title": "Test Metadata", + }, + [Function], +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is a template string with complete undefined 1`] = ` +[ + "test-resource-meta", + ResourceTemplate { + "_callbacks": { + "list": undefined, + }, + "_uriTemplate": UriTemplate { + "parts": [ + "test://uri/meta", + { + "exploded": false, + "name": "version", + "names": [ + "version", + ], + "operator": "?", + }, + ], + "template": "test://uri/meta{?version}", + }, + }, + { + "description": "Discovery manual for Test.", + "mimeType": "text/markdown", + "title": "Test Metadata", + }, + [Function], +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is almost a template 1`] = ` +[ + "test-resource-meta", + "test://lorem-ipsum/meta", + { + "description": "Discovery manual for Test.", + "mimeType": "text/markdown", + "title": "Test Metadata", + }, + [Function], +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is empty 1`] = ` +[ + "test-resource-meta", + "test://uri/meta", + { + "description": "Discovery manual for Test.", + "mimeType": "text/markdown", + "title": "Test Metadata", + }, + [Function], +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is undefined 1`] = ` +[ + "test-resource", + "test://uri", + { + "description": "Test", + "title": "Test", + }, + [MockFunction], + { + "complete": undefined, + "metaConfig": undefined, + }, +] +`; + +exports[`setMetaResources should attempt to return a resource, metaConfig is unique 1`] = ` +[ + "test-resource-meta", + "test://lorem-ipsum/meta", + { + "description": "Discovery manual for Test.", + "mimeType": "text/markdown", + "title": "Test Metadata", + }, + [Function], +] +`; diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index 5c86cd9..30b7d27 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -18,15 +18,24 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -74,15 +83,24 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -130,15 +148,24 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -194,15 +221,24 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -253,15 +289,24 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -317,15 +362,24 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -392,15 +446,24 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -477,15 +540,24 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -541,15 +613,24 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], @@ -618,15 +699,24 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Registered resource: patternfly-context", ], + [ + "Registered resource: patternfly-components-index-meta", + ], [ "Registered resource: patternfly-components-index", ], + [ + "Registered resource: patternfly-docs-index-meta", + ], [ "Registered resource: patternfly-docs-index", ], [ "Registered resource: patternfly-docs-template", ], + [ + "Registered resource: patternfly-schemas-index-meta", + ], [ "Registered resource: patternfly-schemas-index", ], diff --git a/src/__tests__/docs.json.test.ts b/src/__tests__/docs.json.test.ts index 00d2d08..64a2a3a 100644 --- a/src/__tests__/docs.json.test.ts +++ b/src/__tests__/docs.json.test.ts @@ -1,12 +1,13 @@ -import docs from '../docs.json'; +import { distance } from 'fastest-levenshtein'; +import docsJson from '../docs.json'; describe('docs.json', () => { it('should have a valid top-level generated timestamp (ISO date string)', () => { - expect(docs.generated).toBeDefined(); - expect(typeof docs.generated).toBe('string'); - expect(docs.generated.length).toBeGreaterThan(0); + expect(docsJson.generated).toBeDefined(); + expect(typeof docsJson.generated).toBe('string'); + expect(docsJson.generated.length).toBeGreaterThan(0); - const rawDate = docs.generated; + const rawDate = docsJson.generated; const parsedDate = Date.parse(rawDate); expect(Number.isNaN(parsedDate)).toBe(false); @@ -21,7 +22,7 @@ describe('docs.json', () => { const baseHashes = new Set(); let totalDocs = 0; - Object.entries(docs.docs).forEach(([key, entries]) => { + Object.entries(docsJson.docs).forEach(([key, entries]) => { entries.forEach(entry => { totalDocs += 1; allLinks.add(entry.path); @@ -56,9 +57,9 @@ describe('docs.json', () => { throw new Error(`Found ${duplicates.length} duplicate links in docs.json:\n\n${message}`); } - expect(docs.meta.totalEntries).toBeDefined(); - expect(docs.meta.totalDocs).toBeDefined(); - expect(Object.entries(docs.docs).length).toBe(docs.meta.totalEntries); + expect(docsJson.meta.totalEntries).toBeDefined(); + expect(docsJson.meta.totalDocs).toBeDefined(); + expect(Object.entries(docsJson.docs).length).toBe(docsJson.meta.totalEntries); /** * Confirm we have limited hashes, avoid variation within pf versions @@ -74,13 +75,48 @@ describe('docs.json', () => { * Confirm total docs count matches metadata * Update the JSON metadata accordingly */ - expect(totalDocs).toBe(docs.meta.totalDocs); + expect(totalDocs).toBe(docsJson.meta.totalDocs); /** * Confirm unique links against metadata totals * Update the JSON metadata accordingly */ expect(allLinks.size).toBe(linkMap.size); - expect(allLinks.size).toBe(docs.meta.totalDocs); + expect(allLinks.size).toBe(docsJson.meta.totalDocs); + }); +}); + +describe('docs.json 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.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]}"`); + } + } + } + }; + + it('should have categories that are unique and distinct', () => { + expect(() => checkSimilarity(uniqueCategories, 'category')).not.toThrow(); + }); + + it('should have sections that are unique and distinct', () => { + expect(() => checkSimilarity(uniqueSections, 'section')).not.toThrow(); }); }); diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index 2746e91..1dd38df 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -10,8 +10,11 @@ import { isUrl, isPath, isWhitelistedUrl, + listAllCombinations, + listIncrementalCombinations, mergeObjects, portValid, + splitUri, stringJoin, timeoutFunction } from '../server.helpers'; @@ -753,6 +756,51 @@ describe('isWhitelistedUrl', () => { }); }); +describe('listAllCombinations', () => { + it.each([ + { + values: ['a', 'b'], + expected: [[], ['a'], ['a', 'b'], ['b']] + }, + { + values: ['a'], + expected: [[], ['a']] + }, + { + values: [], + expected: [[]] + } + ])('should list all combinations, $values', ({ values, expected }) => { + const result = listAllCombinations(values); + + // Sequence in `listAllCombinations` may vary, take the simple approach + expect(result.length).toBe(expected.length); + + expected.forEach(combo => { + expect(result).toContainEqual(combo); + }); + }); +}); + +describe('listIncrementalCombinations', () => { + it.each([ + { + values: ['a', 'b', 'c'], + expected: [[], ['a'], ['a', 'b'], ['a', 'b', 'c']] + }, + { + values: ['x'], + expected: [[], ['x']] + }, + { + values: [], + expected: [[]] + } + ])('should list incremental combinations, $values', ({ values, expected }) => { + expect(listIncrementalCombinations(values)).toEqual(expected); + }); +}); + describe('mergeObjects', () => { it.each([ { @@ -902,6 +950,29 @@ describe('portValid', () => { }); }); +describe('splitUri', () => { + it.each([ + { + uri: 'patternfly://docs{?category,section}', + expected: { base: 'patternfly://docs', search: ['category', 'section'] } + }, + { + uri: 'patternfly://docs{#anchor}{?version}', + expected: { base: 'patternfly://docs', search: ['version'] } + }, + { + uri: 'patternfly://docs', + expected: { base: 'patternfly://docs', search: undefined } + }, + { + uri: '', + expected: { base: '', search: undefined } + } + ])('should split URI, $uri', ({ uri, expected }) => { + expect(splitUri(uri)).toEqual(expected); + }); +}); + describe('stringJoin', () => { it('should have expected properties', () => { expect(stringJoin.basic).toBeDefined(); diff --git a/src/__tests__/server.resourceMeta.test.ts b/src/__tests__/server.resourceMeta.test.ts new file mode 100644 index 0000000..4a46542 --- /dev/null +++ b/src/__tests__/server.resourceMeta.test.ts @@ -0,0 +1,282 @@ +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + generateMarkdownTable, + generateMetaContent, + getUriVariations, + setMetadataOptions, + getUriBreakdown, + setMetaResources +} from '../server.resourceMeta'; + +describe('generateMarkdownTable', () => { + it.each([ + { + description: 'simple table', + headers: ['Col1', 'Col2'], + rows: [['Val1', 'Val2'], ['Val3', 'Val4']], + options: {} + }, + { + description: 'with wrapped contents', + headers: ['Param', 'Values'], + rows: [['p1', ['v1', 'v2']]], + options: { wrapContents: [true, true] } + } + ])('should generate a markdown table, $description', ({ headers, rows, options }) => { + expect(generateMarkdownTable(headers, rows as any, options)).toMatchSnapshot(); + }); +}); + +describe('generateMetaContent', () => { + it('should generate standardized meta content', () => { + const content = generateMetaContent({ + title: 'Test Title', + description: 'Test Description', + params: [{ name: 'category', values: ['c1', 'c2'], description: 'Filter by category' }], + exampleUris: [{ label: 'Base', uri: 'test://uri' }] + }); + + expect(content).toContain('# Test Title'); + expect(content).toContain('## Available Parameters'); + expect(content).toContain('## Available Patterns'); + expect(content).toMatchSnapshot('meta-content'); + }); +}); + +describe('getUriVariations', () => { + it.each([ + { + baseUri: 'test://uri', + params: ['v1', 'v2'], + allCombos: false + }, + { + baseUri: 'test://uri', + params: ['v1'], + allCombos: true + } + ])('should get URI variations, allCombos=$allCombos', ({ baseUri, params, allCombos }) => { + expect(getUriVariations(baseUri, params, allCombos)).toMatchSnapshot(); + }); +}); + +describe('setMetadataOptions', () => { + it('should return default metadata options', async () => { + const options = setMetadataOptions({ + name: 'test', + baseUri: 'test://uri', + searchParams: [], + config: { title: 'Test Config' } as any, + metaConfig: {}, + complete: undefined, + registerAllSearchCombinations: undefined + }); + + expect(options.metaName).toBe('test-meta'); + expect(options.metaTitle).toBe('Test Config Metadata'); + expect(typeof options.metaHandler).toBe('function'); + + const content = await options.metaHandler({ version: 'v6' }); + + expect(content).toContain('# Test Config Metadata'); + }); + + it('should merge values from multiple complete callbacks', async () => { + const completeVersion = jest.fn().mockResolvedValue(['v1']); + const completeCategory = jest.fn().mockResolvedValue(['cat1']); + const options = setMetadataOptions({ + name: 'test', + baseUri: 'test://uri', + searchParams: [], + config: { title: 'Test Multiple Callbacks' } as any, + metaConfig: {}, + complete: { version: completeVersion, category: completeCategory }, + registerAllSearchCombinations: undefined + }); + + const content = await options.metaHandler({}); + + expect(completeVersion).toHaveBeenCalledTimes(1); + expect(completeCategory).toHaveBeenCalledTimes(1); + expect(content).toContain('version'); + expect(content).toContain('category'); + expect(content).toContain('v1'); + expect(content).toContain('cat1'); + }); + + it('should fall back to empty values when a complete callback throws', async () => { + const throwingComplete = jest.fn().mockRejectedValue(new Error('network error')); + const options = setMetadataOptions({ + name: 'test', + baseUri: 'test://uri', + searchParams: [], + config: { title: 'Test Config' } as any, + metaConfig: {}, + complete: { version: throwingComplete }, + registerAllSearchCombinations: undefined + }); + + const content = await options.metaHandler({ version: 'v6' }); + + expect(content).toContain('# Test Config Metadata'); + expect(throwingComplete).toHaveBeenCalledTimes(1); + }); +}); + +describe('getUriBreakdown', () => { + it.each([ + { + description: 'static URI', + uriOrTemplate: 'test://uri', + configUri: undefined, + expected: { + isMetaTemplate: false, + metaBaseUri: 'test://uri/meta', + metaUri: 'test://uri/meta' + } + }, + { + description: 'template URI', + uriOrTemplate: 'test://uri{?version}', + configUri: undefined, + expected: { + isMetaTemplate: true, + metaBaseUri: 'test://uri/meta', + metaUri: 'test://uri/meta{?version}' + } + }, + { + description: 'configUri provided overrides derived meta URI', + uriOrTemplate: 'test://uri{?version}', + configUri: 'test://custom/meta{?version}', + expected: { + isMetaTemplate: true, + metaBaseUri: 'test://custom/meta', + metaUri: 'test://custom/meta{?version}' + } + }, + { + description: 'searchFields provided, empty fields', + uriOrTemplate: 'test://uri{?version}', + configUri: 'test://custom/meta{?version}', + searchFields: [], + expected: { + isMetaTemplate: false, + metaBaseUri: 'test://custom/meta', + metaUri: 'test://custom/meta' + } + }, + { + description: 'searchFields provided, added field', + uriOrTemplate: 'test://uri{?version}', + configUri: 'test://custom/meta{?version}', + searchFields: ['category'], + expected: { + isMetaTemplate: true, + metaBaseUri: 'test://custom/meta', + metaUri: 'test://custom/meta{?category}' + } + } + ])('should breakdown URI, $description', ({ uriOrTemplate, configUri, searchFields, expected }) => { + const result = getUriBreakdown({ uriOrTemplate, configUri, searchFields } as any); + + expect(result.metaBaseUri).toBe(expected.metaBaseUri); + expect(result.isMetaTemplate).toBe(expected.isMetaTemplate); + expect(result.metaUri).toBe(expected.metaUri); + }); +}); + +describe('setMetaResources', () => { + it.each([ + { + description: 'metaConfig is undefined', + uri: 'test://uri', + metaConfig: undefined, + expected: 'test://uri' + }, + { + description: 'metaConfig is empty', + uri: 'test://uri', + metaConfig: {}, + expected: 'test://uri/meta' + }, + { + description: 'metaConfig is unique', + uri: 'test://uri', + metaConfig: { + uri: 'test://lorem-ipsum/meta' + }, + expected: 'test://lorem-ipsum/meta' + }, + { + description: 'metaConfig is almost a template', + uri: 'test://uri{?version}', + metaConfig: { + uri: 'test://lorem-ipsum/meta' + }, + expected: 'test://lorem-ipsum/meta' + }, + { + description: 'metaConfig is a template string with complete undefined', + uri: 'test://uri{?version}', + complete: undefined, + metaConfig: {}, + expected: 'test://uri/meta' + }, + { + description: 'metaConfig is a template string with complete', + uri: 'test://uri{?version}', + complete: { version: jest.fn() }, + metaConfig: {}, + expected: 'test://uri/meta{?version}' + }, + { + description: 'metaConfig is a template', + uri: new ResourceTemplate('test://uri{?version}', { + list: undefined + }), + complete: { version: jest.fn() }, + metaConfig: {}, + expected: 'test://uri/meta{?version}' + } + ])('should attempt to return a resource, $description', ({ uri, complete, metaConfig, expected }) => { + const callback = jest.fn(); + const resource = () => [ + 'test-resource', + uri, + { title: 'Test', description: 'Test' }, + callback, + { metaConfig, complete } + ]; + + const metaResource: any = setMetaResources([resource] as any)[0]; + const response = metaResource(); + + expect(JSON.stringify(response[1])).toContain(expected); + expect(response).toMatchSnapshot(); + }); + + it('should append meta content to original resource read result', async () => { + const originalContent = { + contents: [{ uri: 'test://uri', mimeType: 'text/markdown', text: 'original' }] + }; + const callback = jest.fn().mockResolvedValue(originalContent); + const resource = () => [ + 'test-resource', + 'test://uri', + { title: 'Test', description: 'Test' }, + callback, + { metaConfig: {} } + ]; + + const [, enhancedResource]: any = setMetaResources([resource] as any); + const [, , , enhancedCallback] = enhancedResource(); + const result = await enhancedCallback(new URL('test://uri'), {}); + + expect(callback).toHaveBeenCalledTimes(1); + expect(result.contents).toHaveLength(2); + expect(result.contents[0]).toBe(originalContent.contents[0]); + expect(result.contents[0].text).toBe('original'); + expect(result.contents[1].text).toContain('Test Metadata'); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 96eab4b..d34ce9d 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 e694b24..5d75568 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 d32f097..4500818 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, splitUri } from './server.helpers'; /** * Register an MCP resource. @@ -51,11 +52,7 @@ const registerResource = ( if (uriOrTemplate instanceof ResourceTemplate) { const templateStr = uriOrTemplate.uriTemplate?.toString(); - const [remainingBaseUri, remainingUri] = templateStr?.split('{?') || []; - - // Technically, the hash should fall after a query, just a precaution - const baseUri = remainingBaseUri?.split('{#')?.[0]; - const searchUri = remainingUri?.split('}')?.[0]?.toLowerCase(); + const { base: baseUri, search: searchUri } = splitUri(templateStr); // Register original uri, then all combinations OR incremental search params. // Or fail the check and fallthrough to default registration. @@ -64,7 +61,7 @@ const registerResource = ( server.registerResource(name, uriOrTemplate, config, callback); const allVariableNames = uriOrTemplate.uriTemplate.variableNames; - const searchParams = allVariableNames.filter(param => searchUri.includes(param.toLowerCase())); + const searchParams = allVariableNames.filter(param => searchUri.some(searchParam => searchParam === param.toLowerCase())); // Register combinations const register = (incrementalParams: string[]) => { @@ -80,25 +77,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 0dd5737..6113494 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. @@ -185,7 +185,10 @@ const resourceCallback = async (passedUri: URL, variables: Record { callback, { complete, - registerAllSearchCombinations: true + registerAllSearchCombinations: true, + metaConfig: { + uri: 'patternfly://docs/meta{?version}', + title: `${CONFIG.title} Metadata`, + description: 'Use these parameters to filter the PatternFly documentation index.' + } } ]; }; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index bd76cc8..46d869f 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 60f8591..56c777d 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. @@ -152,11 +152,14 @@ const resourceCallback = async (passedUri: URL, variables: Record CONFIG, callback, { - complete + complete, + metaConfig: { + uri: 'patternfly://schemas/meta{?version}', + title: `${CONFIG.title} Metadata`, + description: 'Use these parameters to filter the list of PatternFly component schemas.' + } } ]; }; diff --git a/src/resource.patternFlySchemasTemplate.ts b/src/resource.patternFlySchemasTemplate.ts index 7d76f41..47a1e26 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 0aeeaa9..110b722 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -2,36 +2,6 @@ import { createHash, type BinaryToTextEncoding } from 'node:crypto'; import { extname, sep } from 'node:path'; import { type WhitelistUrl } from './options.defaults'; -/** - * Construct a search/query string from an object of key-value pairs, optionally filtering out - * specific values and adding a `?` prefix. - * - * @param values - An object containing key-value pairs to be converted into a query string. - * @param [options] - Configuration options for constructing the query string. - * @param [options.filter=[undefined, null]] - Array of values to filter out from the key-value pairs. - * @param [options.prefix=false] - Determines whether to prepend a "?" to the query string. - * @returns The constructed query string, optionally prefixed with "?", or `undefined` if no valid key-value pairs remain. - */ -const buildSearchString = ( - values: Record, - { filter = [undefined, null], prefix = false }: { filter?: unknown[], prefix?: boolean } = {} -) => { - let entries = Object.entries(values); - - if (filter) { - entries = entries.filter(([_key, value]) => !filter.includes(value)); - } - - if (!entries.length) { - return undefined; - } - - const entriesToString = entries.sort(([aKey], [bKey]) => aKey.localeCompare(bKey)).map(([key, value]) => [key, `${value}`]); - const searchParams = new URLSearchParams(Object.fromEntries(entriesToString)); - - return prefix ? `?${searchParams.toString()}` : searchParams.toString(); -}; - /** * Check if a value is a valid port number. * @@ -205,6 +175,8 @@ const isAsync = (obj: unknown) => /^\[object (Async|AsyncFunction)]/.test(Object /** * Check if "is a Promise", "Promise like". * + * @note This is an internal intentional stable classifier, not a general "returns a thenable" check. + * * @param obj - Object, or otherwise, to check * @returns `true` if the object is a Promise */ @@ -461,6 +433,63 @@ 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], [a, c]] + * + * @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[][]); + +/** + * Basic split for URIs to find base and search. + * + * @note We only support a single `{?...}` query segment. Using `{?a}{?b}{?c}` will fail. Make sure + * resource URIs are set to use a single `{?a,b,c}` segment. + * + * @param uri - The URI to split + * @returns Object containing `base` and `search` URI parts + */ +const splitUri = (uri: string) => { + const [remainingBaseUri, remainingUri] = uri?.split('{?') || []; + const baseUri = remainingBaseUri?.split('{#')?.[0]; + const searchUri = remainingUri + ?.split('}')?.[0] + ?.toLowerCase() + ?.split(',') + ?.map(param => param.trim()) + ?.filter(Boolean); + + return { + base: baseUri, + search: searchUri + }; +}; + /** * Join an array of values with a separator, optionally filtering out falsy values. * @@ -506,6 +535,40 @@ stringJoin.filtered = (...args: unknown[]) => stringJoin(args, { filterFalsyValu */ stringJoin.newlineFiltered = (...args: unknown[]) => stringJoin(args, { sep: '\n', filterFalsyValues: true }); +/** + * Construct a search/query string from an object of key-value pairs, optionally filtering out + * specific values and adding a `?` prefix. + * + * @param values - An object containing key-value pairs to be converted into a query string. + * @param [options] - Configuration options for constructing the query string. + * @param [options.filter=[undefined, null]] - Array of values to filter out from the key-value pairs. + * @param [options.prefix=false] - Determines whether to prepend a "?" to the query string. + * @returns The constructed query string, optionally prefixed with "?", or `undefined` if no valid key-value pairs remain. + */ +const buildSearchString = ( + values: Record, + { filter = [undefined, null], prefix = false }: { filter?: unknown[], prefix?: boolean } = {} +) => { + if (!isPlainObject(values)) { + return undefined; + } + + let entries = Object.entries(values); + + if (filter) { + entries = entries.filter(([_key, value]) => !filter.includes(value)); + } + + if (!entries.length) { + return undefined; + } + + const entriesToString = entries.sort(([aKey], [bKey]) => aKey.localeCompare(bKey)).map(([key, value]) => [key, `${value}`]); + const searchParams = new URLSearchParams(Object.fromEntries(entriesToString)); + + return prefix ? `?${searchParams.toString()}` : searchParams.toString(); +}; + /** * Wrap a function, or another Promise in a timeout, returning a * Promise that either resolves, rejects, or rejects after the timeout. @@ -551,8 +614,11 @@ export { isReferenceLike, isUrl, isWhitelistedUrl, + listAllCombinations, + listIncrementalCombinations, mergeObjects, portValid, + splitUri, stringJoin, timeoutFunction }; diff --git a/src/server.resourceMeta.ts b/src/server.resourceMeta.ts new file mode 100644 index 0000000..da3ffb7 --- /dev/null +++ b/src/server.resourceMeta.ts @@ -0,0 +1,443 @@ +import { + ResourceTemplate, + type CompleteResourceTemplateCallback +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + type McpResource, + type McpResourceCreator, + type McpResourceMetadata, + type McpResourceMetadataMetaConfig +} from './server'; +import { + buildSearchString, + isPlainObject, + listAllCombinations, + listIncrementalCombinations, + splitUri, + stringJoin +} from './server.helpers'; +import { getOptions, runWithOptions } from './options.context'; + +/** + * Type definition for options used in setting up resource metadata. + * + * @interface SetMetadataOptions + * + * @property name - The name of the resource. + * @property baseUri - The base URI for the resource. + * @property searchParams - List of search parameters for the resource. + * @property {McpResource[2]} config - Configuration for the resource. + * @property {McpResourceMetadataMetaConfig | undefined} metaConfig - Metadata configuration for the resource. + * @property {McpResourceMetadata['complete']} complete - Completion function for the resource metadata. + * @property {McpResourceMetadata['registerAllSearchCombinations']} registerAllSearchCombinations - Boolean indicating + * whether to register all search combinations for the resource metadata. + */ +interface SetMetadataOptions { + name: string; + baseUri: string; + searchParams: string[]; + config: McpResource[2]; + metaConfig: McpResourceMetadataMetaConfig | undefined; + complete: McpResourceMetadata['complete']; + registerAllSearchCombinations: McpResourceMetadata['registerAllSearchCombinations']; +} + +/** + * Generate a basic Markdown table with optional content wrapping. + * + * @note Consider relocating this function to somewhere like a "resourceHelpers" + * if we end up using it in multiple places. + * + * @param columnHeaders - Column headers for the table. + * @param rows - Rows of data to include in the table. + * @param [options] - Options for table generation. + * @param [options.wrapContents] - Optional array of booleans that aligns to each column and indicates whether to wrap the content. + * @returns A Markdown table string. + */ +const generateMarkdownTable = (columnHeaders: string[], rows: (string | string[])[][], { wrapContents = [] }: { wrapContents?: boolean[] } = {}) => { + const wrapValue = (value: string | string[], index: number) => { + if (!wrapContents[index]) { + return value; + } + + if (Array.isArray(value)) { + return value.map(val => `\`${val}\``).join(', '); + } + + return `\`${value}\``; + }; + + const tableRows = rows.map(row => `| ${row.slice(0, columnHeaders.length).map((cell, index) => wrapValue(cell, index)).join(' | ')} |`); + const tableHeader = `| ${columnHeaders.join(' | ')} |`; + const tableSeparator = `| ${columnHeaders.map(() => ':---').join(' | ')} |`; + + return stringJoin.newline( + tableHeader, + tableSeparator, + ...tableRows + ); +}; + +/** + * Generate a standardized metadata table for resource discovery. + * + * @param param - Object parameter + * @param param.title - Heading title for the generated Markdown. + * @param param.description - Resource description/summary for the Markdown. + * @param param.params - Resource rows for the parameters Markdown table. + * @param [param.exampleUris] - Example URIs for the resource "Available Patterns" Markdown section. + * @returns Markdown content for resource metadata. + */ +const generateMetaContent = ({ title, description, params, exampleUris = [] }: { + title: string; + description: string; + params: { name: string; values: string[]; description: string }[]; + exampleUris?: { label: string; uri: string }[]; +}) => { + let table = ''; + let examples = ''; + + if (params.length) { + const tableRows = params.map(({ name, values, description }) => [name, values, description]); + + table = stringJoin.newline( + '', + '## Available Parameters', + '', + generateMarkdownTable(['Parameter', 'Valid Values', 'Description'], tableRows, { wrapContents: [true, true, false] }) + ); + } + + if (exampleUris.length) { + const exampleUriLines = exampleUris.map(example => `- **${example.label}**: \`${example.uri}\``); + + examples = stringJoin.newline( + '', + '## Available Patterns', + ...exampleUriLines + ); + } + + return stringJoin.newline( + `# ${title}`, + description, + table, + examples + ); +}; + +/** + * Get all registered URI variations for a template. + * + * @param baseUri - The base URI string. + * @param params - The variable names. + * @param [allCombos=false] - Whether to generate all permutations. + * @returns 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 => { + let str = baseUri; + + if (combo.length) { + str += `?${combo.map(param => `${param}=...`).join('&')}`; + } + + return str; + }); +}; + +/** + * Configures and returns metadata options based on the provided parameters and configuration. + * + * @note The `metaHandler` must be a function (sync or async) to align with the MCP SDK. + * If a provided handler is not a function, a default fallback async handler is used, + * see type `McpResourceMetadataMetaConfig`. + * + * @note The generated `metaHandler` attempts to run any related "completion" callbacks. If they + * fail, the handler silently ignores the error and continues execution. This is by design and + * related to our concept of meta-resources providing an alternative avenue for MCP clients + * lacking completion. + * + * @param {SetMetadataOptions} settings - Settings for configuring metadata options. + * @returns An object containing the configured metadata options. + */ +const setMetadataOptions = ({ name, baseUri, searchParams, metaConfig, config, complete, registerAllSearchCombinations }: SetMetadataOptions) => { + // Set basic meta-properties from config or create them. + const metaName = metaConfig?.name || `${name}-meta`; + const metaTitle = metaConfig?.title || `${config.title} Metadata`; + const metaDescription = metaConfig?.description || `Discovery manual for ${config.title}.`; + const metaMimeType = metaConfig?.mimeType || 'text/markdown'; + let metaHandler = metaConfig?.metaHandler; + + if (typeof metaHandler !== 'function') { + // Generated example URIs for fallback handler + const exampleUris = getUriVariations(baseUri, searchParams, Boolean(registerAllSearchCombinations)).map(uri => { + const splitSearchParams = uri.split('?')[1]; + + return { + label: !splitSearchParams ? 'Base View' : `Filtered View (${splitSearchParams})`, + uri + }; + }); + + // Fallback handler for generating metadata content + metaHandler = async (passedParams: Record | undefined) => { + const updatedParams = isPlainObject(passedParams) ? passedParams : {}; + const params = []; + + if (isPlainObject(complete)) { + for (const prop in complete) { + const name = prop; + const description = `Filter by ${name}`; + let values: string[] = []; + + if (complete[prop]) { + try { + values = await complete[prop]('', { arguments: { ...updatedParams } }); + } catch {} + } + + params.push({ name, values, description }); + } + } + + return generateMetaContent({ + title: metaTitle, + description: metaDescription, + params, + exampleUris + }); + }; + } + + return { + metaName, + metaTitle, + metaDescription, + metaMimeType, + metaHandler + }; +}; + +/** + * Generate related metadata URIs and parameters. + * + * @param options - Input options + * @param options.uriOrTemplate - Original URI or a `ResourceTemplate` instance to parse. + * @param options.configUri - Passed metadata configuration URI. + * @param options.searchFields - Passed metadata "searchFields" settings associated with the resource. + * @returns Breakdown used to build meta resources and templates. + * - `isMetaTemplate` - Whether the meta resource uses a `ResourceTemplate` (query variables). + * - `originalBaseUri` - Base URI of the source resource (before `/meta`), from the original template or string. + * - `originalSearchParams` - Query parameter names from the source URI template `{?...}` segment. + * - `metaBaseUri` - Static meta path (no `{?...}`), e.g. `originalBaseUri + '/meta'` or the base of `configUri`. + * - `metaUri` - Full meta URI, either `metaBaseUri` or `metaBaseUri{?a,b,...}` when there are variables. + * - `metaSearchParams` - Names included in the meta template; driven by `searchFields`, `configUri`, or the original template. + */ +const getUriBreakdown = ({ uriOrTemplate, configUri, searchFields }: { + uriOrTemplate: string | ResourceTemplate, + configUri: McpResourceMetadataMetaConfig['uri'], + searchFields: McpResourceMetadataMetaConfig['searchFields'] +}) => { + const isResourceTemplate = uriOrTemplate instanceof ResourceTemplate; + let metaUri = configUri; + let metaBaseUri: string | undefined; + + const tempOriginalUri = isResourceTemplate ? uriOrTemplate.uriTemplate?.toString() : uriOrTemplate; + + const { base: originalBaseUri, search: searchOriginalKeys } = splitUri(tempOriginalUri); + const { search: metaSearchKeys } = metaUri ? splitUri(metaUri) : {}; + + const originalSearchParams = (searchOriginalKeys?.length && searchOriginalKeys) || []; + const tempMetaSearchParams = (metaSearchKeys?.length && metaSearchKeys) || []; + + // If `searchFields` is set, use it regardless of length. + const metaSearchParams = (Array.isArray(searchFields) && searchFields) || (metaUri && tempMetaSearchParams) || originalSearchParams; + + const isMetaTemplate = isResourceTemplate || metaSearchParams.length > 0; + + if (metaUri) { + const { base } = splitUri(metaUri); + + metaBaseUri = base; + } else if (originalBaseUri) { + metaBaseUri = `${originalBaseUri}/meta`; + } + + metaUri = metaBaseUri; + + if (metaSearchParams?.length) { + metaUri = `${metaBaseUri}{?${metaSearchParams.join(',')}}`; + } + + return { + isMetaTemplate, + originalBaseUri, + originalSearchParams, + metaBaseUri, + metaUri, + metaSearchParams + }; +}; + +/** + * Enhances and generates meta-resources for a set of resources. + * + * - Adds a new meta-resource if a configuration is provided + * - Modifies the original resource to indicate a meta-resource is available + * + * @note Review needing to apply session context. We currently don't apply it in `resource.*.ts` + * either, but it is applied in `server.ts`. + * + * @param {McpResourceCreator[]} resources - List of resource creators to process and enhance. + * @param [options] - Optional settings. + * @returns {McpResourceCreator[]} An updated list of resource creators, including any added or modified meta-resources. + */ +const setMetaResources = (resources: McpResourceCreator[], options = getOptions()) => { + const updatedResources: McpResourceCreator[] = []; + + // Check each resource for meta-config + resources.forEach(resourceCreator => { + const [name, uriOrTemplate, config, callback, metadata] = resourceCreator(options); + + // No meta-config available, move to the next resource + if (!metadata?.metaConfig) { + updatedResources.push(resourceCreator); + + return; + } + + // Get a URI breakdown + const uriBreakdown = getUriBreakdown({ + uriOrTemplate, + configUri: metadata.metaConfig.uri, + searchFields: metadata.metaConfig.searchFields + }); + + // If no URI breakdown assume resource is still valid + if (!uriBreakdown.metaBaseUri || !uriBreakdown.metaUri || !uriBreakdown.originalBaseUri) { + updatedResources.push(resourceCreator); + + return; + } + + // Create a new meta-resource or template + // We still allow complete even though the intent of `meta` resource is to provide a + // way around completion for "lesser" MCP clients since technically, those clients can still + // pass a version parameter based on the meta URI template. + let metaResourceOrTemplate: string | ResourceTemplate = uriBreakdown.metaUri; + + if (uriBreakdown.isMetaTemplate) { + const updatedComplete: { [variable: string]: CompleteResourceTemplateCallback; } = {}; + + if (isPlainObject(metadata.complete)) { + Object.entries(metadata.complete).forEach(([key, value]) => { + if (uriBreakdown.metaSearchParams.includes(key)) { + updatedComplete[key] = value; + } + }); + } + + metaResourceOrTemplate = new ResourceTemplate(uriBreakdown.metaUri, { + list: undefined, + ...(Object.keys(updatedComplete).length ? { complete: updatedComplete } : {}) + }); + } + + // Set meta-properties + const { metaName, metaTitle, metaDescription, metaMimeType, metaHandler } = setMetadataOptions({ + name, + baseUri: uriBreakdown.originalBaseUri, + searchParams: uriBreakdown.originalSearchParams, + metaConfig: metadata.metaConfig, + config, + complete: metadata.complete, + registerAllSearchCombinations: metadata.registerAllSearchCombinations + }); + + // Resolve and serialize meta handler output + const resolveMetaText = async (params: Record = {}) => { + const resourceText = await metaHandler(params); + + return isPlainObject(resourceText) || Array.isArray(resourceText) + ? JSON.stringify(resourceText, null, 2) + : String(resourceText); + }; + + // Create a new meta-resource + const metaResource = (opts = options): McpResource => { + const metaCallback: McpResource[3] = async (passedUri, variables) => + runWithOptions(opts, async () => { + const updatedText = await resolveMetaText(variables); + + return { + contents: [ + { + uri: passedUri?.toString(), + mimeType: metaMimeType, + text: updatedText + } + ] + }; + }); + + return [ + metaName, + metaResourceOrTemplate, + { + title: metaTitle, + description: metaDescription, + mimeType: metaMimeType + }, + metaCallback + ]; + }; + + // Add the meta-resource enhancement to the existing resource + const enhancedResource = (opts = options): McpResource => { + const metaEnhancedCallback: McpResource[3] = async (passedUri, variables) => + runWithOptions(opts, async () => { + const result = await callback(passedUri, variables); + + if (!isPlainObject(result) || !Array.isArray(result.contents)) { + return result; + } + + const updatedText = await resolveMetaText(variables); + const queryString = buildSearchString(variables, { prefix: true }); + const metaContentUri = queryString ? `${uriBreakdown.metaBaseUri}${queryString}` : uriBreakdown.metaBaseUri; + + return { + ...result, + contents: [ + ...result.contents, + { + uri: metaContentUri, + mimeType: metaMimeType, + text: updatedText + } + ] + }; + }); + + return [name, uriOrTemplate, config, metaEnhancedCallback, metadata]; + }; + + // Add the resources back in + updatedResources.push(metaResource); + updatedResources.push(enhancedResource); + }); + + return updatedResources; +}; + +export { + generateMetaContent, + generateMarkdownTable, + getUriBreakdown, + getUriVariations, + setMetadataOptions, + setMetaResources +}; diff --git a/src/server.ts b/src/server.ts index 97db791..2fd1c2d 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 { setMetaResources } from './server.resourceMeta'; import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; import { searchPatternFlyDocsTool } from './tool.searchPatternFlyDocs'; import { componentSchemasTool } from './tool.componentSchemas'; @@ -56,30 +57,68 @@ type McpTool = [ */ type McpToolCreator = ((options?: GlobalOptions) => McpTool) & { toolName?: string }; +/** + * Configuration for a generated metadata MCP resource. + * + * @interface McpResourceMetadataMetaConfig + * + * @property [uri] - Override URI for the meta-resource. (e.g., `test://lorem/meta`, `test://ipsum/meta{?var}`). + * @property [name] - Registered name for the meta-resource (defaults to `{primaryName}-meta`). + * @property [title] - Title shown for the meta-resource in listings and generated Markdown. + * @property [description] - Description for the meta-resource in listings and generated Markdown. + * @property [searchFields] - Query parameter names included on the meta-URI template for completion. + * - If an empty array is provided the meta-resource uses a static URI, no template + * - If omitted the search fields are inferred from the `uri` or the primary resource template. + * @property [mimeType] - MIME type of the meta-resource body. Acceptable values are: + * - 'text/markdown' + * - 'application/json' + * @property [metaHandler] - A custom handler for the meta-resource. It accepts an optional object as its + * argument for passing parameters and returns a serialized value to the MCP client. A default fallback + * async handler is used if none is provided. + */ +interface McpResourceMetadataMetaConfig { + uri?: string; + name?: string; + title?: string; + description?: string; + searchFields?: string[] | undefined; + mimeType?: 'text/markdown' | 'application/json'; + metaHandler?: (params: Record | undefined) => Promise | unknown; +} + +/** + * A resource metadata configuration for the MCP server. + * + * @property registerAllSearchCombinations - Whether to register all search combinations for the resource. + * @property metaConfig - Optional configuration for generating a metadata resource. Being defined + * (e.g. `{ metadata: { metaConfig: {} }}`) means a meta-resource will be generated for the related MCP resource. + * @property complete - Callback functions for resource completion. + */ +interface McpResourceMetadata { + registerAllSearchCombinations?: boolean | undefined; + metaConfig?: McpResourceMetadataMetaConfig; + complete?: { + [key: string]: CompleteResourceTemplateCallback; + } | undefined; + [key: string]: unknown; +} + /** * A resource registered with the MCP server. * * 0. `name`: Registered name of the resource. - * 1. `uriOrTemplate`: URI string or template. - * 2. `config`: Resource configuration metadata. + * 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 - * resource registry. - * - `metadata.complete`: Callback functions for resource read operations completion - * - `metadata.registerAllSearchCombinations`: Whether to register all search parameter permutations or not. + * 4. `metadata`: Optional **internal metadata** object, not used by the standard MCP SDK + * resource registry. {@link McpResourceMetadata} */ type McpResource = [ name: string, uriOrTemplate: string | ResourceTemplate, config: ResourceMetadata, handler: (...args: any[]) => any | Promise, - metadata?: { - registerAllSearchCombinations?: boolean | undefined; - complete?: { - [key: string]: CompleteResourceTemplateCallback; - } | undefined; - [key: string]: unknown; - } | undefined + metadata?: McpResourceMetadata | undefined ]; /** @@ -291,7 +330,10 @@ const runServer = async (options: ServerOptions = getOptions(), { log.info(`Server stats enabled.`); // Compose resources after logging is set up. - const updatedResources = await composeResources(resources); + let updatedResources = await composeResources(resources); + + // Add dynamic metadata to resources + updatedResources = setMetaResources(updatedResources); // Combine built-in tools with custom ones after logging is set up. const updatedTools = await composeTools(tools); @@ -314,6 +356,7 @@ const runServer = async (options: ServerOptions = getOptions(), { getStatsSetup = () => statsTracker.getStats(); } + // Apply MCP resources, if available updatedResources.forEach(resourceCreator => { const [name, uri, config, callback, metadata] = resourceCreator(options); @@ -338,6 +381,7 @@ const runServer = async (options: ServerOptions = getOptions(), { } }); + // Apply MCP tools, if available updatedTools.forEach(toolCreator => { const [name, schema, callback] = toolCreator(options); // Do NOT normalize schemas here. This is by design and is a fallback check for malformed schemas. @@ -487,6 +531,8 @@ export { type McpToolCreator, type McpResource, type McpResourceCreator, + type McpResourceMetadata, + type McpResourceMetadataMetaConfig, type ServerInstance, type ServerLogEvent, type ServerOnLog, diff --git a/tests/e2e/__snapshots__/httpTransport.test.ts.snap b/tests/e2e/__snapshots__/httpTransport.test.ts.snap index fdcf2be..5f6a576 100644 --- a/tests/e2e/__snapshots__/httpTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/httpTransport.test.ts.snap @@ -1,5 +1,74 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`Builtin resources, HTTP transport should read meta resources, patternfly-components-meta 1`] = ` +{ + "mimeType": "text/markdown", + "text": "# PatternFly Components Index Metadata +Use these parameters to filter the list of PatternFly components. + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`accessibility\`, \`design-guidelines\`, \`react\` | Filter by category | +| \`version\` | \`v5\`, \`v6\` | Filter by version | + +## Available Patterns +- **Base View**: \`patternfly://components/index\` +- **Filtered View (version=...)**: \`patternfly://components/index?version=...\` +- **Filtered View (version=...&category=...)**: \`patternfly://components/index?version=...&category=...\`", + "uri": "patternfly://components/meta", +} +`; + +exports[`Builtin resources, HTTP transport should read meta resources, patternfly-docs-meta 1`] = ` +{ + "mimeType": "text/markdown", + "text": "# PatternFly Documentation Index Metadata +Use these parameters to filter the PatternFly documentation index. + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`accessibility\`, \`design-guidelines\`, \`design-tokens\`, \`development-guidelines\`, \`grammar\`, \`react\`, \`writing-guides\` | Filter by category | +| \`section\` | \`ai\`, \`charts\`, \`components\`, \`content-design\`, \`extensions\`, \`foundations-and-styles\`, \`get-started\`, \`guidelines\`, \`layouts\`, \`patterns\`, \`resources\`, \`upgrade\` | Filter by section | +| \`version\` | \`v4\`, \`v5\`, \`v6\` | Filter by version | + +## Available Patterns +- **Base View**: \`patternfly://docs/index\` +- **Filtered View (version=...)**: \`patternfly://docs/index?version=...\` +- **Filtered View (category=...)**: \`patternfly://docs/index?category=...\` +- **Filtered View (version=...&category=...)**: \`patternfly://docs/index?version=...&category=...\` +- **Filtered View (section=...)**: \`patternfly://docs/index?section=...\` +- **Filtered View (version=...§ion=...)**: \`patternfly://docs/index?version=...§ion=...\` +- **Filtered View (category=...§ion=...)**: \`patternfly://docs/index?category=...§ion=...\` +- **Filtered View (version=...&category=...§ion=...)**: \`patternfly://docs/index?version=...&category=...§ion=...\`", + "uri": "patternfly://docs/meta", +} +`; + +exports[`Builtin resources, HTTP transport should read meta resources, patternfly-schemas-meta 1`] = ` +{ + "mimeType": "text/markdown", + "text": "# PatternFly Component Schemas Index Metadata +Use these parameters to filter the list of PatternFly component schemas. + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`accessibility\`, \`design-guidelines\`, \`react\` | Filter by category | +| \`version\` | \`v5\`, \`v6\` | Filter by version | + +## Available Patterns +- **Base View**: \`patternfly://schemas/index\` +- **Filtered View (version=...)**: \`patternfly://schemas/index?version=...\` +- **Filtered View (version=...&category=...)**: \`patternfly://schemas/index?version=...&category=...\`", + "uri": "patternfly://schemas/meta", +} +`; + exports[`Builtin tools, HTTP transport should concatenate headers and separator with two remote files 1`] = ` "# Content for https://www.patternfly.org/notARealPath/AboutModal.md Source: https://www.patternfly.org/notARealPath/AboutModal.md diff --git a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap index 6b165bb..f5bc32a 100644 --- a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap @@ -1,5 +1,74 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`Builtin resources, STDIO should read meta resources, patternfly-components-meta 1`] = ` +{ + "mimeType": "text/markdown", + "text": "# PatternFly Components Index Metadata +Use these parameters to filter the list of PatternFly components. + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`accessibility\`, \`design-guidelines\`, \`react\` | Filter by category | +| \`version\` | \`v5\`, \`v6\` | Filter by version | + +## Available Patterns +- **Base View**: \`patternfly://components/index\` +- **Filtered View (version=...)**: \`patternfly://components/index?version=...\` +- **Filtered View (version=...&category=...)**: \`patternfly://components/index?version=...&category=...\`", + "uri": "patternfly://components/meta", +} +`; + +exports[`Builtin resources, STDIO should read meta resources, patternfly-docs-meta 1`] = ` +{ + "mimeType": "text/markdown", + "text": "# PatternFly Documentation Index Metadata +Use these parameters to filter the PatternFly documentation index. + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`accessibility\`, \`design-guidelines\`, \`design-tokens\`, \`development-guidelines\`, \`grammar\`, \`react\`, \`writing-guides\` | Filter by category | +| \`section\` | \`ai\`, \`charts\`, \`components\`, \`content-design\`, \`extensions\`, \`foundations-and-styles\`, \`get-started\`, \`guidelines\`, \`layouts\`, \`patterns\`, \`resources\`, \`upgrade\` | Filter by section | +| \`version\` | \`v4\`, \`v5\`, \`v6\` | Filter by version | + +## Available Patterns +- **Base View**: \`patternfly://docs/index\` +- **Filtered View (version=...)**: \`patternfly://docs/index?version=...\` +- **Filtered View (category=...)**: \`patternfly://docs/index?category=...\` +- **Filtered View (version=...&category=...)**: \`patternfly://docs/index?version=...&category=...\` +- **Filtered View (section=...)**: \`patternfly://docs/index?section=...\` +- **Filtered View (version=...§ion=...)**: \`patternfly://docs/index?version=...§ion=...\` +- **Filtered View (category=...§ion=...)**: \`patternfly://docs/index?category=...§ion=...\` +- **Filtered View (version=...&category=...§ion=...)**: \`patternfly://docs/index?version=...&category=...§ion=...\`", + "uri": "patternfly://docs/meta", +} +`; + +exports[`Builtin resources, STDIO should read meta resources, patternfly-schemas-meta 1`] = ` +{ + "mimeType": "text/markdown", + "text": "# PatternFly Component Schemas Index Metadata +Use these parameters to filter the list of PatternFly component schemas. + +## Available Parameters + +| Parameter | Valid Values | Description | +| :--- | :--- | :--- | +| \`category\` | \`accessibility\`, \`design-guidelines\`, \`react\` | Filter by category | +| \`version\` | \`v5\`, \`v6\` | Filter by version | + +## Available Patterns +- **Base View**: \`patternfly://schemas/index\` +- **Filtered View (version=...)**: \`patternfly://schemas/index?version=...\` +- **Filtered View (version=...&category=...)**: \`patternfly://schemas/index?version=...&category=...\`", + "uri": "patternfly://schemas/meta", +} +`; + exports[`Builtin tools, STDIO should concatenate headers and separator with two remote files 1`] = ` "# Content for http://127.0.0.1:5010/notARealPath/AboutModal.md Source: http://127.0.0.1:5010/notARealPath/AboutModal.md @@ -49,12 +118,18 @@ exports[`Logging should allow setting logging options, stderr 1`] = ` "[INFO]: No external tools loaded. ", "[INFO]: Registered resource: patternfly-context +", + "[INFO]: Registered resource: patternfly-components-index-meta ", "[INFO]: Registered resource: patternfly-components-index +", + "[INFO]: Registered resource: patternfly-docs-index-meta ", "[INFO]: Registered resource: patternfly-docs-index ", "[INFO]: Registered resource: patternfly-docs-template +", + "[INFO]: Registered resource: patternfly-schemas-index-meta ", "[INFO]: Registered resource: patternfly-schemas-index ", diff --git a/tests/e2e/httpTransport.test.ts b/tests/e2e/httpTransport.test.ts index 10d6748..67e1de2 100644 --- a/tests/e2e/httpTransport.test.ts +++ b/tests/e2e/httpTransport.test.ts @@ -192,15 +192,46 @@ describe('Builtin resources, HTTP transport', () => { expect(resourceNames).toContain('patternfly://context'); expect(templateNames).toContain('patternfly://components/index'); + expect(templateNames).toContain('patternfly://components/meta'); expect(templateNames).toContain('patternfly://components/index{?version,category}'); expect(templateNames).toContain('patternfly://docs/index'); + expect(templateNames).toContain('patternfly://docs/meta'); expect(templateNames).toContain('patternfly://docs/index{?version,category,section}'); expect(templateNames).toContain('patternfly://docs/{name}{?version,category,section}'); expect(templateNames).toContain('patternfly://schemas/index'); + expect(templateNames).toContain('patternfly://schemas/meta'); expect(templateNames).toContain('patternfly://schemas/index{?version,category}'); expect(templateNames).toContain('patternfly://schemas/{name}{?version,category}'); }); + it.each([ + { + description: 'patternfly-components-meta', + uri: 'patternfly://components/meta', + expected: 'PatternFly Components Index Metadata' + }, + { + description: 'patternfly-docs-meta', + uri: 'patternfly://docs/meta', + expected: 'PatternFly Documentation Index Metadata' + }, + { + description: 'patternfly-schemas-meta', + uri: 'patternfly://schemas/meta', + expected: 'PatternFly Component Schemas Index Metadata' + } + ])('should read meta resources, $description', async ({ uri, expected }) => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe(uri); + expect(content.text).toContain(expected); + expect(content).toMatchSnapshot(); + }); + it('should read the patternfly-context resource', async () => { const response = await CLIENT?.send({ method: 'resources/read', diff --git a/tests/e2e/stdioTransport.test.ts b/tests/e2e/stdioTransport.test.ts index c9ad1f2..9ad4c57 100644 --- a/tests/e2e/stdioTransport.test.ts +++ b/tests/e2e/stdioTransport.test.ts @@ -189,15 +189,46 @@ describe('Builtin resources, STDIO', () => { expect(resourceNames).toContain('patternfly://context'); expect(templateNames).toContain('patternfly://components/index'); + expect(templateNames).toContain('patternfly://components/meta'); expect(templateNames).toContain('patternfly://components/index{?version,category}'); expect(templateNames).toContain('patternfly://docs/index'); + expect(templateNames).toContain('patternfly://docs/meta'); expect(templateNames).toContain('patternfly://docs/index{?version,category,section}'); expect(templateNames).toContain('patternfly://docs/{name}{?version,category,section}'); expect(templateNames).toContain('patternfly://schemas/index'); + expect(templateNames).toContain('patternfly://schemas/meta'); expect(templateNames).toContain('patternfly://schemas/index{?version,category}'); expect(templateNames).toContain('patternfly://schemas/{name}{?version,category}'); }); + it.each([ + { + description: 'patternfly-components-meta', + uri: 'patternfly://components/meta', + expected: 'PatternFly Components Index Metadata' + }, + { + description: 'patternfly-docs-meta', + uri: 'patternfly://docs/meta', + expected: 'PatternFly Documentation Index Metadata' + }, + { + description: 'patternfly-schemas-meta', + uri: 'patternfly://schemas/meta', + expected: 'PatternFly Component Schemas Index Metadata' + } + ])('should read meta resources, $description', async ({ uri, expected }) => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe(uri); + expect(content.text).toContain(expected); + expect(content).toMatchSnapshot(); + }); + it('should read the patternfly-context resource', async () => { const response = await CLIENT.send({ method: 'resources/read',