diff --git a/README.md b/README.md index 6aa453d..a442ec2 100644 --- a/README.md +++ b/README.md @@ -49,37 +49,47 @@ npx @patternfly/patternfly-mcp ## Usage -The MCP server can communicate over **stdio** (default) or **HTTP** transport. It provides access to PatternFly documentation through built-in tools. +The MCP server tools are focused on being a resource library for PatternFly. Server tools are extensible by design and intended to be used in conjunction with the available MCP resources. ### Built-in Tools -All tools accept an argument named `urlList` (array of strings) or `componentName` (string). +#### Tool: searchPatternFlyDocs +Use this to search for PatternFly documentation URLs and component names. Accepts partial string matches or `*` to list all available components. From the content, you can select specific URLs and component names to use with `usePatternFlyDocs` + +- **Parameters**: `searchQuery`: `string` (required) #### Tool: usePatternFlyDocs -Use this to fetch high-level index content (for example, a local `README.md` that contains relevant links, or `llms.txt` files in docs-host mode). From that content, you can select specific URLs to pass to `fetchDocs`. +Fetch full documentation and component JSON schemas for specific PatternFly URLs or component names. -- **Parameters**: `urlList`: `string[]` (required) +> **Feature**: This tool automatically detects if a URL belongs to a component (or if a "name" is provided) and appends its machine-readable JSON schema (props, types, validation) to the response, providing a fused context of human-readable docs and technical specs. -#### Tool: fetchDocs -Use this to fetch one or more specific documentation pages (e.g., concrete design guidelines or accessibility pages) after you’ve identified them via `usePatternFlyDocs`. +- **Parameters**: _Parameters are mutually exclusive. Provide either `name` OR `urlList` not both._ + - `name`: `string` (optional) - The name of the PatternFly component (e.g., "Button", "Modal"). **Recommended** for known component lookups. + - `urlList`: `string[]` (optional) - A list of specific documentation URLs discovered via `searchPatternFlyDocs`. -- **Parameters**: `urlList`: `string[]` (required) +#### Removed: ~~Tool: fetchDocs~~ +> "fetchDocs" has been integrated into "usePatternFlyDocs." -#### Tool: componentSchemas -Use this to fetch the JSON Schema for a specific PatternFly component. +~~Use this to fetch one or more specific documentation pages (e.g., concrete design guidelines or accessibility pages) after you’ve identified them via `usePatternFlyDocs`.~~ -- **Parameters**: `componentName`: `string` (required) +- ~~**Parameters**: `urlList`: `string[]` (required)~~ -### Docs-host mode (local llms.txt mode) +#### Deprecated: ~~Tool: componentSchemas~~ +> "componentSchemas" has been integrated into "usePatternFlyDocs." -If you run the server with `--docs-host`, local paths you pass in `urlList` are resolved relative to the `llms-files` folder at the repository root. This is useful when you have pre-curated `llms.txt` files locally. +~~Use this to fetch the JSON Schema for a specific PatternFly component.~~ -Example: -```bash -npx @patternfly/patternfly-mcp --docs-host -``` +- ~~**Parameters**: `componentName`: `string` (required)~~ + +### Built-in Resources + +The server exposes a resource-centric architecture via the `patternfly://` URI scheme: -Then, passing a local path such as `react-core/6.0.0/llms.txt` in `urlList` will load from `llms-files/react-core/6.0.0/llms.txt`. +- **`patternfly://context`**: General PatternFly development context and high-level rules. +- **`patternfly://docs/index`**: Index of all available documentation pages. +- **`patternfly://docs/{name}`**: Documentation for a specific component (e.g., `patternfly://docs/Button`). +- **`patternfly://schemas/index`**: Index of all available component schemas. +- **`patternfly://schemas/{name}`**: JSON Schema for a specific component (e.g., `patternfly://schemas/Button`). ### MCP Client Configuration @@ -111,19 +121,6 @@ Most MCP clients use a JSON configuration to specify how to start this server. B } ``` -#### Docs-host mode -```json -{ - "mcpServers": { - "patternfly-docs": { - "command": "npx", - "args": ["-y", "@patternfly/patternfly-mcp@latest", "--docs-host"], - "description": "PatternFly docs (docs-host mode)" - } - } -} -``` - #### Custom local tool ```json @@ -173,6 +170,30 @@ Example: npx @patternfly/patternfly-mcp --log-stderr --log-level debug ``` +### Disabled: ~~Docs-host mode (local llms.txt mode)~~ + +> Docs-host mode will be removed or replaced in a future release. +> +> Docs-host mode was intended to be a more efficient way for accessing text file versions of PatternFly documentation and link +> resources. That effort was intended to help load times and token counts while attempting to account for future API work. +> Docs-host mode documentation and links have experienced drift with recent updates to PatternFly resources. That drift combined +> with the introduction of MCP server resources concludes in disabling Docs-host mode while we evaluate its removal or replacement. +> +> If you have been using Docs-host mode, there's a probability you've been leveraging model inference instead of PatternFly +> documentation. You can continue passing the `--docs-host` flag, it will not break the CLI, but it will no-longer affect how the +> PatternFly MCP server loads documentation and link resources. + +~~If you run the server with `--docs-host`, local paths you pass in `urlList` are resolved relative to the `llms-files` folder at the repository root. This is useful when you have pre-curated `llms.txt` files locally.~~ + +- `--docs-host`: Running this flag produces no results. ~~Local paths you pass in `urlList` are resolved relative to the `llms-files` folder.~~ + +Example: +```bash +npx @patternfly/patternfly-mcp --docs-host +``` + +~~Then, passing a local path such as `react-core/6.0.0/llms.txt` in `urlList` will load from `llms-files/react-core/6.0.0/llms.txt`.~~ + ### MCP Tool Plugins You can extend the server's capabilities by loading **Tool Plugins** at startup. These plugins run out‑of‑process in an isolated **Tools Host** (Node.js >= 22) to ensure security and stability. diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 0cd3b19..1a7c00f 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -21,6 +21,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "stderr": false, "transport": "stdio", }, + "maxDocsToLoad": 500, "name": "@patternfly/patternfly-mcp", "nodeVersion": 22, "pfExternal": "https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content", @@ -38,6 +39,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "loadTimeoutMs": 5000, }, "pluginIsolation": "strict", + "recommendedMaxDocsToLoad": 15, "repoName": "patternfly-mcp", "resourceMemoOptions": { "default": { @@ -54,6 +56,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "expire": 120000, }, }, + "resourceModules": [], "separator": " --- @@ -66,10 +69,10 @@ exports[`options defaults should return specific properties: defaults 1`] = ` }, }, "toolMemoOptions": { - "fetchDocs": { + "searchPatternFlyDocs": { "cacheErrors": false, - "cacheLimit": 15, - "expire": 60000, + "cacheLimit": 10, + "expire": 600000, }, "usePatternFlyDocs": { "cacheErrors": false, diff --git a/src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap new file mode 100644 index 0000000..e073eb4 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlyContextResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-context", + "uri": "patternfly://context", +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap new file mode 100644 index 0000000..ad9fd09 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlyDocsIndexResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-docs-index", + "uri": "patternfly://docs/index", +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap new file mode 100644 index 0000000..5ed0afb --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlyDocsTemplateResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-docs-template", + "uri": ResourceTemplate { + "_callbacks": { + "list": undefined, + }, + "_uriTemplate": UriTemplate { + "parts": [ + "patternfly://docs/", + { + "exploded": false, + "name": "name", + "names": [ + "name", + ], + "operator": "", + }, + ], + "template": "patternfly://docs/{name}", + }, + }, +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap new file mode 100644 index 0000000..26bd6c3 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlySchemasIndexResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-schemas-index", + "uri": "patternfly://schemas/index", +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlySchemasTemplate.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlySchemasTemplate.test.ts.snap new file mode 100644 index 0000000..529dcd1 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlySchemasTemplate.test.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlySchemasTemplateResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-schemas-template", + "uri": ResourceTemplate { + "_callbacks": { + "list": undefined, + }, + "_uriTemplate": UriTemplate { + "parts": [ + "patternfly://schemas/", + { + "exploded": false, + "name": "name", + "names": [ + "name", + ], + "operator": "", + }, + ], + "template": "patternfly://schemas/{name}", + }, + }, +} +`; diff --git a/src/__tests__/__snapshots__/server.getResources.test.ts.snap b/src/__tests__/__snapshots__/server.getResources.test.ts.snap index 121326a..6f9d23e 100644 --- a/src/__tests__/__snapshots__/server.getResources.test.ts.snap +++ b/src/__tests__/__snapshots__/server.getResources.test.ts.snap @@ -1,51 +1,100 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`processDocsFunction should handle errors gracefully: errors 1`] = ` -"# Documentation from good-file.md - -success content - ---- - -❌ Failed to load bad-file.md: Error: File not found" +[ + { + "content": "success content", + "isSuccess": true, + "path": "good-file.md", + "resolvedPath": "/good-file.md", + }, + { + "content": "❌ Failed to load bad-file.md: File not found", + "isSuccess": false, + "path": "bad-file.md", + "resolvedPath": undefined, + }, +] `; exports[`processDocsFunction should process local and remote inputs, duplicate files and URLS 1`] = ` -"# Documentation from file.md - -local file content - ---- - -# Documentation from https://example.com/remote.md - -fetched content" +[ + { + "content": "local file content", + "isSuccess": true, + "path": "file.md", + "resolvedPath": "/file.md", + }, + { + "content": "fetched content", + "isSuccess": true, + "path": "https://example.com/remote.md", + "resolvedPath": "https://example.com/remote.md", + }, +] `; exports[`processDocsFunction should process local and remote inputs, files and URLS 1`] = ` -"# Documentation from local-file.md - -local file content - ---- - -# Documentation from https://example.com/remote.md - -fetched content" +[ + { + "content": "local file content", + "isSuccess": true, + "path": "local-file.md", + "resolvedPath": "/local-file.md", + }, + { + "content": "fetched content", + "isSuccess": true, + "path": "https://example.com/remote.md", + "resolvedPath": "https://example.com/remote.md", + }, +] `; exports[`processDocsFunction should process local and remote inputs, filter empty strings 1`] = ` -"# Documentation from file.md - -local file content +[ + { + "content": "local file content", + "isSuccess": true, + "path": "file.md", + "resolvedPath": "/file.md", + }, + { + "content": "local file content", + "isSuccess": true, + "path": "file2.md", + "resolvedPath": "/file2.md", + }, +] +`; ---- +exports[`promiseQueue should execute promises in order: allSettled 1`] = ` +[ + { + "status": "fulfilled", + "value": { + "content": "/dolor-sit.md", + "resolvedPath": "/dolor-sit.md", + }, + }, + { + "reason": "https://example.com/remote.md", + "status": "rejected", + }, + { + "status": "fulfilled", + "value": { + "content": "/lorem-ipsum.md", + "resolvedPath": "/lorem-ipsum.md", + }, + }, +] +`; -# Documentation from file2.md +exports[`resolveLocalPathFunction should return a consistent path, basic 1`] = `"/lorem-ipsum.md"`; -local file content" -`; +exports[`resolveLocalPathFunction should return a consistent path, url, file 1`] = `"file://someDirectory/dolor-sit.md"`; -exports[`resolveLocalPathFunction should return a consistent path, with docsHost false 1`] = `"documentation/README.md"`; +exports[`resolveLocalPathFunction should return a consistent path, url, http 1`] = `"http://example.com/dolor-sit.md"`; -exports[`resolveLocalPathFunction should return a consistent path, with docsHost true 1`] = `"/llms-files/react-core/6.0.0/llms.txt"`; +exports[`resolveLocalPathFunction should return a consistent path, url, https 1`] = `"https://example.com/dolor-sit.md"`; diff --git a/src/__tests__/__snapshots__/server.helpers.test.ts.snap b/src/__tests__/__snapshots__/server.helpers.test.ts.snap index 64571f7..ae83ceb 100644 --- a/src/__tests__/__snapshots__/server.helpers.test.ts.snap +++ b/src/__tests__/__snapshots__/server.helpers.test.ts.snap @@ -106,3 +106,29 @@ exports[`mergeObjects should merge two objects, recursive plain object against r }, } `; + +exports[`stringJoin should join values, default 1`] = `"lorem ipsum 0 1 2 3 true false "`; + +exports[`stringJoin should join values, filtered 1`] = `"lorem ipsum 1 2 3 true"`; + +exports[`stringJoin should join values, newline 1`] = ` +"lorem +ipsum +0 +1 +2 +3 +true +false + +" +`; + +exports[`stringJoin should join values, newline filtered 1`] = ` +"lorem +ipsum +1 +2 +3 +true" +`; diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index da84fa2..d933135 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -9,14 +9,32 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -44,14 +62,32 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -79,9 +115,27 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "test-server-4 server running on stdio transport", ], @@ -95,6 +149,7 @@ exports[`runServer should attempt to run server, create transport, connect, and }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -119,9 +174,27 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "test-server-7 server running on stdio transport", ], @@ -135,6 +208,7 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -154,9 +228,27 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "test-server-8 server running on stdio transport", ], @@ -170,6 +262,7 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -194,9 +287,27 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "Registered tool: loremIpsum", ], @@ -219,6 +330,7 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -245,9 +357,27 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "Registered tool: loremIpsum", ], @@ -279,6 +409,7 @@ exports[`runServer should attempt to run server, register multiple tools: diagno }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -306,9 +437,27 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "test-server-3 server running on stdio transport", ], @@ -322,6 +471,7 @@ exports[`runServer should attempt to run server, use custom options: diagnostics }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -346,14 +496,32 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -371,6 +539,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -384,7 +553,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno ], "registerTool": [ "usePatternFlyDocs", - "fetchDocs", + "searchPatternFlyDocs", "componentSchemas", ], } @@ -399,14 +568,32 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], + [ + "Registered resource: patternfly-schemas-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -424,6 +611,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -437,7 +625,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn ], "registerTool": [ "usePatternFlyDocs", - "fetchDocs", + "searchPatternFlyDocs", "componentSchemas", ], } diff --git a/src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap deleted file mode 100644 index 63af756..0000000 --- a/src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap +++ /dev/null @@ -1,75 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`fetchDocsTool should have a consistent return structure: structure 1`] = ` -{ - "callback": [Function], - "name": "fetchDocs", - "schema": true, -} -`; - -exports[`fetchDocsTool, callback should parse parameters, default 1`] = ` -{ - "content": [ - { - "text": "components/button.md", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, multiple files 1`] = ` -{ - "content": [ - { - "text": "combined docs content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with empty files 1`] = ` -{ - "content": [ - { - "text": "trimmed content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with empty strings in a urlList 1`] = ` -{ - "content": [ - { - "text": "trimmed and empty content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with empty urlList 1`] = ` -{ - "content": [ - { - "text": "empty content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with invalid urlList 1`] = ` -{ - "content": [ - { - "text": "invalid path", - "type": "text", - }, - ], -} -`; diff --git a/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap index 204d90b..7a83354 100644 --- a/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap @@ -7,69 +7,3 @@ exports[`usePatternFlyDocsTool should have a consistent return structure: struct "schema": true, } `; - -exports[`usePatternFlyDocsTool, callback should parse parameters, default 1`] = ` -{ - "content": [ - { - "text": "components/button.md", - "type": "text", - }, - ], -} -`; - -exports[`usePatternFlyDocsTool, callback should parse parameters, multiple files 1`] = ` -{ - "content": [ - { - "text": "combined docs content", - "type": "text", - }, - ], -} -`; - -exports[`usePatternFlyDocsTool, callback should parse parameters, with empty files 1`] = ` -{ - "content": [ - { - "text": "trimmed content", - "type": "text", - }, - ], -} -`; - -exports[`usePatternFlyDocsTool, callback should parse parameters, with empty strings in a urlList 1`] = ` -{ - "content": [ - { - "text": "trimmed and empty content", - "type": "text", - }, - ], -} -`; - -exports[`usePatternFlyDocsTool, callback should parse parameters, with empty urlList 1`] = ` -{ - "content": [ - { - "text": "empty content", - "type": "text", - }, - ], -} -`; - -exports[`usePatternFlyDocsTool, callback should parse parameters, with invalid urlList 1`] = ` -{ - "content": [ - { - "text": "invalid path", - "type": "text", - }, - ], -} -`; diff --git a/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap new file mode 100644 index 0000000..02a4620 --- /dev/null +++ b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`searchPatternFlyDocsTool should have a consistent return structure: structure 1`] = ` +{ + "callback": [Function], + "name": "searchPatternFlyDocs", + "schema": true, +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, default: search 1`] = `"# Search results for "Button", 1 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with "*" searchQuery all: search 1`] = `"# Search results for "all components", 8 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with "all" searchQuery all: search 1`] = `"# Search results for "all components", 8 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with empty searchQuery all: search 1`] = `"# Search results for "all components", 8 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with lower case componentName: search 1`] = `"# Search results for "button", 1 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with made up componentName: search 1`] = `"No PatternFly documentation found matching "lorem ipsum dolor sit amet""`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with multiple words: search 1`] = `"No PatternFly documentation found matching "Button Card Table""`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with partial componentName: search 1`] = `"# Search results for "ton", 2 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with trimmed componentName: search 1`] = `"# Search results for " Button ", 1 matches found:"`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with upper case componentName: search 1`] = `"# Search results for "BUTTON", 1 matches found:"`; diff --git a/src/__tests__/options.context.test.ts b/src/__tests__/options.context.test.ts index 123fc08..01854ca 100644 --- a/src/__tests__/options.context.test.ts +++ b/src/__tests__/options.context.test.ts @@ -22,7 +22,7 @@ describe('setOptions', () => { expect(updatedOptions.logging.protocol).toBe(DEFAULT_OPTIONS.logging.protocol); expect(updatedOptions.resourceMemoOptions?.readFile?.expire).toBe(DEFAULT_OPTIONS.resourceMemoOptions?.readFile?.expire); - expect(updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.fetchDocs?.expire); + expect(updatedOptions.toolMemoOptions?.usePatternFlyDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.usePatternFlyDocs?.expire); expect(updatedOptions.pluginIsolation).toBe(DEFAULT_OPTIONS.pluginIsolation); }); @@ -35,8 +35,8 @@ describe('setOptions', () => { expect(typeof updatedOptions.resourceMemoOptions?.readFile?.expire).toBe('number'); expect(updatedOptions.resourceMemoOptions?.readFile?.expire).toBe(DEFAULT_OPTIONS.resourceMemoOptions?.readFile?.expire); - expect(typeof updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe('number'); - expect(updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.fetchDocs?.expire); + expect(typeof updatedOptions.toolMemoOptions?.usePatternFlyDocs?.expire).toBe('number'); + expect(updatedOptions.toolMemoOptions?.usePatternFlyDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.usePatternFlyDocs?.expire); expect(typeof updatedOptions.pluginIsolation).toBe('string'); expect(updatedOptions.pluginIsolation).toBe(DEFAULT_OPTIONS.pluginIsolation); @@ -85,6 +85,7 @@ describe('tool creator options context', () => { // Mock server instance mockServer = { registerTool: jest.fn(), + registerResource: jest.fn(), connect: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) }; diff --git a/src/__tests__/resource.patternFlyContext.test.ts b/src/__tests__/resource.patternFlyContext.test.ts new file mode 100644 index 0000000..3aa93f2 --- /dev/null +++ b/src/__tests__/resource.patternFlyContext.test.ts @@ -0,0 +1,38 @@ +import { patternFlyContextResource } from '../resource.patternFlyContext'; +import { isPlainObject } from '../server.helpers'; + +describe('patternFlyContextResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlyContextResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlyContextResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + args: [] + } + ])('should return context content, $description', async ({ args }) => { + const [_name, _uri, _config, callback] = patternFlyContextResource(); + const result = await callback(...args); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + }); +}); diff --git a/src/__tests__/resource.patternFlyDocsIndex.test.ts b/src/__tests__/resource.patternFlyDocsIndex.test.ts new file mode 100644 index 0000000..c2bd15c --- /dev/null +++ b/src/__tests__/resource.patternFlyDocsIndex.test.ts @@ -0,0 +1,47 @@ +import { patternFlyDocsIndexResource } from '../resource.patternFlyDocsIndex'; +import { getLocalDocs } from '../docs.local'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../docs.local'); + +const mockGetLocalDocs = getLocalDocs as jest.MockedFunction; + +describe('patternFlyDocsIndexResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetLocalDocs.mockReturnValue(['[@patternfly/react-guidelines](./guidelines/README.md)']); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlyDocsIndexResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlyDocsIndexResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetLocalDocs.mockReturnValue(['[@patternfly/react-guidelines](./guidelines/README.md)']); + }); + + it.each([ + { + description: 'default', + args: [] + } + ])('should return context content, $description', async ({ args }) => { + const [_name, _uri, _config, callback] = patternFlyDocsIndexResource(); + const result = await callback(...args); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + expect(result.contents[0].text).toContain('[@patternfly/react-guidelines](./guidelines/README.md)'); + }); +}); diff --git a/src/__tests__/resource.patternFlyDocsTemplate.test.ts b/src/__tests__/resource.patternFlyDocsTemplate.test.ts new file mode 100644 index 0000000..4ba9c44 --- /dev/null +++ b/src/__tests__/resource.patternFlyDocsTemplate.test.ts @@ -0,0 +1,134 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { patternFlyDocsTemplateResource } from '../resource.patternFlyDocsTemplate'; +import { processDocsFunction } from '../server.getResources'; +import { searchComponents } from '../tool.searchPatternFlyDocs'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../server.getResources'); +jest.mock('../tool.searchPatternFlyDocs'); +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); +jest.mock('../options.context', () => ({ + getOptions: jest.fn(() => ({})) +})); + +const mockProcessDocs = processDocsFunction as jest.MockedFunction; +const mockSearchComponents = searchComponents as jest.MockedFunction; + +describe('patternFlyDocsTemplateResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlyDocsTemplateResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlyDocsTemplateResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + name: 'Button', + urls: ['components/button.md'], + result: 'Button documentation content' + }, + { + description: 'with multiple matched URLs', + name: 'Card', + urls: ['components/card.md', 'components/card-examples.md'], + result: 'Card documentation content' + }, + { + description: 'with trimmed name', + name: ' Table ', + urls: ['components/table.md'], + result: 'Table documentation content' + }, + { + description: 'with lower case name', + name: 'button', + urls: ['components/button.md'], + result: 'Button documentation content' + } + ])('should parse parameters and return documentation, $description', async ({ name, urls, result: mockResult }) => { + mockSearchComponents.mockReturnValue({ + isSearchWildCardAll: false, + firstExactMatch: undefined, + exactMatches: [{ urls } as any], + searchResults: [] + }); + mockProcessDocs.mockResolvedValue([{ content: mockResult }] as any); + + const [_name, _uri, _config, callback] = patternFlyDocsTemplateResource(); + const uri = new URL('patternfly://docs/Button'); + const variables = { name }; + const result = await callback(uri, variables); + + expect(mockSearchComponents).toHaveBeenCalledWith(name); + expect(mockProcessDocs).toHaveBeenCalledWith(urls); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + expect(result.contents[0].text).toContain(mockResult); + }); + + it.each([ + { + description: 'with missing or undefined name', + error: 'Missing required parameter: name must be a string', + variables: {} + }, + { + description: 'with null name', + error: 'Missing required parameter: name must be a string', + variables: { name: null } + }, + { + description: 'with empty name', + error: 'Missing required parameter: name must be a string', + variables: { name: '' } + }, + { + description: 'with non-string name', + error: 'Missing required parameter: name must be a string', + variables: { name: 123 } + } + ])('should handle variable errors, $description', async ({ error, variables }) => { + const [_name, _uri, _config, callback] = patternFlyDocsTemplateResource(); + const uri = new URL('patternfly://docs/test'); + + await expect(callback(uri, variables)).rejects.toThrow(McpError); + await expect(callback(uri, variables)).rejects.toThrow(error); + }); + + it('should handle documentation loading errors', async () => { + mockSearchComponents.mockReturnValue({ + isSearchWildCardAll: false, + firstExactMatch: undefined, + exactMatches: [], + searchResults: [] + }); + mockProcessDocs.mockRejectedValue(new Error('File not found')); + + const [_name, _uri, _config, handler] = patternFlyDocsTemplateResource(); + const uri = new URL('patternfly://docs/Button'); + const variables = { name: 'Button' }; + + await expect(handler(uri, variables)).rejects.toThrow(McpError); + await expect(handler(uri, variables)).rejects.toThrow('No documentation found'); + }); +}); diff --git a/src/__tests__/resource.patternFlySchemasIndex.test.ts b/src/__tests__/resource.patternFlySchemasIndex.test.ts new file mode 100644 index 0000000..504efa0 --- /dev/null +++ b/src/__tests__/resource.patternFlySchemasIndex.test.ts @@ -0,0 +1,44 @@ +import { patternFlySchemasIndexResource } from '../resource.patternFlySchemasIndex'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../tool.searchPatternFlyDocs', () => ({ + componentNames: ['Button', 'Card', 'Table'] +})); + +describe('patternFlySchemasIndexResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlySchemasIndexResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlySchemasIndexResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + args: [] + } + ])('should return component schemas index, $description', async ({ args }) => { + const [_name, _uri, _config, callback] = patternFlySchemasIndexResource(); + const result = await callback(...args); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + expect(result.contents[0].text).toContain('# PatternFly Component Names Index'); + }); +}); diff --git a/src/__tests__/resource.patternFlySchemasTemplate.test.ts b/src/__tests__/resource.patternFlySchemasTemplate.test.ts new file mode 100644 index 0000000..8af3aba --- /dev/null +++ b/src/__tests__/resource.patternFlySchemasTemplate.test.ts @@ -0,0 +1,104 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { getComponentSchema } from '../tool.patternFlyDocs'; +import { patternFlySchemasTemplateResource } from '../resource.patternFlySchemasTemplate'; +import { searchComponents } from '../tool.searchPatternFlyDocs'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../tool.searchPatternFlyDocs'); +jest.mock('../tool.patternFlyDocs'); +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); +jest.mock('../options.context', () => ({ + getOptions: jest.fn(() => ({})) +})); + +const mockGetComponentSchema = getComponentSchema as jest.MockedFunction; +const mockSearchComponents = searchComponents as jest.MockedFunction; + +describe('patternFlySchemasTemplateResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlySchemasTemplateResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlySchemasTemplateResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with missing or undefined name', + error: 'Missing required parameter: name must be a string', + variables: {} + }, + { + description: 'with null name', + error: 'Missing required parameter: name must be a string', + variables: { name: null } + }, + { + description: 'with empty name', + error: 'Missing required parameter: name must be a string', + variables: { name: '' } + }, + { + description: 'with non-string name', + error: 'Missing required parameter: name must be a string', + variables: { name: 123 } + } + ])('should handle variable errors, $description', async ({ error, variables }) => { + const [_name, _uri, _config, callback] = patternFlySchemasTemplateResource(); + const uri = new URL('patternfly://schemas/test'); + + await expect(callback(uri, variables)).rejects.toThrow(McpError); + await expect(callback(uri, variables)).rejects.toThrow(error); + }); + + it('should handle missing exact match and missing schema errors', async () => { + mockSearchComponents.mockReturnValue({ + isSearchWildCardAll: false, + firstExactMatch: undefined, + exactMatches: [], + searchResults: [] + }); + mockGetComponentSchema.mockReturnValue(undefined as any); + + const [_name, _uri, _config, handler] = patternFlySchemasTemplateResource(); + const uri = new URL('patternfly://schemas/DolorSitAmet'); + const variables = { name: 'DolorSitAmet' }; + + await expect(handler(uri, variables)).rejects.toThrow(McpError); + await expect(handler(uri, variables)).rejects.toThrow('Component "DolorSitAmet" not found'); + }); + + it('should handle exact match but missing schema errors', async () => { + mockSearchComponents.mockReturnValue({ + isSearchWildCardAll: false, + firstExactMatch: undefined, + exactMatches: [{ item: 'Button', urls: [] } as any], + searchResults: [] + }); + mockGetComponentSchema.mockReturnValue(undefined as any); + + const [_name, _uri, _config, handler] = patternFlySchemasTemplateResource(); + const uri = new URL('patternfly://schemas/DolorSitAmet'); + const variables = { name: 'Button' }; + + await expect(handler(uri, variables)).rejects.toThrow(McpError); + await expect(handler(uri, variables)).rejects.toThrow('Component "Button" found'); + }); +}); diff --git a/src/__tests__/server.getResources.test.ts b/src/__tests__/server.getResources.test.ts index 188f9c1..0c0c9f7 100644 --- a/src/__tests__/server.getResources.test.ts +++ b/src/__tests__/server.getResources.test.ts @@ -1,5 +1,12 @@ import { readFile } from 'node:fs/promises'; -import { readLocalFileFunction, fetchUrlFunction, resolveLocalPathFunction, processDocsFunction } from '../server.getResources'; +import { + readLocalFileFunction, + fetchUrlFunction, + processDocsFunction, + promiseQueue, + loadFileFetch, + resolveLocalPathFunction +} from '../server.getResources'; import { type GlobalOptions } from '../options'; // Mock dependencies @@ -82,28 +89,77 @@ describe('fetchUrlFunction', () => { describe('resolveLocalPathFunction', () => { it.each([ { - description: 'with docsHost true', - options: { - docsHost: true, - llmsFilesPath: '/llms-files' - }, - path: 'react-core/6.0.0/llms.txt' + description: 'basic', + path: 'lorem-ipsum.md' }, { - description: 'with docsHost false', - options: { - docsHost: false, - llmsFilesPath: '/llms-files' - }, - path: 'documentation/README.md' + description: 'url, http', + path: 'http://example.com/dolor-sit.md' + }, + { + description: 'url, https', + path: 'https://example.com/dolor-sit.md' + }, + { + description: 'url, file', + path: 'file://someDirectory/dolor-sit.md' } - ])('should return a consistent path, $description', ({ path, options }) => { - const result = resolveLocalPathFunction(path, options as GlobalOptions); + ])('should return a consistent path, $description', ({ path }) => { + const result = resolveLocalPathFunction(path); expect(result).toMatchSnapshot(); }); }); +describe('loadFileFetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with local file', + pathUrl: 'dolor-sit.md', + expectedIsFetch: false + }, + { + description: 'with remote URL', + pathUrl: 'https://example.com/remote.md', + expectedIsFetch: true + } + ])('should attempt to load a file or fetch, $description', async ({ pathUrl, expectedIsFetch }) => { + const mockFetchCall = jest.fn().mockResolvedValue('content'); + const mockReadCall = jest.fn().mockResolvedValue('content'); + + readLocalFileFunction.memo = mockReadCall; + fetchUrlFunction.memo = mockFetchCall; + + const result = await loadFileFetch(pathUrl); + + expect(mockFetchCall).toHaveBeenCalledTimes(expectedIsFetch ? 1 : 0); + expect(mockReadCall).toHaveBeenCalledTimes(expectedIsFetch ? 0 : 1); + expect(result).toEqual({ + content: 'content', + resolvedPath: expect.any(String) + }); + }); +}); + +describe('promiseQueue', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should execute promises in order', async () => { + readLocalFileFunction.memo = jest.fn().mockImplementation(path => Promise.resolve(path)); + fetchUrlFunction.memo = jest.fn().mockImplementation(url => Promise.reject(url)); + + const pathUrlQueue = ['dolor-sit.md', 'https://example.com/remote.md', 'lorem-ipsum.md']; + + await expect(promiseQueue(pathUrlQueue, 1)).resolves.toMatchSnapshot('allSettled'); + }); +}); + describe('processDocsFunction', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index 1720b08..2c3b27a 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -2,11 +2,14 @@ import { freezeObject, generateHash, hashCode, + isAsync, isPlainObject, isPromise, isReferenceLike, mergeObjects, - portValid + portValid, + stringJoin, + timeoutFunction } from '../server.helpers'; describe('freezeObject', () => { @@ -365,6 +368,28 @@ describe('hashCode', () => { }); }); +describe('isAsync', () => { + it.each([ + { + description: 'Promise.resolve', + param: Promise.resolve(), + value: false + }, + { + description: 'async function', + param: async () => {}, + value: true + }, + { + description: 'non-promise', + param: () => 'lorem', + value: false + } + ])('should determine an async function for $description', ({ param, value }) => { + expect(isAsync(param)).toBe(value); + }); +}); + describe('isPromise', () => { it.each([ { @@ -674,3 +699,97 @@ describe('portValid', () => { expect(portValid(port)).toBe(expected); }); }); + +describe('stringJoin', () => { + it('should have expected properties', () => { + expect(stringJoin.basic).toBeDefined(); + expect(stringJoin.newline).toBeDefined(); + expect(stringJoin.filtered).toBeDefined(); + expect(stringJoin.newlineFiltered).toBeDefined(); + }); + + it.each([ + { + description: 'default', + args: ['lorem', 'ipsum', 0, 1, 2, 3, true, false, null, undefined], + settings: {} + }, + { + description: 'newline', + args: ['lorem', 'ipsum', 0, 1, 2, 3, true, false, null, undefined], + settings: { sep: '\n' } + }, + { + description: 'filtered', + args: ['lorem', 'ipsum', 0, 1, 2, 3, true, false, null, undefined], + settings: { filterFalsyValues: true } + }, + { + description: 'newline filtered', + args: ['lorem', 'ipsum', 0, 1, 2, 3, true, false, null, undefined], + settings: { sep: '\n', filterFalsyValues: true } + } + ])('should join values, $description', ({ args, settings }) => { + expect(stringJoin(args, settings)).toMatchSnapshot(); + }); +}); + +describe('timeoutFunction', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it.each([ + { + description: 'non-promise', + func: () => 'lorem ipsum', + expected: 'lorem ipsum' + }, + { + description: 'async function', + func: async () => 'dolor sit', + expected: 'dolor sit' + }, + { + description: 'promise', + func: Promise.resolve('lorem ipsum'), + expected: 'lorem ipsum' + }, + { + description: 'returned async promise', + func: async () => Promise.resolve('dolor ipsum'), + expected: 'dolor ipsum' + }, + { + description: 'returned promise', + func: () => Promise.resolve('lorem amet'), + expected: 'lorem amet' + } + ])('should timeout a function, $description', async ({ func, expected }) => { + await expect(timeoutFunction(func)).resolves.toBe(expected); + }); + + it.each([ + { + description: 'promise', + func: Promise.reject(new Error('lorem ipsum')), + expected: 'lorem ipsum' + }, + { + description: 'returned async promise', + func: async () => Promise.reject(new Error('dolor ipsum')), + expected: 'dolor ipsum' + }, + { + description: 'returned promise', + func: () => Promise.reject(new Error('lorem amet')), + expected: 'lorem amet' + } + ])('should timeout a function, $description', async ({ func, expected }) => { + await expect(timeoutFunction(func)).rejects.toThrow(expected); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 1437f2f..9977f9f 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -34,6 +34,7 @@ describe('runServer', () => { // Mock server instance mockServer = { registerTool: jest.fn(), + registerResource: jest.fn(), connect: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) }; diff --git a/src/__tests__/tool.fetchDocs.test.ts b/src/__tests__/tool.fetchDocs.test.ts deleted file mode 100644 index c568614..0000000 --- a/src/__tests__/tool.fetchDocs.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { McpError } from '@modelcontextprotocol/sdk/types.js'; -import { fetchDocsTool } from '../tool.fetchDocs'; -import { processDocsFunction } from '../server.getResources'; -import { isPlainObject } from '../server.helpers'; - -// Mock dependencies -jest.mock('../server.getResources'); -jest.mock('../server.caching', () => ({ - memo: jest.fn(fn => fn) -})); - -const mockProcessDocs = processDocsFunction as jest.MockedFunction; - -describe('fetchDocsTool', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should have a consistent return structure', () => { - const tool = fetchDocsTool(); - - expect({ - name: tool[0], - schema: isPlainObject(tool[1]), - callback: tool[2] - }).toMatchSnapshot('structure'); - }); -}); - -describe('fetchDocsTool, callback', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it.each([ - { - description: 'default', - value: 'components/button.md', - urlList: ['components/button.md'] - }, - { - description: 'multiple files', - value: 'combined docs content', - urlList: ['components/button.md', 'components/card.md', 'components/table.md'] - }, - { - description: 'with empty files', - value: 'trimmed content', - urlList: ['components/button.md', '', ' ', 'components/card.md', 'components/table.md'] - }, - { - description: 'with empty urlList', - value: 'empty content', - urlList: [] - }, - { - description: 'with empty strings in a urlList', - value: 'trimmed and empty content', - urlList: ['', ' '] - }, - { - description: 'with invalid urlList', - value: 'invalid path', - urlList: ['invalid-url'] - } - ])('should parse parameters, $description', async ({ value, urlList }) => { - mockProcessDocs.mockResolvedValue(value); - const [_name, _schema, callback] = fetchDocsTool(); - const result = await callback({ urlList }); - - expect(mockProcessDocs).toHaveBeenCalledWith(urlList); - expect(result).toMatchSnapshot(); - }); - - it.each([ - { - description: 'with missing or undefined urlList', - error: 'Missing required parameter: urlList', - urlList: undefined - }, - { - description: 'with null urlList', - error: 'Missing required parameter: urlList', - urlList: null - }, - { - description: 'when urlList is not an array', - error: 'must be an array of strings', - urlList: 'not-an-array' - } - ])('should handle errors, $description', async ({ error, urlList }) => { - const [_name, _schema, callback] = fetchDocsTool(); - - await expect(callback({ urlList })).rejects.toThrow(McpError); - await expect(callback({ urlList })).rejects.toThrow(error); - }); - - it('should handle processing errors', async () => { - mockProcessDocs.mockRejectedValue(new Error('Network error')); - const [_name, _schema, callback] = fetchDocsTool(); - - await expect(callback({ urlList: ['missing.md'] })).rejects.toThrow(McpError); - await expect(callback({ urlList: ['missing.md'] })).rejects.toThrow('Failed to fetch documentation'); - }); -}); diff --git a/src/__tests__/tool.patternFlyDocs.test.ts b/src/__tests__/tool.patternFlyDocs.test.ts index 49ac65b..d73523a 100644 --- a/src/__tests__/tool.patternFlyDocs.test.ts +++ b/src/__tests__/tool.patternFlyDocs.test.ts @@ -43,56 +43,68 @@ describe('usePatternFlyDocsTool, callback', () => { value: 'combined docs content', urlList: ['components/button.md', 'components/card.md', 'components/table.md'] }, - { - description: 'with empty files', - value: 'trimmed content', - urlList: ['components/button.md', '', ' ', 'components/card.md', 'components/table.md'] - }, - { - description: 'with empty urlList', - value: 'empty content', - urlList: [] - }, - { - description: 'with empty strings in a urlList', - value: 'trimmed and empty content', - urlList: ['', ' '] - }, { description: 'with invalid urlList', value: 'invalid path', urlList: ['invalid-url'] + }, + { + description: 'with name', + value: 'button content', + name: 'button' } - ])('should parse parameters, $description', async ({ value, urlList }) => { - mockProcessDocs.mockResolvedValue(value); + ])('should parse parameters, $description', async ({ value, urlList, name }) => { + mockProcessDocs.mockResolvedValue([{ content: value }] as any); const [_name, _schema, callback] = usePatternFlyDocsTool(); - const result = await callback({ urlList }); + const result = await callback({ urlList, name }); - expect(mockProcessDocs).toHaveBeenCalledWith(urlList); - expect(result).toMatchSnapshot(); + expect(mockProcessDocs).toHaveBeenCalledTimes(1); + expect(result.content[0].text).toBeDefined(); + expect(result.content[0].text.startsWith('# Documentation from')).toBe(true); }); it.each([ { description: 'with missing or undefined urlList', - error: 'Missing required parameter: urlList', + error: 'Provide either a string', urlList: undefined }, { description: 'with null urlList', - error: 'Missing required parameter: urlList', + error: 'Provide either a string', urlList: null }, { description: 'when urlList is not an array', - error: 'must be an array of strings', + error: 'Provide either a string', urlList: 'not-an-array' + }, + { + description: 'with empty files', + error: 'Provide either a string', + urlList: ['components/button.md', '', ' ', 'components/card.md', 'components/table.md'] + }, + { + description: 'with empty urlList', + error: 'Provide either a string', + urlList: [] + }, + { + description: 'with empty strings in a urlList', + error: 'Provide either a string', + urlList: ['', ' '] + }, + { + description: 'with both urlList and name', + error: 'Provide either a string', + urlList: ['components/button.md'], + name: 'lorem ipsum' } - ])('should handle errors, $description', async ({ error, urlList }) => { + ])('should handle errors, $description', async ({ error, urlList, name }) => { const [_name, _schema, callback] = usePatternFlyDocsTool(); - await expect(callback({ urlList })).rejects.toThrow(McpError); - await expect(callback({ urlList })).rejects.toThrow(error); + await expect(callback({ urlList, name })).rejects.toThrow(McpError); + await expect(callback({ urlList, name })).rejects.toThrow(error); }); it('should handle processing errors', async () => { diff --git a/src/__tests__/tool.searchPatternFlyDocs.test.ts b/src/__tests__/tool.searchPatternFlyDocs.test.ts new file mode 100644 index 0000000..a4c2e1e --- /dev/null +++ b/src/__tests__/tool.searchPatternFlyDocs.test.ts @@ -0,0 +1,101 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { searchPatternFlyDocsTool } from '../tool.searchPatternFlyDocs'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); + +describe('searchPatternFlyDocsTool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const tool = searchPatternFlyDocsTool(); + + expect({ + name: tool[0], + schema: isPlainObject(tool[1]), + callback: tool[2] + }).toMatchSnapshot('structure'); + }); +}); + +describe('searchPatternFlyDocsTool, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + searchQuery: 'Button' + }, + { + description: 'with trimmed componentName', + searchQuery: ' Button ' + }, + { + description: 'with lower case componentName', + searchQuery: 'button' + }, + { + description: 'with upper case componentName', + searchQuery: 'BUTTON' + }, + { + description: 'with partial componentName', + searchQuery: 'ton' + }, + { + description: 'with multiple words', + searchQuery: 'Button Card Table' + }, + { + description: 'with made up componentName', + searchQuery: 'lorem ipsum dolor sit amet' + }, + { + description: 'with "*" searchQuery all', + searchQuery: '*' + }, + { + description: 'with "all" searchQuery all', + searchQuery: 'ALL' + }, + { + description: 'with empty searchQuery all', + searchQuery: '' + } + ])('should parse parameters, $description', async ({ searchQuery }) => { + const [_name, _schema, callback] = searchPatternFlyDocsTool(); + const result = await callback({ searchQuery }); + + expect(result.content[0].text.split('\n')[0]).toMatchSnapshot('search'); + }); + + it.each([ + { + description: 'with missing or undefined searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: undefined + }, + { + description: 'with null searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: null + }, + { + description: 'with non-string searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: 123 + } + ])('should handle errors, $description', async ({ error, searchQuery }) => { + const [_name, _schema, callback] = searchPatternFlyDocsTool(); + + await expect(callback({ searchQuery })).rejects.toThrow(McpError); + await expect(callback({ searchQuery })).rejects.toThrow(error); + }); +}); diff --git a/src/options.defaults.ts b/src/options.defaults.ts index fb84e90..dcddfb1 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -11,12 +11,14 @@ import { type ToolModule } from './server.toolsUser'; * @template TLogOptions The logging options type, defaulting to LoggingOptions. * @property contextPath - Current working directory. * @property contextUrl - Current working directory URL. - * @property docsHost - Flag indicating whether to use the docs-host. + * @property docsHost - `@DISABLED` This flag no longer produces a result. * @property docsPath - Path to the documentation directory. * @property isHttp - Flag indicating whether the server is running in HTTP mode. * @property {HttpOptions} http - HTTP server options. * @property llmsFilesPath - Path to the LLMs files directory. * @property {LoggingOptions} logging - Logging options. + * @property maxDocsToLoad - Maximum number of docs to load. + * @property recommendedMaxDocsToLoad - Recommended maximum number of docs to load. * @property name - Name of the package. * @property nodeVersion - Node.js major version. * @property pluginIsolation - Isolation preset for external plugins. @@ -43,12 +45,14 @@ import { type ToolModule } from './server.toolsUser'; interface DefaultOptions { contextPath: string; contextUrl: string; - docsHost: boolean; + docsHost?: boolean; docsPath: string; http: HttpOptions; isHttp: boolean; llmsFilesPath: string; logging: TLogOptions; + maxDocsToLoad: number; + recommendedMaxDocsToLoad: number; name: string; nodeVersion: number; pluginIsolation: 'none' | 'strict'; @@ -64,6 +68,7 @@ interface DefaultOptions { pfExternalAccessibility: string; repoName: string | undefined; resourceMemoOptions: Partial; + resourceModules: unknown | unknown[]; separator: string; stats: StatsOptions; toolMemoOptions: Partial; @@ -239,9 +244,9 @@ const TOOL_MEMO_OPTIONS = { expire: 1 * 60 * 1000, // 1 minute sliding cache cacheErrors: false }, - fetchDocs: { - cacheLimit: 15, - expire: 1 * 60 * 1000, // 1 minute sliding cache + searchPatternFlyDocs: { + cacheLimit: 10, + expire: 10 * 60 * 1000, // 10 minute sliding cache cacheErrors: false } }; @@ -348,6 +353,9 @@ const getNodeMajorVersion = (nodeVersion = process.versions.node) => { /** * Global default options. Base defaults before CLI/programmatic overrides. * + * @note `maxDocsToLoad` and `recommendedMaxDocsToLoad` should be generated from the length + * of doc-link resources once we migrate over to a new docs structure. + * * @type {DefaultOptions} Default options object. */ const DEFAULT_OPTIONS: DefaultOptions = { @@ -359,6 +367,8 @@ const DEFAULT_OPTIONS: DefaultOptions = { http: HTTP_OPTIONS, llmsFilesPath: (process.env.NODE_ENV === 'local' && '/llms-files') || join(resolve(process.cwd()), 'llms-files'), logging: LOGGING_OPTIONS, + maxDocsToLoad: 500, + recommendedMaxDocsToLoad: 15, name: packageJson.name, nodeVersion: (process.env.NODE_ENV === 'local' && 22) || getNodeMajorVersion(), pluginIsolation: 'strict', @@ -375,6 +385,7 @@ const DEFAULT_OPTIONS: DefaultOptions = { resourceMemoOptions: RESOURCE_MEMO_OPTIONS, repoName: basename(process.cwd() || '').trim(), stats: STATS_OPTIONS, + resourceModules: [], toolMemoOptions: TOOL_MEMO_OPTIONS, toolModules: [], separator: DEFAULT_SEPARATOR, diff --git a/src/resource.patternFlyContext.ts b/src/resource.patternFlyContext.ts new file mode 100644 index 0000000..fd306e4 --- /dev/null +++ b/src/resource.patternFlyContext.ts @@ -0,0 +1,58 @@ +import { type McpResource } from './server'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-context'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://context'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Design System Context', + description: 'Information about PatternFly design system and how to use this MCP server', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for context. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlyContextResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async () => { + const context = `PatternFly is an open-source design system for building consistent, accessible user interfaces. + +**What is PatternFly?** +PatternFly provides React components, design guidelines, and development tools for creating enterprise applications. It is used by Red Hat and other organizations to build consistent UIs with reusable components. + +**Key Features:** +- React component library with TypeScript support +- Design guidelines and accessibility standards +- JSON Schema validation for component props +- Comprehensive documentation and examples + +**PatternFly MCP Server:** +This MCP server provides tools to access PatternFly documentation, component schemas, and design guidelines. Use the available tools to fetch documentation, search for component information, and retrieve component prop definitions.`; + + return { + contents: [ + { + uri: 'patternfly://context', + mimeType: 'text/markdown', + text: context + } + ] + }; + } +]; + +export { patternFlyContextResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlyDocsIndex.ts b/src/resource.patternFlyDocsIndex.ts new file mode 100644 index 0000000..b212cf9 --- /dev/null +++ b/src/resource.patternFlyDocsIndex.ts @@ -0,0 +1,65 @@ +import { COMPONENT_DOCS } from './docs.component'; +import { LAYOUT_DOCS } from './docs.layout'; +import { CHART_DOCS } from './docs.chart'; +import { getLocalDocs } from './docs.local'; +import { type McpResource } from './server'; +import { stringJoin } from './server.helpers'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-docs-index'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://docs/index'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Documentation Index', + description: 'A comprehensive list of PatternFly documentation links, organized by components, layouts, charts, and local files.', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for the documentation index. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlyDocsIndexResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async () => { + const allDocs = stringJoin.newline( + '# PatternFly Documentation Index', + '', + '## Components', + ...COMPONENT_DOCS, + '', + '## Layouts', + ...LAYOUT_DOCS, + '', + '## Charts', + ...CHART_DOCS, + '', + '## Local Documentation', + ...getLocalDocs() + ); + + return { + contents: [ + { + uri: 'patternfly://docs/index', + mimeType: 'text/markdown', + text: allDocs + } + ] + }; + } +]; + +export { patternFlyDocsIndexResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts new file mode 100644 index 0000000..171a0e8 --- /dev/null +++ b/src/resource.patternFlyDocsTemplate.ts @@ -0,0 +1,112 @@ +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { type McpResource } from './server'; +import { processDocsFunction } from './server.getResources'; +import { searchComponents } from './tool.searchPatternFlyDocs'; +import { getOptions } from './options.context'; +import { memo } from './server.caching'; +import { stringJoin } from './server.helpers'; + +/** + * Name of the resource template. + */ +const NAME = 'patternfly-docs-template'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = new ResourceTemplate('patternfly://docs/{name}', { list: undefined }); + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Documentation Page', + description: 'Retrieve specific PatternFly documentation by name or path', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for the documentation template. + * + * @param options - Global options + * @returns {McpResource} The resource definition tuple + */ +const patternFlyDocsTemplateResource = (options = getOptions()): McpResource => { + const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.usePatternFlyDocs); + + return [ + NAME, + URI_TEMPLATE, + CONFIG, + async (uri: URL, variables: Record) => { + const { name } = variables || {}; + + if (!name || typeof name !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: name must be a string: ${name}` + ); + } + + const docResults = []; + const docs = []; + const { exactMatches, searchResults } = searchComponents.memo(name); + + if (exactMatches.length === 0 || exactMatches.every(match => match.urls.length === 0)) { + const suggestions = searchResults.map(searchResult => searchResult.item).slice(0, 3); + const suggestionMessage = suggestions.length + ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` + : 'No similar components found.'; + + throw new McpError( + ErrorCode.InvalidParams, + `No documentation found for component "${name.trim()}". ${suggestionMessage}` + ); + } + + try { + const exactMatchesUrls = exactMatches.flatMap(match => match.urls); + + if (exactMatchesUrls.length > 0) { + const processedDocs = await memoProcess(exactMatchesUrls); + + docs.push(...processedDocs); + } + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Failed to fetch documentation: ${error}` + ); + } + + // Redundancy check, technically this should never happen, future proofing + if (docs.length === 0) { + throw new McpError( + ErrorCode.InvalidParams, + `Component "${name.trim()}" was found, but no documentation URLs are available for it.` + ); + } + + for (const doc of docs) { + docResults.push(stringJoin.newline( + `# Documentation from ${doc.resolvedPath || doc.path}`, + '', + doc.content + )); + } + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: docResults.join(options.separator) + } + ] + }; + } + ]; +}; + +export { patternFlyDocsTemplateResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlySchemasIndex.ts b/src/resource.patternFlySchemasIndex.ts new file mode 100644 index 0000000..bbb8ec0 --- /dev/null +++ b/src/resource.patternFlySchemasIndex.ts @@ -0,0 +1,47 @@ +import { componentNames } from '@patternfly/patternfly-component-schemas/json'; +import { type McpResource } from './server'; +import { stringJoin } from './server.helpers'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-schemas-index'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://schemas/index'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Component Schemas Index', + description: 'A list of all PatternFly component names available for JSON Schema retrieval', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for the component schemas index. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlySchemasIndexResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async () => ({ + contents: [{ + uri: 'patternfly://schemas/index', + mimeType: 'text/markdown', + text: stringJoin.newline( + '# PatternFly Component Names Index', + '', + '', + ...componentNames + ) + }] + }) +]; + +export { patternFlySchemasIndexResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlySchemasTemplate.ts b/src/resource.patternFlySchemasTemplate.ts new file mode 100644 index 0000000..350e7cf --- /dev/null +++ b/src/resource.patternFlySchemasTemplate.ts @@ -0,0 +1,90 @@ +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { componentNames as pfComponentNames } from '@patternfly/patternfly-component-schemas/json'; +import { type McpResource } from './server'; +import { getComponentSchema } from './tool.patternFlyDocs'; +import { searchComponents } from './tool.searchPatternFlyDocs'; + +/** + * Derive the component schema type from @patternfly/patternfly-component-schemas + */ +type ComponentSchema = Awaited>; + +/** + * Name of the resource template. + */ +const NAME = 'patternfly-schemas-template'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = new ResourceTemplate('patternfly://schemas/{name}', { list: undefined }); + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Component Schema', + description: 'Retrieve the JSON Schema for a specific PatternFly component by name', + mimeType: 'application/json' +}; + +/** + * Resource creator for the component schemas template. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlySchemasTemplateResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async (uri: URL, variables: Record) => { + const { name } = variables || {}; + + if (!name || typeof name !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: name must be a string: ${name}` + ); + } + + const { exactMatches, searchResults } = searchComponents.memo(name, { names: pfComponentNames }); + let result: ComponentSchema | undefined = undefined; + + if (exactMatches.length > 0) { + for (const match of exactMatches) { + const schema = await getComponentSchema.memo(match.item); + + if (schema) { + result = schema; + break; + } + } + } + + if (result === undefined) { + const suggestions = searchResults.map(searchResult => searchResult.item).slice(0, 3); + const suggestionMessage = suggestions.length + ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` + : 'No similar components found.'; + const foundNotFound = exactMatches.length ? 'found but JSON schema not available.' : 'not found.'; + + throw new McpError( + ErrorCode.InvalidParams, + `Component "${name.trim()}" ${foundNotFound} ${suggestionMessage}` + ); + } + + return { + contents: [ + { + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(result, null, 2) + } + ] + }; + } +]; + +export { patternFlySchemasTemplateResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/server.getResources.ts b/src/server.getResources.ts index 419d993..74cee03 100644 --- a/src/server.getResources.ts +++ b/src/server.getResources.ts @@ -1,8 +1,10 @@ import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { isAbsolute, normalize, resolve } from 'node:path'; import { getOptions } from './options.context'; import { DEFAULT_OPTIONS } from './options.defaults'; import { memo } from './server.caching'; +import { normalizeString } from './server.search'; +import { isUrl } from './server.helpers'; /** * Read a local file and return its contents as a string @@ -19,6 +21,8 @@ readLocalFileFunction.memo = memo(readLocalFileFunction, DEFAULT_OPTIONS.resourc /** * Fetch content from a URL with timeout and error handling * + * @note Review expanding fetch to handle more file types like JSON. + * * @param url */ const fetchUrlFunction = async (url: string) => { @@ -51,71 +55,125 @@ const fetchUrlFunction = async (url: string) => { fetchUrlFunction.memo = memo(fetchUrlFunction, DEFAULT_OPTIONS.resourceMemoOptions.fetchUrl); /** - * Resolve a local path depending on docs host flag + * Resolve a local path against a base directory. + * Ensures the resolved path stays within the intended base for security. * - * @param relativeOrAbsolute - * @param options + * @param path - Path to resolve. If it's relative, it will be resolved against the base directory.' + * @param options - Options */ -const resolveLocalPathFunction = (relativeOrAbsolute: string, options = getOptions()) => { - const useHost = Boolean(options?.docsHost); - const base = options?.llmsFilesPath; +const resolveLocalPathFunction = (path: string, options = getOptions()) => { + if (isUrl(path)) { + return path; + } + + const base = options.contextPath; + const resolved = isAbsolute(path) ? normalize(path) : resolve(base, path); + + // Safety check: ensure the resolved path actually starts with the base directory + if (!resolved.startsWith(normalize(base))) { + throw new Error(`Access denied: path ${path} is outside of base directory ${base}`); + } - return (useHost && join(base, relativeOrAbsolute)) || relativeOrAbsolute; + return resolved; }; /** - * Normalize inputs, load all in parallel, and return a joined string. + * Load a file from disk or `URL`, depending on the input type. * - * @param inputs - * @param options + * @param pathOrUrl - Path or URL to load. If it's a URL, it will be fetched with `timeout` and `error` handling. */ -const processDocsFunction = async ( - inputs: string[], - options = getOptions() -) => { - const seen = new Set(); - const list = inputs - .map(str => String(str).trim()) - .filter(Boolean) - .filter(str => { - if (seen.has(str)) { - return false; - } - seen.add(str); - - return true; +const loadFileFetch = async (pathOrUrl: string) => { + const isUrlStr = isUrl(pathOrUrl); + const updatedPathOrUrl = (isUrlStr && pathOrUrl) || resolveLocalPathFunction(pathOrUrl); + let content; + + if (isUrlStr) { + content = await fetchUrlFunction.memo(updatedPathOrUrl); + } else { + content = await readLocalFileFunction.memo(updatedPathOrUrl); + } + + return { content, resolvedPath: updatedPathOrUrl }; +}; + +/** + * Promise queue for `loadFileFetch`. Limit the number of concurrent promises. + * + * @param queue - List of paths or URLs to load + * @param limit - Optional limit on the number of concurrent promises. Defaults to 5. + */ +const promiseQueue = async (queue: string[], limit = 5) => { + const results = []; + const slidingQueue = new Set(); + let activeCount = 0; + + for (const item of queue) { + // Use a sliding window to limit the number of concurrent promises. + const promise = loadFileFetch(item).finally(() => { + slidingQueue.delete(promise); + activeCount -= 1; }); - const loadOne = async (pathOrUrl: string) => { - const isUrl = options.urlRegex.test(pathOrUrl); - const updatedPathOrUrl = (isUrl && pathOrUrl) || resolveLocalPathFunction(pathOrUrl); - let content; + results.push(promise); + slidingQueue.add(promise); + activeCount += 1; - if (isUrl) { - content = await fetchUrlFunction.memo(updatedPathOrUrl); - } else { - content = await readLocalFileFunction.memo(updatedPathOrUrl); + if (activeCount >= limit) { + // Silent fail if one promise fails to load, but keep processing the rest. + await Promise.race(slidingQueue).catch(() => {}); } + } - return { header: `# Documentation from ${updatedPathOrUrl}`, content }; - }; + return Promise.allSettled(results); +}; - const settled = await Promise.allSettled(list.map(item => loadOne(item))); - const parts: string[] = []; +/** + * Normalize inputs, load all in parallel, and return a joined string. + * + * @note Remember to limit the number of docs to load to avoid OOM. + * @param inputs - List of paths or URLs to load + * @param options - Optional options + */ +const processDocsFunction = async ( + inputs: string[], + options = getOptions() +) => { + const uniqueInputs = new Map( + inputs.map(input => [normalizeString.memo(input), input.trim()]) + ); + const list = Array.from(uniqueInputs.values()).slice(0, options.maxDocsToLoad).filter(Boolean); + + const settled = await promiseQueue(list); + const docs: { content: string, path: string | undefined, resolvedPath: string | undefined, isSuccess: boolean }[] = []; settled.forEach((res, index) => { const original = list[index]; + let content; + let resolvedPath; + const path = original; + let isSuccess = false; if (res.status === 'fulfilled') { - const { header, content } = res.value; + const { resolvedPath: docResolvedPath, content: docContent } = res.value; - parts.push(`${header}\n\n${content}`); + resolvedPath = docResolvedPath; + content = docContent; + isSuccess = true; } else { - parts.push(`❌ Failed to load ${original}: ${res.reason}`); + const errorMessage = res.reason instanceof Error ? res.reason.message : String(res.reason); + + content = `❌ Failed to load ${original}: ${errorMessage}`; } + + docs.push({ + content, + path, + resolvedPath, + isSuccess + }); }); - return parts.join(options.separator); + return docs; }; -export { readLocalFileFunction, fetchUrlFunction, resolveLocalPathFunction, processDocsFunction }; +export { fetchUrlFunction, loadFileFetch, processDocsFunction, promiseQueue, readLocalFileFunction, resolveLocalPathFunction }; diff --git a/src/server.helpers.ts b/src/server.helpers.ts index 640c79a..bb4da03 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -163,6 +163,13 @@ const freezeObject = (obj: TBase, _seen?: WeakSet): TBase => { return obj; }; +/** + * Check if "is an Async function". + * + * @param obj + */ +const isAsync = (obj: unknown) => /^\[object (Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); + /** * Check if "is a Promise", "Promise like". * @@ -171,6 +178,21 @@ const freezeObject = (obj: TBase, _seen?: WeakSet): TBase => { */ const isPromise = (obj: unknown) => /^\[object (Promise|Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); +/** + * Check if a value is a valid URL. + * + * @param str + */ +const isUrl = (str: unknown) => { + try { + new URL(str as any); + + return true; + } catch { + return false; + } +}; + /** * Generate a hash from a string * @@ -294,15 +316,95 @@ const generateHash = (anyValue: unknown): string => { return hashCode(stringify); }; +/** + * Join an array of values with a separator, optionally filtering out falsy values. + * + * - `stringJoin.basic` Join argument values with a single space separator + * - `stringJoin.newline` Join argument values with a newline separator + * - `stringJoin.filtered` Join argument values with a single space separator, filtering out falsy values + * - `stringJoin.newlineFiltered` Join argument values with a newline separator, filtering out falsy values + * + * @param arr - Array of strings to join + * @param settings - Join settings + * @param settings.sep - Separator to use + * @param settings.filterFalsyValues - If `true`, filter out falsy values before joining (default: `false`) + * @returns Joined string, with optional separator + */ +const stringJoin = (arr: unknown[], { sep = ' ', filterFalsyValues = false } = {}): string => + (filterFalsyValues ? arr.filter(Boolean).join(sep) : arr.join(sep)); + +/** + * Join argument values with a single space separator. + * + * @param args - Array of values to join + */ +stringJoin.basic = (...args: unknown[]) => stringJoin(args); + +/** + * Join argument values with a newline separator. + * + * @param args - Array of values to join + */ +stringJoin.newline = (...args: unknown[]) => stringJoin(args, { sep: '\n' }); + +/** + * Join argument values with a single space separator, filtering out falsy values. + * + * @param args - Array of values to join + */ +stringJoin.filtered = (...args: unknown[]) => stringJoin(args, { filterFalsyValues: true }); + +/** + * Join argument values with a newline separator, filtering out falsy values. + * + * @param args - Array of values to join + */ +stringJoin.newlineFiltered = (...args: unknown[]) => stringJoin(args, { sep: '\n', filterFalsyValues: true }); + +/** + * Wrap a function, or another Promise in a timeout, returning a + * Promise that either resolves, rejects, or rejects after the timeout. + * + * @param func - Function or Promise to wrap + * @param settings - Timeout settings + * @param settings.timeout - Timeout in milliseconds (default: `10_000`) + * @param settings.errorMessage - Error message to use if timeout occurs (default: `'Call timed out'`) + */ +const timeoutFunction = async ( + func: Promise | (() => TReturn | Promise), + { timeout = 10_000, errorMessage = 'Call timed out' }: { timeout?: number, errorMessage?: string } = {} +) => { + let funcTimer: NodeJS.Timeout | undefined; + const timer = () => new Promise((_, reject) => { + funcTimer = setTimeout(reject, timeout, new Error(errorMessage)); + funcTimer?.unref(); + }); + + try { + const updatedFunc = async () => + (!isAsync(func) && isPromise(func) ? func as Promise : (func as () => TReturn | Promise)()); + + return await Promise.race([updatedFunc(), timer()]); + } finally { + if (funcTimer) { + clearTimeout(funcTimer); + } + } +}; + export { freezeObject, generateHash, hashCode, hashNormalizeValue, + isAsync, isObject, isPlainObject, isPromise, isReferenceLike, + isUrl, mergeObjects, - portValid + portValid, + stringJoin, + timeoutFunction }; diff --git a/src/server.resources.ts b/src/server.resources.ts new file mode 100644 index 0000000..41c640a --- /dev/null +++ b/src/server.resources.ts @@ -0,0 +1,32 @@ +import { type McpResourceCreator } from './server'; +import { type AppSession, type GlobalOptions } from './options'; +import { getOptions, getSessionOptions } from './options.context'; +import { log } from './logger'; + +/** + * Compose built-in resource creators. + * + * @note This is primarily a placeholder for future external resources. + * + * @param builtinCreators - Built-in resource creators + * @param {GlobalOptions} options - Global options. + * @param {AppSession} _sessionOptions - Session options. + * @returns {Promise} Promise array of resource creators + */ +const composeResources = async ( + builtinCreators: McpResourceCreator[], + { resourceModules }: GlobalOptions = getOptions(), + _sessionOptions: AppSession = getSessionOptions() +): Promise => { + const resourceCreators: McpResourceCreator[] = [...builtinCreators]; + + if (!Array.isArray(resourceModules) || resourceModules.length === 0) { + log.info('No external resources loaded.'); + + return resourceCreators; + } + + return resourceCreators; +}; + +export { composeResources }; diff --git a/src/server.search.ts b/src/server.search.ts index ad07901..154625f 100644 --- a/src/server.search.ts +++ b/src/server.search.ts @@ -22,7 +22,7 @@ interface ClosestSearchOptions { interface FuzzySearchResult { item: string; distance: number; - matchType: 'exact' | 'prefix' | 'suffix' | 'contains' | 'fuzzy'; + matchType: 'exact' | 'prefix' | 'suffix' | 'contains' | 'fuzzy' | 'all'; } /** @@ -71,7 +71,7 @@ const normalizeString: NormalizeString = (str: string) => String(str || '') /** * Memoized version of normalizeString */ -normalizeString.memo = memo(normalizeString, { cacheLimit: 25 }); +normalizeString.memo = memo(normalizeString, { cacheLimit: 50 }); /** * Find the closest match using fastest-levenshtein's closest function. @@ -124,6 +124,15 @@ const findClosest = ( * @param query - Search query string * @param items - Array of strings to search * @param {FuzzySearchOptions} options - Search configuration options + * @param {number} options.maxDistance - Maximum edit distance for a match. Distance is defined as + * @param {number} options.maxResults - Maximum number of results to return + * @param {NormalizeString} options.normalizeFn - Function to normalize strings (default: `normalizeString`) + * @param {boolean} options.isExactMatch - Include exact matches in results (default: `true`) + * @param {boolean} options.isPrefixMatch - Include prefix matches in results (default: `true`) + * @param {boolean} options.isSuffixMatch - Include suffix matches in results (default: `true`) + * @param {boolean} options.isContainsMatch - Include contains matches in results (default: `true`) + * @param {boolean} options.isFuzzyMatch - Allow fuzzy matches even when `maxDistance` is negative or zero. + * @param {boolean} options.deduplicateByNormalized - If `true`, deduplicate results by normalized value instead of original string. * @returns {FuzzySearchResult[]} Array of matching strings with distance and match type * * @example diff --git a/src/server.tools.ts b/src/server.tools.ts index 1640c47..d4d61e0 100644 --- a/src/server.tools.ts +++ b/src/server.tools.ts @@ -524,7 +524,7 @@ const sendToolsHostShutdown = async ( }; /** - * Compose built-in creators with any externally loaded creators. + * Compose built-in tool creators with any externally loaded creators. * * - Node.js version policy: * - Node >= 22, external plugins are executed out-of-process via a Tools Host. diff --git a/src/server.ts b/src/server.ts index 0b874e5..4a74acc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,19 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer, type ResourceTemplate, type ResourceMetadata } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; -import { fetchDocsTool } from './tool.fetchDocs'; +import { searchPatternFlyDocsTool } from './tool.searchPatternFlyDocs'; import { componentSchemasTool } from './tool.componentSchemas'; +import { patternFlyContextResource } from './resource.patternFlyContext'; +import { patternFlyDocsIndexResource } from './resource.patternFlyDocsIndex'; +import { patternFlyDocsTemplateResource } from './resource.patternFlyDocsTemplate'; +import { patternFlySchemasIndexResource } from './resource.patternFlySchemasIndex'; +import { patternFlySchemasTemplateResource } from './resource.patternFlySchemasTemplate'; import { startHttpTransport, type HttpServerHandle } from './server.http'; import { memo } from './server.caching'; import { log, type LogEvent } from './logger'; import { createServerLogger } from './server.logger'; import { composeTools, sendToolsHostShutdown } from './server.tools'; +import { composeResources } from './server.resources'; import { type GlobalOptions } from './options'; import { getOptions, @@ -17,7 +23,7 @@ import { } from './options.context'; import { DEFAULT_OPTIONS } from './options.defaults'; import { isZodRawShape, isZodSchema } from './server.schema'; -import { isPlainObject } from './server.helpers'; +import { isPlainObject, timeoutFunction } from './server.helpers'; import { createServerStats, type Stats } from './server.stats'; import { stat } from './stats'; @@ -43,6 +49,21 @@ type McpTool = [ */ type McpToolCreator = ((options?: GlobalOptions) => McpTool) & { toolName?: string }; +/** + * A resource registered with the MCP server. + */ +type McpResource = [ + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata, + handler: (...args: any[]) => any | Promise +]; + +/** + * A function that creates a resource registered with the MCP server. + */ +type McpResourceCreator = ((options?: GlobalOptions) => McpResource) & { resourceName?: string }; + /** * Server options. Equivalent to GlobalOptions. */ @@ -54,11 +75,13 @@ type ServerOptions = GlobalOptions; * @interface ServerSettings * * @property {McpToolCreator[]} [tools] - An optional array of tool creators used by the server. + * @property {McpResourceCreator[]} [resources] - An optional array of resource creators used by the server. * @property [enableSigint] - Indicates whether SIGINT signal handling is enabled. * @property [allowProcessExit] - Determines if the process is allowed to exit explicitly. */ interface ServerSettings { tools?: McpToolCreator[]; + resources?: McpResourceCreator[]; enableSigint?: boolean; allowProcessExit?: boolean; } @@ -117,10 +140,23 @@ interface ServerInstance { */ const builtinTools: McpToolCreator[] = [ usePatternFlyDocsTool, - fetchDocsTool, + searchPatternFlyDocsTool, componentSchemasTool ]; +/** + * Built-in resources. + * + * Array of built-in resources + */ +const builtinResources: McpResourceCreator[] = [ + patternFlyContextResource, + patternFlyDocsIndexResource, + patternFlyDocsTemplateResource, + patternFlySchemasIndexResource, + patternFlySchemasTemplateResource +]; + /** * Create and run the MCP server, register tools, and return a handle. * @@ -132,10 +168,12 @@ const builtinTools: McpToolCreator[] = [ * @param [settings.tools] - Built-in tools to register. * @param [settings.enableSigint] - Indicates whether SIGINT signal handling is enabled. * @param [settings.allowProcessExit] - Determines if the process is allowed to exit explicitly, useful for testing. + * @param settings.resources * @returns Server instance with `stop()`, `getStats()` `isRunning()`, and `onLog()` subscription. */ const runServer = async (options: ServerOptions = getOptions(), { tools = builtinTools, + resources = builtinResources, enableSigint = true, allowProcessExit = true }: ServerSettings = {}): Promise => { @@ -195,6 +233,7 @@ const runServer = async (options: ServerOptions = getOptions(), { { capabilities: { tools: {}, + resources: {}, ...(enableProtocolLogging ? { logging: {} } : {}) } } @@ -217,6 +256,9 @@ const runServer = async (options: ServerOptions = getOptions(), { log.info(`Server stats enabled.`); + // Compose resources after logging is set up. + const updatedResources = await composeResources(resources); + // Combine built-in tools with custom ones after logging is set up. const updatedTools = await composeTools(tools); @@ -238,6 +280,29 @@ const runServer = async (options: ServerOptions = getOptions(), { getStatsSetup = () => statsTracker.getStats(); } + updatedResources.forEach(resourceCreator => { + const [name, uri, config, callback] = resourceCreator(options); + + log.info(`Registered resource: ${name}`); + + // Note: uri is being cast as any to bypass a type mismatch introduced at the MCP SDK level. Rereview when SDK is updated. + server?.registerResource(name, uri as any, config, (...args: unknown[]) => + runWithSession(session, async () => + runWithOptions(options, async () => { + log.debug( + `Running resource "${name}"`, + `isArgs = ${args?.length > 0}` + ); + + const timedReport = stat.traffic(); + const resourceResult = await callback(...args); + + timedReport({ resource: name }); + + return resourceResult; + }))); + }); + 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. @@ -300,15 +365,21 @@ const runServer = async (options: ServerOptions = getOptions(), { httpHandle = await startHttpTransport(server, options); } else { transport = new StdioServerTransport(); - await server.connect(transport); + + await timeoutFunction( + server.connect(transport), + { + errorMessage: 'Transport connection timed out.' + } + ); } if (!httpHandle && !transport) { throw new Error('No transport available'); } - log.info(`${options.name} server running on ${options.isHttp ? 'HTTP' : 'stdio'} transport`); running = true; + log.info(`${options.name} server running on ${options.isHttp ? 'HTTP' : 'stdio'} transport`); statsTracker.setStats(httpHandle); } catch (error) { log.error(`Error creating ${options.name} server:`, error); @@ -379,6 +450,8 @@ export { builtinTools, type McpTool, type McpToolCreator, + type McpResource, + type McpResourceCreator, type ServerInstance, type ServerLogEvent, type ServerOnLog, diff --git a/src/tool.componentSchemas.ts b/src/tool.componentSchemas.ts index 0186cc2..d4e21f2 100644 --- a/src/tool.componentSchemas.ts +++ b/src/tool.componentSchemas.ts @@ -1,10 +1,11 @@ import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { componentNames, getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; +import { getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { type McpTool } from './server'; import { getOptions } from './options.context'; import { memo } from './server.caching'; import { fuzzySearch } from './server.search'; +import { componentNames } from './tool.searchPatternFlyDocs'; /** * Derive the component schema type from @patternfly/patternfly-component-schemas @@ -12,18 +13,18 @@ import { fuzzySearch } from './server.search'; type ComponentSchema = Awaited>; /** - * componentSchemas tool function (tuple pattern) + * componentSchemas tool function * * Creates an MCP tool that retrieves JSON Schema for PatternFly React components. * Uses fuzzy search to handle typos and case variations, with related fallback suggestions. * * @param options - Optional configuration options (defaults to OPTIONS) - * @returns {McpTool} MCP tool tuple [name, schema, callback] + * @returns MCP tool tuple [name, schema, callback] */ const componentSchemasTool = (options = getOptions()): McpTool => { const memoGetComponentSchema = memo( async (componentName: string): Promise => getComponentSchema(componentName), - options?.toolMemoOptions?.fetchDocs // Use the same memo options as fetchDocs + options?.toolMemoOptions?.usePatternFlyDocs ); const callback = async (args: any = {}) => { @@ -32,7 +33,7 @@ const componentSchemasTool = (options = getOptions()): McpTool => { if (typeof componentName !== 'string') { throw new McpError( ErrorCode.InvalidParams, - `Missing required parameter: componentName (must be a string): ${componentName}` + `Missing required parameter: componentName must be a string: ${componentName}` ); } @@ -82,7 +83,11 @@ const componentSchemasTool = (options = getOptions()): McpTool => { return [ 'componentSchemas', { - description: 'Get JSON Schema for a PatternFly React component. Returns prop definitions, types, and validation rules. Use this for structured component metadata, not documentation.', + description: `[Deprecated: Use "usePatternFlyDocs" to retrieve component schemas from PatternFly documentation URLs.] + + Get JSON Schema for a PatternFly React component. + + Returns prop definitions, types, and validation rules. Use this for structured component metadata, not documentation.`, inputSchema: { componentName: z.string().describe('Name of the PatternFly component (e.g., "Button", "Table")') } diff --git a/src/tool.fetchDocs.ts b/src/tool.fetchDocs.ts deleted file mode 100644 index 2e2bea5..0000000 --- a/src/tool.fetchDocs.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from 'zod'; -import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { type McpTool } from './server'; -import { processDocsFunction } from './server.getResources'; -import { getOptions } from './options.context'; -import { memo } from './server.caching'; - -/** - * fetchDocs tool function (tuple pattern) - * - * @param options - */ -const fetchDocsTool = (options = getOptions()): McpTool => { - const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.fetchDocs); - - const callback = async (args: any = {}) => { - const { urlList } = args; - - if (!urlList || !Array.isArray(urlList)) { - throw new McpError( - ErrorCode.InvalidParams, - `Missing required parameter: urlList (must be an array of strings): ${urlList}` - ); - } - - let result: string; - - try { - result = await memoProcess(urlList); - } catch (error) { - throw new McpError( - ErrorCode.InternalError, - `Failed to fetch documentation: ${error}` - ); - } - - return { - content: [ - { - type: 'text', - text: result - } - ] - }; - }; - - return [ - 'fetchDocs', - { - description: 'Fetch documentation for one or more URLs extracted from previous tool calls responses. The URLs should be passed as an array in the "urlList" argument.', - inputSchema: { - urlList: z.array(z.string()).describe('The list of URLs to fetch documentation from') - } - }, - callback - ]; -}; - -/** - * A tool name, typically the first entry in the tuple. Used in logging and deduplication. - */ -fetchDocsTool.toolName = 'fetchDocs'; - -export { fetchDocsTool }; diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index ecb29b6..ccf61b0 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -1,37 +1,101 @@ -import { join } from 'node:path'; import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { getComponentSchema as pfGetComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { type McpTool } from './server'; -import { COMPONENT_DOCS } from './docs.component'; -import { LAYOUT_DOCS } from './docs.layout'; -import { CHART_DOCS } from './docs.chart'; -import { getLocalDocs } from './docs.local'; import { getOptions } from './options.context'; import { processDocsFunction } from './server.getResources'; import { memo } from './server.caching'; +import { stringJoin } from './server.helpers'; +import { setComponentToDocsMap, searchComponents } from './tool.searchPatternFlyDocs'; +import { DEFAULT_OPTIONS } from './options.defaults'; +import { log } from './logger'; /** - * usePatternFlyDocs tool function (tuple pattern) + * Get the component schema from @patternfly/patternfly-component-schemas. + * + * @param componentName + */ +const getComponentSchema = async (componentName: string) => { + try { + return await pfGetComponentSchema(componentName); + } catch {} + + return undefined; +}; + +/** + * Memoized version of getComponentSchema. + */ +getComponentSchema.memo = memo(getComponentSchema, DEFAULT_OPTIONS.toolMemoOptions.usePatternFlyDocs); + +/** + * usePatternFlyDocs tool function * * @param options + * @returns MCP tool tuple [name, schema, callback] */ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.usePatternFlyDocs); + const { getKey: getComponentToDocsKey } = setComponentToDocsMap.memo(); const callback = async (args: any = {}) => { - const { urlList } = args; + const { urlList, name } = args; + const isUrlList = urlList && Array.isArray(urlList) && urlList.length > 0 && urlList.every(url => typeof url === 'string' && url.trim().length > 0); + const isName = typeof name === 'string' && name.trim().length > 0; + const hasUri = (isName && new RegExp('patternfly://', 'i').test(name)) || (isUrlList && urlList.some(url => new RegExp('patternfly://', 'i').test(url))); - if (!urlList || !Array.isArray(urlList)) { + if (hasUri) { throw new McpError( ErrorCode.InvalidParams, - `Missing required parameter: urlList (must be an array of strings): ${urlList}` + stringJoin.basic( + 'Direct "patternfly://" URIs are not supported as tool inputs, and are intended to be used directly.', + 'Use a component "name" or provide a "urlList" of raw documentation URLs.' + ) ); } - let result: string; + if ((isUrlList && isName) || (!isUrlList && !isName)) { + throw new McpError( + ErrorCode.InvalidParams, + `Provide either a string "name" OR an array of strings "urlList".` + ); + } + + const updatedUrlList = isUrlList ? urlList.slice(0, options.recommendedMaxDocsToLoad) : []; + + if (isUrlList && urlList.length > options.recommendedMaxDocsToLoad) { + log.warn( + `usePatternFlyDocs: urlList truncated from ${urlList.length} to ${options.recommendedMaxDocsToLoad} items.` + ); + } + + if (name) { + const { exactMatches, searchResults } = searchComponents.memo(name); + + if (exactMatches.length === 0 || exactMatches.every(match => match.urls.length === 0)) { + const suggestions = searchResults.map(result => result.item).slice(0, 3); + const suggestionMessage = suggestions.length + ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` + : 'No similar components found.'; + + throw new McpError( + ErrorCode.InvalidParams, + `Component "${name.trim()}" not found. ${suggestionMessage}` + ); + } + + updatedUrlList.push(...exactMatches.flatMap(match => match.urls)); + } + + const docs = []; + const schemasSeen = new Set(); + const schemaResults = []; + const docResults = []; try { - result = await memoProcess(urlList); + const processedDocs = await memoProcess(updatedUrlList); + + docs.push(...processedDocs); } catch (error) { throw new McpError( ErrorCode.InternalError, @@ -39,11 +103,55 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { ); } + if (docs.length === 0) { + const urlListBlock = updatedUrlList.map((url: string, index: number) => ` ${index + 1}. ${url}`).join('\n'); + + return { + content: [{ + type: 'text', + text: stringJoin.newline( + `No PatternFly documentation found for:`, + urlListBlock, + '', + '---', + '', + '**Important**:', + ' - To browse all available components use "searchPatternFlyDocs" with a search all ("*").' + ) + }] + }; + } + + for (const doc of docs) { + const componentName = getComponentToDocsKey(doc.path); + + docResults.push(stringJoin.newline( + `# Documentation${(componentName && ` for ${componentName}`) || ''} from ${doc.path || 'unknown'}`, + '', + doc.content + )); + + if (componentName && !schemasSeen.has(componentName)) { + schemasSeen.add(componentName); + const componentSchema = await getComponentSchema.memo(componentName); + + if (componentSchema) { + schemaResults.push(stringJoin.newline( + `# Component Schema for ${componentName}`, + `This machine-readable JSON schema defines the component's props, types, and validation rules.`, + '```json', + JSON.stringify(componentSchema, null, 2), + '```' + )); + } + } + } + return { content: [ { type: 'text', - text: result + text: [...docResults, ...schemaResults].join(options.separator) } ] }; @@ -52,25 +160,18 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { return [ 'usePatternFlyDocs', { - description: `You must use this tool to answer any questions related to PatternFly components or documentation. - - The description of the tool contains links to ${options.docsHost ? 'llms.txt' : '.md'} files or local file paths that the user has made available. - - ${options.docsHost - ? `[@patternfly/react-core@6.0.0^](${join('react-core', '6.0.0', 'llms.txt')})` - : ` - ${COMPONENT_DOCS.join('\n')} - ${LAYOUT_DOCS.join('\n')} - ${CHART_DOCS.join('\n')} - ${getLocalDocs().join('\n')} - ` - } + description: `Get markdown documentation and component JSON schemas for PatternFly components. + + **Usage**: + 1. Input a component name (e.g., "Button") OR a list of up to ${options.recommendedMaxDocsToLoad} documentation URLs at a time (typically from searchPatternFlyDocs results). - 1. Pick the most suitable URL from the above list, and use that as the "urlList" argument for this tool's execution, to get the docs content. If it's just one, let it be an array with one URL. - 2. Analyze the URLs listed in the ${options.docsHost ? 'llms.txt' : '.md'} file - 3. Then fetch specific documentation pages relevant to the user's question with the subsequent tool call.`, + **Returns**: + - Markdown documentation + - Component JSON schemas, if available + `, inputSchema: { - urlList: z.array(z.string()).describe('The list of urls to fetch the documentation from') + urlList: z.array(z.string()).max(options.recommendedMaxDocsToLoad).optional().describe(`The list of URLs to fetch the documentation from (max ${options.recommendedMaxDocsToLoad} at a time`), + name: z.string().optional().describe('The name of a PatternFly component to fetch documentation for (e.g., "Button", "Table")') } }, callback @@ -82,4 +183,4 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { */ usePatternFlyDocsTool.toolName = 'usePatternFlyDocs'; -export { usePatternFlyDocsTool }; +export { usePatternFlyDocsTool, getComponentSchema }; diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts new file mode 100644 index 0000000..1a89bf1 --- /dev/null +++ b/src/tool.searchPatternFlyDocs.ts @@ -0,0 +1,283 @@ +import { z } from 'zod'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { componentNames as pfComponentNames } from '@patternfly/patternfly-component-schemas/json'; +import { type McpTool } from './server'; +import { COMPONENT_DOCS } from './docs.component'; +import { LAYOUT_DOCS } from './docs.layout'; +import { CHART_DOCS } from './docs.chart'; +import { getLocalDocs } from './docs.local'; +import { fuzzySearch, type FuzzySearchResult } from './server.search'; +import { memo } from './server.caching'; +import { stringJoin } from './server.helpers'; +import { DEFAULT_OPTIONS } from './options.defaults'; + +/** + * List of component names to include in search results. + * + * @note The "table" component is manually added to the list because it's not currently included + * in the component schemas package. + */ +const componentNames = [...pfComponentNames, 'Table'].sort((a, b) => a.localeCompare(b)); + +/** + * Extract a component name from an internal documentation URL string + * + * @note This is reliant on the documentation URLs being in the accepted format. + * If the format changes, this will need to be updated. This is a short-term solution + * until we can move the internal links to a new format like: + * ``` + * { + * name: 'Charts', + * description: 'Colors for Charts', + * type: 'example', + * scope: '@patternfly', + * url: `${PF_EXTERNAL_EXAMPLES_CHARTS}/ChartTheme/examples/ChartTheme.md` + * } + * ``` + * + * @example + * extractComponentName('[@patternfly/ComponentName - Type](URL)'); + * + * @param docUrl - Documentation URL string + * @returns ComponentName or `null` if not found + */ +const extractComponentName = (docUrl: string): string | null => { + // Stop at space or closing bracket, allowing dashes in the name + const match = docUrl.match(/\[@patternfly\/([^\s\]]+)/); + const name = match && match[1] ? match[1].trim() : null; + + // Filter out known non-component patterns + if (name?.startsWith('react-')) { + return null; + } + + return name; +}; + +/** + * Extract a URL from an internal Markdown link. + * + * @note This is a short-term solution until we can move the internal links to a new format. + * + * @example + * extractUrl('[text](URL)'); + * + * @param docUrl + * @returns URL or original string if not a Markdown link + */ +const extractUrl = (docUrl: string): string => { + const match = docUrl.match(/]\(([^)]+)\)/); + + return match && match[1] ? match[1] : docUrl; +}; + +/** + * Build a map of component names relative to internal documentation URLs. + * + * @returns Map of component name -> array of URLs (Design Guidelines + Accessibility) + */ +const setComponentToDocsMap = () => { + const map = new Map(); + const allDocs = [...COMPONENT_DOCS, ...LAYOUT_DOCS, ...CHART_DOCS, ...getLocalDocs()]; + const getKey = (value?: string | undefined) => { + if (!value) { + return undefined; + } + + for (const [key, urls] of map) { + if (urls.includes(value)) { + return key; + } else { + const results = fuzzySearch(value, urls, { + deduplicateByNormalized: true + }); + + if (results.length) { + return key; + } + } + } + + return undefined; + }; + + allDocs.forEach(docUrl => { + const componentName = extractComponentName(docUrl); + + if (componentName) { + const url = extractUrl(docUrl); + const existing = map.get(componentName) || []; + + map.set(componentName, [...existing, url]); + } + }); + + return { + map, + getKey + }; +}; + +/** + * Memoized version of componentToDocsMap. + */ +setComponentToDocsMap.memo = memo(setComponentToDocsMap); + +/** + * Search for PatternFly component documentation URLs using fuzzy search. + * + * @param searchQuery - Search query string + * @param settings - Optional settings object + * @param settings.names - List of names to search. Defaults to all component names. + * @param settings.allowWildCardAll - Allow a search query to match all components. Defaults to false. + * @returns Object containing search results and matched URLs + * - `isSearchWildCardAll`: Whether the search query matched all components + * - `firstExactMatch`: First exact match within fuzzy search results + * - `exactMatches`: All exact matches within fuzzy search results + * - `searchResults`: Fuzzy search results + */ +const searchComponents = (searchQuery: string, { names = componentNames, allowWildCardAll = false } = {}) => { + const isWildCardAll = searchQuery.trim() === '*' || searchQuery.trim().toLowerCase() === 'all' || searchQuery.trim() === ''; + const isSearchWildCardAll = allowWildCardAll && isWildCardAll; + const { map: componentToDocsMap } = setComponentToDocsMap.memo(); + let searchResults: FuzzySearchResult[] = []; + + if (isSearchWildCardAll) { + searchResults = componentNames.map(name => ({ matchType: 'all', distance: 0, item: name } as FuzzySearchResult)); + } else { + searchResults = fuzzySearch(searchQuery, names, { + maxDistance: 3, + maxResults: 10, + isFuzzyMatch: true, + deduplicateByNormalized: true + }); + } + + const extendResults = (results: FuzzySearchResult[] = []) => results.map(result => { + const isSchemasAvailable = pfComponentNames.includes(result.item); + const urls = componentToDocsMap.get(result.item) || []; + const matchedUrls = new Set(); + + urls.forEach(url => { + matchedUrls.add(url); + }); + + return { + ...result, + doc: `patternfly://docs/${result.item}`, + isSchemasAvailable, + schema: isSchemasAvailable ? `patternfly://schemas/${result.item}` : undefined, + urls: Array.from(matchedUrls) + }; + }); + + const exactMatches = searchResults.filter(result => result.matchType === 'exact'); + const extendedExactMatches = extendResults(exactMatches); + const extendedSearchResults = extendResults(searchResults); + + return { + isSearchWildCardAll, + firstExactMatch: extendedExactMatches[0], + exactMatches: extendedExactMatches, + searchResults: extendedSearchResults + }; +}; + +/** + * Memoized version of searchComponents. + */ +searchComponents.memo = memo(searchComponents, DEFAULT_OPTIONS.toolMemoOptions.searchPatternFlyDocs); + +/** + * searchPatternFlyDocs tool function + * + * Searches for PatternFly component documentation URLs using fuzzy search. + * Returns URLs only (does not fetch content). Use usePatternFlyDocs to fetch the actual content. + * + * @returns MCP tool tuple [name, schema, callback] + */ +const searchPatternFlyDocsTool = (): McpTool => { + const callback = async (args: any = {}) => { + const { searchQuery } = args; + + if (typeof searchQuery !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: searchQuery must be a string: ${searchQuery}` + ); + } + + const { isSearchWildCardAll, searchResults } = searchComponents.memo(searchQuery, { allowWildCardAll: true }); + + if (!isSearchWildCardAll && searchResults.length === 0) { + return { + content: [{ + type: 'text', + text: stringJoin.newline( + `No PatternFly documentation found matching "${searchQuery}"`, + '', + '---', + '', + '**Important**:', + ' - Use a search all ("*") to find all available components.' + ) + }] + }; + } + + const results = searchResults.map(result => { + const urlList = result.urls.map((url: string, index: number) => ` ${index + 1}. ${url}`).join('\n'); + + return stringJoin.newline( + '', + `## ${result.item}`, + `**Match Type**: ${result.matchType}`, + `### "usePatternFlyDocs" tool documentation URLs`, + urlList.length ? urlList : ' - No URLs found', + `### Resources metadata`, + ` - **Component name**: ${result.item}`, + ` - **JSON Schemas**: ${result.isSchemasAvailable ? 'Available' : 'Not available'}` + ); + }); + + return { + content: [{ + type: 'text', + text: stringJoin.newline( + `# Search results for "${isSearchWildCardAll ? 'all components' : searchQuery}", ${searchResults.length} matches found:`, + ...results, + '', + '---', + '', + '**Important**:', + ' - Use the "usePatternFlyDocs" tool with the above URLs to fetch documentation content.', + ' - Use a search all ("*") to find all available components.' + ) + }] + }; + }; + + return [ + 'searchPatternFlyDocs', + { + description: `Search PatternFly components and get component names with documentation URLs. Supports case-insensitive partial and all ("*") matches. + + **Usage**: + 1. Input a "searchQuery" to find PatternFly documentation URLs and component names. + 2. Use the returned component names OR URLs with the "usePatternFlyDocs" tool to get markdown documentation and component JSON schemas. + + **Returns**: + - Component names that can be used with "usePatternFlyDocs" + - Documentation URLs that can be used with "usePatternFlyDocs" + `, + inputSchema: { + searchQuery: z.string().describe('Full or partial component name to search for (e.g., "button", "table", "*")') + } + }, + callback + ]; +}; + +searchPatternFlyDocsTool.toolName = 'searchPatternFlyDocs'; + +export { searchPatternFlyDocsTool, searchComponents, setComponentToDocsMap, componentNames }; diff --git a/tests/__snapshots__/httpTransport.test.ts.snap b/tests/__snapshots__/httpTransport.test.ts.snap index e7ac195..43f1b52 100644 --- a/tests/__snapshots__/httpTransport.test.ts.snap +++ b/tests/__snapshots__/httpTransport.test.ts.snap @@ -1,6 +1,20 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator with two local files 1`] = ` +exports[`Builtin resources, HTTP transport should expose expected resources and templates: resources 1`] = ` +{ + "resourceNames": [ + "patternfly://context", + "patternfly://docs/index", + "patternfly://schemas/index", + ], + "templateNames": [ + "patternfly://docs/{name}", + "patternfly://schemas/{name}", + ], +} +`; + +exports[`Builtin tools, HTTP transport should concatenate headers and separator with two local files 1`] = ` "# Documentation from documentation/guidelines/README.md # PatternFly Guidelines @@ -108,7 +122,7 @@ You can find documentation on PatternFly's components at [PatternFly All compone " `; -exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator with two remote files 1`] = ` +exports[`Builtin tools, HTTP transport should concatenate headers and separator with two remote files 1`] = ` "# Documentation from https://www.patternfly.org/notARealPath/README.md # PatternFly Development Rules @@ -132,17 +146,17 @@ exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator This is a test document for mocking remote HTTP requests." `; -exports[`PatternFly MCP, HTTP Transport should expose expected tools and stable shape 1`] = ` +exports[`Builtin tools, HTTP transport should expose expected tools and stable shape: tools 1`] = ` { "toolNames": [ "componentSchemas", - "fetchDocs", + "searchPatternFlyDocs", "usePatternFlyDocs", ], } `; -exports[`PatternFly MCP, HTTP Transport should initialize MCP session over HTTP 1`] = ` +exports[`Builtin tools, HTTP transport should initialize MCP session over HTTP 1`] = ` { "baseUrl": "http://127.0.0.1:5001", "name": "@patternfly/patternfly-mcp", diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index 482ce48..c291f5f 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -1,308 +1,20 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Hosted mode, --docs-host should read llms-files and includes expected tokens 1`] = ` -[ - "# @patternfly/react-core 6.0.0", - "## Components", - "[@patternfly/AboutModal - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/about-modal/about-modal.md)", - "[@patternfly/AboutModal - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/about-modal/about-modal.md)", - "[@patternfly/AboutModal - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/AboutModal/examples/AboutModal.md)", - "[@patternfly/Accordion - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/accordion/accordion.md)", - "[@patternfly/Accordion - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/accordion/accordion.md)", - "[@patternfly/Accordion - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Accordion/examples/Accordion.md)", - "[@patternfly/ActionList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/action-list/action-list.md)", - "[@patternfly/ActionList - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/action-list/action-list.md)", - "[@patternfly/ActionList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ActionList/examples/ActionList.md)", - "[@patternfly/Alert - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/alert/alert.md)", - "[@patternfly/Alert - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/alert/alert.md)", - "[@patternfly/Alert - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Alert/examples/Alert.md)", - "[@patternfly/ApplicationLauncher - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/application-launcher/application-launcher.md)", - "[@patternfly/ApplicationLauncher - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/application-launcher/application-launcher.md)", - "[@patternfly/ApplicationLauncher - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ApplicationLauncher/examples/ApplicationLauncher.md)", - "[@patternfly/Avatar - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/avatar/avatar.md)", - "[@patternfly/Avatar - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/avatar/avatar.md)", - "[@patternfly/Avatar - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Avatar/examples/Avatar.md)", - "[@patternfly/BackToTop - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/back-to-top/back-to-top.md)", - "[@patternfly/BackToTop - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/back-to-top/back-to-top.md)", - "[@patternfly/BackToTop - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/BackToTop/examples/BackToTop.md)", - "[@patternfly/Backdrop - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/backdrop/backdrop.md)", - "[@patternfly/Backdrop - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/backdrop/backdrop.md)", - "[@patternfly/Backdrop - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Backdrop/examples/Backdrop.md)", - "[@patternfly/BackgroundImage - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/background-image/background-image.md)", - "[@patternfly/BackgroundImage - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/background-image/background-image.md)", - "[@patternfly/BackgroundImage - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/BackgroundImage/examples/BackgroundImage.md)", - "[@patternfly/Badge - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/badge/badge.md)", - "[@patternfly/Badge - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/badge/badge.md)", - "[@patternfly/Badge - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Badge/examples/Badge.md)", - "[@patternfly/Banner - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/banner/banner.md)", - "[@patternfly/Banner - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/banner/banner.md)", - "[@patternfly/Banner - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Banner/examples/Banner.md)", - "[@patternfly/Brand - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/brand/brand.md)", - "[@patternfly/Brand - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/brand/brand.md)", - "[@patternfly/Brand - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Brand/examples/Brand.md)", - "[@patternfly/Breadcrumb - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/breadcrumb/breadcrumb.md)", - "[@patternfly/Breadcrumb - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/breadcrumb/breadcrumb.md)", - "[@patternfly/Breadcrumb - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Breadcrumb/examples/Breadcrumb.md)", - "[@patternfly/Button - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md)", - "[@patternfly/Button - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md)", - "[@patternfly/Button - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Button/examples/Button.md)", - "[@patternfly/CalendarMonth - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/calendar-month/calendar-month.md)", - "[@patternfly/CalendarMonth - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/calendar-month/calendar-month.md)", - "[@patternfly/CalendarMonth - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/CalendarMonth/examples/CalendarMonth.md)", - "[@patternfly/Card - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/card/card.md)", - "[@patternfly/Card - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/card/card.md)", - "[@patternfly/Card - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Card/examples/Card.md)", - "[@patternfly/Checkbox - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/checkbox/checkbox.md)", - "[@patternfly/Checkbox - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/checkbox/checkbox.md)", - "[@patternfly/Checkbox - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Checkbox/examples/Checkbox.md)", - "[@patternfly/ChipDeprecated - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/chip-deprecated/chip-deprecated.md)", - "[@patternfly/ChipDeprecated - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/chip-deprecated/chip-deprecated.md)", - "[@patternfly/ChipDeprecated - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ChipDeprecated/examples/ChipDeprecated.md)", - "[@patternfly/ClipboardCopy - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/clipboard-copy/clipboard-copy.md)", - "[@patternfly/ClipboardCopy - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/clipboard-copy/clipboard-copy.md)", - "[@patternfly/ClipboardCopy - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ClipboardCopy/examples/ClipboardCopy.md)", - "[@patternfly/CodeBlock - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/code-block/code-block.md)", - "[@patternfly/CodeBlock - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/code-block/code-block.md)", - "[@patternfly/CodeBlock - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/CodeBlock/examples/CodeBlock.md)", - "[@patternfly/CodeEditor - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/code-editor/code-editor.md)", - "[@patternfly/CodeEditor - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/code-editor/code-editor.md)", - "[@patternfly/CodeEditor - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/CodeEditor/examples/CodeEditor.md)", - "[@patternfly/Content - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/content/content.md)", - "[@patternfly/Content - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/content/content.md)", - "[@patternfly/Content - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Content/examples/Content.md)", - "[@patternfly/DataList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/data-list/data-list.md)", - "[@patternfly/DataList - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/data-list/data-list.md)", - "[@patternfly/DataList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/DataList/examples/DataList.md)", - "[@patternfly/DatePicker - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/date-picker/date-picker.md)", - "[@patternfly/DatePicker - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/date-picker/date-picker.md)", - "[@patternfly/DatePicker - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/DatePicker/examples/DatePicker.md)", - "[@patternfly/DateTimePicker - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/date-time-picker/date-time-picker.md)", - "[@patternfly/DateTimePicker - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/date-time-picker/date-time-picker.md)", - "[@patternfly/DateTimePicker - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/DateTimePicker/examples/DateTimePicker.md)", - "[@patternfly/DescriptionList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/description-list/description-list.md)", - "[@patternfly/DescriptionList - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/description-list/description-list.md)", - "[@patternfly/DescriptionList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/DescriptionList/examples/DescriptionList.md)", - "[@patternfly/Divider - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/divider/divider.md)", - "[@patternfly/Divider - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/divider/divider.md)", - "[@patternfly/Divider - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Divider/examples/Divider.md)", - "[@patternfly/DragAndDrop - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/drag-and-drop/drag-and-drop.md)", - "[@patternfly/DragAndDrop - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/drag-and-drop/drag-and-drop.md)", - "[@patternfly/DragAndDrop - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/DragAndDrop/examples/DragAndDrop.md)", - "[@patternfly/Drawer - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/drawer/drawer.md)", - "[@patternfly/Drawer - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/drawer/drawer.md)", - "[@patternfly/Drawer - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Drawer/examples/Drawer.md)", - "[@patternfly/Dropdown - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/dropdown/dropdown.md)", - "[@patternfly/Dropdown - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/dropdown/dropdown.md)", - "[@patternfly/Dropdown - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Dropdown/examples/Dropdown.md)", - "[@patternfly/DualListSelector - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/dual-list-selector/dual-list-selector.md)", - "[@patternfly/DualListSelector - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/dual-list-selector/dual-list-selector.md)", - "[@patternfly/DualListSelector - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md)", - "[@patternfly/EmptyState - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/empty-state/empty-state.md)", - "[@patternfly/EmptyState - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/empty-state/empty-state.md)", - "[@patternfly/EmptyState - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/EmptyState/examples/EmptyState.md)", - "[@patternfly/ExpandableSection - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/expandable-section/expandable-section.md)", - "[@patternfly/ExpandableSection - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/expandable-section/expandable-section.md)", - "[@patternfly/ExpandableSection - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ExpandableSection/examples/ExpandableSection.md)", - "[@patternfly/FileUpload - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/file-upload/file-upload.md)", - "[@patternfly/FileUpload - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/file-upload/file-upload.md)", - "[@patternfly/FileUpload - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/FileUpload/examples/FileUpload.md)", - "[@patternfly/Form - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/form/form.md)", - "[@patternfly/Form - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/form/form.md)", - "[@patternfly/Form - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Form/examples/Form.md)", - "[@patternfly/FormControl - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/form-control/form-control.md)", - "[@patternfly/FormControl - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/form-control/form-control.md)", - "[@patternfly/FormControl - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/FormControl/examples/FormControl.md)", - "[@patternfly/FormSelect - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/form-select/form-select.md)", - "[@patternfly/FormSelect - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/form-select/form-select.md)", - "[@patternfly/FormSelect - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/FormSelect/examples/FormSelect.md)", - "[@patternfly/HelperText - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/helper-text/helper-text.md)", - "[@patternfly/HelperText - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/helper-text/helper-text.md)", - "[@patternfly/HelperText - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/HelperText/examples/HelperText.md)", - "[@patternfly/Hint - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/hint/hint.md)", - "[@patternfly/Hint - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/hint/hint.md)", - "[@patternfly/Hint - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Hint/examples/Hint.md)", - "[@patternfly/Icon - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/icon/icon.md)", - "[@patternfly/Icon - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/icon/icon.md)", - "[@patternfly/Icon - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Icon/examples/Icon.md)", - "[@patternfly/InlineEdit - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/inline-edit/inline-edit.md)", - "[@patternfly/InlineEdit - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/inline-edit/inline-edit.md)", - "[@patternfly/InlineEdit - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/InlineEdit/examples/InlineEdit.md)", - "[@patternfly/InputGroup - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/input-group/input-group.md)", - "[@patternfly/InputGroup - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/input-group/input-group.md)", - "[@patternfly/InputGroup - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/InputGroup/examples/InputGroup.md)", - "[@patternfly/JumpLinks - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/jump-links/jump-links.md)", - "[@patternfly/JumpLinks - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/jump-links/jump-links.md)", - "[@patternfly/JumpLinks - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/JumpLinks/examples/JumpLinks.md)", - "[@patternfly/Label - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/label/label.md)", - "[@patternfly/Label - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/label/label.md)", - "[@patternfly/Label - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Label/examples/Label.md)", - "[@patternfly/List - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/list/list.md)", - "[@patternfly/List - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/list/list.md)", - "[@patternfly/List - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/List/examples/List.md)", - "[@patternfly/LoginPage - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/login-page/login-page.md)", - "[@patternfly/LoginPage - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/login-page/login-page.md)", - "[@patternfly/LoginPage - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/LoginPage/examples/LoginPage.md)", - "[@patternfly/Masthead - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/masthead/masthead.md)", - "[@patternfly/Masthead - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/masthead/masthead.md)", - "[@patternfly/Masthead - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Masthead/examples/Masthead.md)", - "[@patternfly/Menu - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/menu/menu.md)", - "[@patternfly/Menu - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/menu/menu.md)", - "[@patternfly/Menu - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Menu/examples/Menu.md)", - "[@patternfly/MenuToggle - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/menu-toggle/menu-toggle.md)", - "[@patternfly/MenuToggle - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/menu-toggle/menu-toggle.md)", - "[@patternfly/MenuToggle - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/MenuToggle/examples/MenuToggle.md)", - "[@patternfly/Modal - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/modal/modal.md)", - "[@patternfly/Modal - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/modal/modal.md)", - "[@patternfly/Modal - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Modal/examples/Modal.md)", - "[@patternfly/Navigation - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/navigation/navigation.md)", - "[@patternfly/Navigation - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/navigation/navigation.md)", - "[@patternfly/Navigation - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Navigation/examples/Navigation.md)", - "[@patternfly/NotificationBadge - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/notification-badge/notification-badge.md)", - "[@patternfly/NotificationBadge - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/notification-badge/notification-badge.md)", - "[@patternfly/NotificationBadge - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/NotificationBadge/examples/NotificationBadge.md)", - "[@patternfly/NotificationDrawer - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/notification-drawer/notification-drawer.md)", - "[@patternfly/NotificationDrawer - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/notification-drawer/notification-drawer.md)", - "[@patternfly/NotificationDrawer - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/NotificationDrawer/examples/NotificationDrawer.md)", - "[@patternfly/NumberInput - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/number-input/number-input.md)", - "[@patternfly/NumberInput - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/number-input/number-input.md)", - "[@patternfly/NumberInput - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/NumberInput/examples/NumberInput.md)", - "[@patternfly/OverflowMenu - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/overflow-menu/overflow-menu.md)", - "[@patternfly/OverflowMenu - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/overflow-menu/overflow-menu.md)", - "[@patternfly/OverflowMenu - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/OverflowMenu/examples/OverflowMenu.md)", - "[@patternfly/Page - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/page/page.md)", - "[@patternfly/Page - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/page/page.md)", - "[@patternfly/Page - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Page/examples/Page.md)", - "[@patternfly/Pagination - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/pagination/pagination.md)", - "[@patternfly/Pagination - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/pagination/pagination.md)", - "[@patternfly/Pagination - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Pagination/examples/Pagination.md)", - "[@patternfly/Panel - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/panel/panel.md)", - "[@patternfly/Panel - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/panel/panel.md)", - "[@patternfly/Panel - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Panel/examples/Panel.md)", - "[@patternfly/Popover - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/popover/popover.md)", - "[@patternfly/Popover - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/popover/popover.md)", - "[@patternfly/Popover - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Popover/examples/Popover.md)", - "[@patternfly/Progress - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/progress/progress.md)", - "[@patternfly/Progress - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/progress/progress.md)", - "[@patternfly/Progress - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Progress/examples/Progress.md)", - "[@patternfly/ProgressStepper - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/progress-stepper/progress-stepper.md)", - "[@patternfly/ProgressStepper - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/progress-stepper/progress-stepper.md)", - "[@patternfly/ProgressStepper - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ProgressStepper/examples/ProgressStepper.md)", - "[@patternfly/Radio - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/radio/radio.md)", - "[@patternfly/Radio - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/radio/radio.md)", - "[@patternfly/Radio - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Radio/examples/Radio.md)", - "[@patternfly/SearchInput - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/search-input/search-input.md)", - "[@patternfly/SearchInput - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/search-input/search-input.md)", - "[@patternfly/SearchInput - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/SearchInput/examples/SearchInput.md)", - "[@patternfly/Select - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/select/select.md)", - "[@patternfly/Select - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/select/select.md)", - "[@patternfly/Select - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Select/examples/Select.md)", - "[@patternfly/Sidebar - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/sidebar/sidebar.md)", - "[@patternfly/Sidebar - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/sidebar/sidebar.md)", - "[@patternfly/Sidebar - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Sidebar/examples/Sidebar.md)", - "[@patternfly/SimpleList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/simple-list/simple-list.md)", - "[@patternfly/SimpleList - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/simple-list/simple-list.md)", - "[@patternfly/SimpleList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/SimpleList/examples/SimpleList.md)", - "[@patternfly/Skeleton - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/skeleton/skeleton.md)", - "[@patternfly/Skeleton - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/skeleton/skeleton.md)", - "[@patternfly/Skeleton - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Skeleton/examples/Skeleton.md)", - "[@patternfly/SkipToContent - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/skip-to-content/skip-to-content.md)", - "[@patternfly/SkipToContent - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/skip-to-content/skip-to-content.md)", - "[@patternfly/SkipToContent - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/SkipToContent/examples/SkipToContent.md)", - "[@patternfly/Slider - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/slider/slider.md)", - "[@patternfly/Slider - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/slider/slider.md)", - "[@patternfly/Slider - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Slider/examples/Slider.md)", - "[@patternfly/Spinner - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/spinner/spinner.md)", - "[@patternfly/Spinner - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/spinner/spinner.md)", - "[@patternfly/Spinner - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Spinner/examples/Spinner.md)", - "[@patternfly/Switch - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/switch/switch.md)", - "[@patternfly/Switch - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/switch/switch.md)", - "[@patternfly/Switch - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Switch/examples/Switch.md)", - "[@patternfly/Table - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/table/table.md)", - "[@patternfly/Table - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/table/table.md)", - "[@patternfly/Table - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Table/examples/Table.md)", - "[@patternfly/Tabs - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tabs/tabs.md)", - "[@patternfly/Tabs - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/tabs/tabs.md)", - "[@patternfly/Tabs - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Tabs/examples/Tabs.md)", - "[@patternfly/TextArea - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/text-area/text-area.md)", - "[@patternfly/TextArea - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/text-area/text-area.md)", - "[@patternfly/TextArea - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/TextArea/examples/TextArea.md)", - "[@patternfly/TextInput - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/text-input/text-input.md)", - "[@patternfly/TextInput - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/text-input/text-input.md)", - "[@patternfly/TextInput - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/TextInput/examples/TextInput.md)", - "[@patternfly/TextInputGroup - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/text-input-group/text-input-group.md)", - "[@patternfly/TextInputGroup - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/text-input-group/text-input-group.md)", - "[@patternfly/TextInputGroup - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/TextInputGroup/examples/TextInputGroup.md)", - "[@patternfly/TileDeprecated - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tile-deprecated/tile-deprecated.md)", - "[@patternfly/TileDeprecated - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/tile-deprecated/tile-deprecated.md)", - "[@patternfly/TileDeprecated - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/TileDeprecated/examples/TileDeprecated.md)", - "[@patternfly/TimePicker - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/time-picker/time-picker.md)", - "[@patternfly/TimePicker - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/time-picker/time-picker.md)", - "[@patternfly/TimePicker - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/TimePicker/examples/TimePicker.md)", - "[@patternfly/Timestamp - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/timestamp/timestamp.md)", - "[@patternfly/Timestamp - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/timestamp/timestamp.md)", - "[@patternfly/Timestamp - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Timestamp/examples/Timestamp.md)", - "[@patternfly/Title - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/title/title.md)", - "[@patternfly/Title - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/title/title.md)", - "[@patternfly/Title - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Title/examples/Title.md)", - "[@patternfly/ToggleGroup - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/toggle-group/toggle-group.md)", - "[@patternfly/ToggleGroup - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/toggle-group/toggle-group.md)", - "[@patternfly/ToggleGroup - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/ToggleGroup/examples/ToggleGroup.md)", - "[@patternfly/Toolbar - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/toolbar/toolbar.md)", - "[@patternfly/Toolbar - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/toolbar/toolbar.md)", - "[@patternfly/Toolbar - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Toolbar/examples/Toolbar.md)", - "[@patternfly/Tooltip - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tooltip/tooltip.md)", - "[@patternfly/Tooltip - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/tooltip/tooltip.md)", - "[@patternfly/Tooltip - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Tooltip/examples/Tooltip.md)", - "[@patternfly/TreeView - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tree-view/tree-view.md)", - "[@patternfly/TreeView - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/tree-view/tree-view.md)", - "[@patternfly/TreeView - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/TreeView/examples/TreeView.md)", - "[@patternfly/Truncate - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/truncate/truncate.md)", - "[@patternfly/Truncate - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/truncate/truncate.md)", - "[@patternfly/Truncate - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Truncate/examples/Truncate.md)", - "[@patternfly/Wizard - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/wizard/wizard.md)", - "[@patternfly/Wizard - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/wizard/wizard.md)", - "[@patternfly/Wizard - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-core/src/components/Wizard/examples/Wizard.md)", - "## Layouts", -] -`; - -exports[`Logging should allow setting logging options, default 1`] = `[]`; - -exports[`Logging should allow setting logging options, stderr 1`] = ` -[ - "[INFO]: Server logging enabled. -", - "[INFO]: Server stats enabled. -", - "[INFO]: No external tools loaded. -", - "[INFO]: Registered tool: usePatternFlyDocs -", - "[INFO]: Registered tool: fetchDocs -", - "[INFO]: Registered tool: componentSchemas -", - "[INFO]: @patternfly/patternfly-mcp server running on stdio transport -", -] -`; - -exports[`Logging should allow setting logging options, with log level filtering 1`] = `[]`; - -exports[`Logging should allow setting logging options, with mcp protocol 1`] = ` -[ - { - "method": "notifications/message", - "params": { - "data": "@patternfly/patternfly-mcp server running on stdio transport", - "level": "info", - "logger": "@patternfly/patternfly-mcp", - }, - }, -] +exports[`Builtin resources, STDIO should expose expected resources and templates 1`] = ` +{ + "resourceNames": [ + "patternfly://context", + "patternfly://docs/index", + "patternfly://schemas/index", + ], + "templateNames": [ + "patternfly://docs/{name}", + "patternfly://schemas/{name}", + ], +} `; -exports[`PatternFly MCP, STDIO should concatenate headers and separator with two local files 1`] = ` +exports[`Builtin tools, STDIO should concatenate headers and separator with two local files 1`] = ` "# Documentation from documentation/guidelines/README.md # PatternFly Guidelines @@ -410,7 +122,7 @@ You can find documentation on PatternFly's components at [PatternFly All compone " `; -exports[`PatternFly MCP, STDIO should concatenate headers and separator with two remote files 1`] = ` +exports[`Builtin tools, STDIO should concatenate headers and separator with two remote files 1`] = ` "# Documentation from http://127.0.0.1:5010/notARealPath/README.md # PatternFly Development Rules @@ -434,16 +146,64 @@ exports[`PatternFly MCP, STDIO should concatenate headers and separator with two This is a test document for mocking remote HTTP requests." `; -exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] = ` +exports[`Builtin tools, STDIO should expose expected tools and stable shape 1`] = ` { "toolNames": [ "componentSchemas", - "fetchDocs", + "searchPatternFlyDocs", "usePatternFlyDocs", ], } `; +exports[`Logging should allow setting logging options, default 1`] = `[]`; + +exports[`Logging should allow setting logging options, stderr 1`] = ` +[ + "[INFO]: Server logging enabled. +", + "[INFO]: Server stats enabled. +", + "[INFO]: No external resources loaded. +", + "[INFO]: No external tools loaded. +", + "[INFO]: Registered resource: patternfly-context +", + "[INFO]: Registered resource: patternfly-docs-index +", + "[INFO]: Registered resource: patternfly-docs-template +", + "[INFO]: Registered resource: patternfly-schemas-index +", + "[INFO]: Registered resource: patternfly-schemas-template +", + "[INFO]: Registered tool: usePatternFlyDocs +", + "[INFO]: Registered tool: searchPatternFlyDocs +", + "[INFO]: Registered tool: componentSchemas +", + "[INFO]: @patternfly/patternfly-mcp server running on stdio transport +", +] +`; + +exports[`Logging should allow setting logging options, with log level filtering 1`] = `[]`; + +exports[`Logging should allow setting logging options, with mcp protocol 1`] = ` +[ + { + "method": "notifications/message", + "params": { + "data": "@patternfly/patternfly-mcp server running on stdio transport", + "level": "info", + "logger": "@patternfly/patternfly-mcp", + }, + }, +] +`; + exports[`Tools should interact with a tool, echo basic tool 1`] = ` { "args": { diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts index 4f14869..74809a7 100644 --- a/tests/httpTransport.test.ts +++ b/tests/httpTransport.test.ts @@ -7,7 +7,7 @@ import { createMcpTool } from '../dist/index.js'; import { startServer, type HttpTransportClient, type RpcRequest } from './utils/httpTransportClient'; import { setupFetchMock } from './utils/fetchMock'; -describe('PatternFly MCP, HTTP Transport', () => { +describe('Builtin tools, HTTP transport', () => { let FETCH_MOCK: Awaited> | undefined; let CLIENT: HttpTransportClient | undefined; @@ -72,7 +72,7 @@ describe('PatternFly MCP, HTTP Transport', () => { const tools = response?.result?.tools || []; const toolNames = tools.map((tool: any) => tool.name).sort(); - expect({ toolNames }).toMatchSnapshot(); + expect({ toolNames }).toMatchSnapshot('tools'); }); it('should concatenate headers and separator with two local files', async () => { @@ -94,7 +94,7 @@ describe('PatternFly MCP, HTTP Transport', () => { const response = await CLIENT?.send(req); const text = response?.result?.content?.[0]?.text || ''; - expect(text.startsWith('# Documentation from')).toBe(true); + expect(text.startsWith('# Documentation')).toBe(true); expect(text).toMatchSnapshot(); }); @@ -105,7 +105,7 @@ describe('PatternFly MCP, HTTP Transport', () => { id: 1, method: 'tools/call', params: { - name: 'fetchDocs', + name: 'usePatternFlyDocs', arguments: { urlList: [ 'https://www.patternfly.org/notARealPath/README.md', @@ -118,13 +118,117 @@ describe('PatternFly MCP, HTTP Transport', () => { const response = await CLIENT.send(req); const text = response?.result?.content?.[0]?.text || ''; - expect(text.startsWith('# Documentation from')).toBe(true); + expect(text.startsWith('# Documentation')).toBe(true); expect(text).toMatchSnapshot(); await CLIENT.close(); }); }); -describe('Inline tools over HTTP', () => { +describe('Builtin resources, HTTP transport', () => { + let FETCH_MOCK: Awaited> | undefined; + let CLIENT: HttpTransportClient | undefined; + + beforeAll(async () => { + FETCH_MOCK = await setupFetchMock({ + routes: [ + { + url: /\/README\.md$/, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: `# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration` + }, + { + url: /.*button.*/i, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: '# Test Document\n\nThis is a test document for mocking remote HTTP requests.' + } + ], + excludePorts: [5002] + }); + + CLIENT = await startServer({ http: { port: 5002 }, logging: { level: 'debug', protocol: true } }); + }); + + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + CLIENT = undefined; + } + + if (FETCH_MOCK) { + await FETCH_MOCK.cleanup(); + } + }); + + it('should expose expected resources and templates', async () => { + const resources = await CLIENT?.send({ method: 'resources/list' }); + const updatedResources = resources?.result?.resources || []; + const resourceNames = updatedResources.map((resource: any) => resource.uri).sort(); + + const templates = await CLIENT?.send({ method: 'resources/templates/list' }); + const updatedTemplates = templates?.result?.resourceTemplates || []; + const templateNames = updatedTemplates.map((template: any) => template.uriTemplate).sort(); + + expect({ resourceNames, templateNames }).toMatchSnapshot('resources'); + }); + + it('should read the patternfly-context resource', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://context' } + }); + const content = response?.result.contents[0]; + + expect(content.text).toContain('PatternFly is an open-source design system'); + expect(content.mimeType).toBe('text/markdown'); + }); + + it('should read the patternfly-docs-index', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://docs/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://docs/index'); + expect(content.text).toContain('PatternFly Documentation Index'); + }); + + it('should read a doc through a template', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://docs/Button' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://docs/Button'); + expect(content.text).toContain('This is a test document for mocking remote HTTP requests'); + }); + + it('should read the patternfly-schemas-index', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://schemas/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://schemas/index'); + expect(content.text).toContain('PatternFly Component Names Index'); + }); +}); + +describe('Inline tools, HTTP transport', () => { let CLIENT: HttpTransportClient | undefined; afterAll(async () => { diff --git a/tests/stdioTransport.test.ts b/tests/stdioTransport.test.ts index 88c5505..c96459c 100644 --- a/tests/stdioTransport.test.ts +++ b/tests/stdioTransport.test.ts @@ -12,7 +12,7 @@ import { } from './utils/stdioTransportClient'; import { setupFetchMock } from './utils/fetchMock'; -describe('PatternFly MCP, STDIO', () => { +describe('Builtin tools, STDIO', () => { let FETCH_MOCK: Awaited> | undefined; let CLIENT: StdioTransportClient; let URL_MOCK: string; @@ -90,7 +90,7 @@ describe('PatternFly MCP, STDIO', () => { const response = await CLIENT.send(req); const text = response?.result?.content?.[0]?.text || ''; - expect(text.startsWith('# Documentation from')).toBe(true); + expect(text.startsWith('# Documentation')).toBe(true); expect(text).toMatchSnapshot(); }); @@ -100,7 +100,7 @@ describe('PatternFly MCP, STDIO', () => { id: 1, method: 'tools/call', params: { - name: 'fetchDocs', + name: 'usePatternFlyDocs', arguments: { urlList: [ // URL_MOCK @@ -114,34 +114,100 @@ describe('PatternFly MCP, STDIO', () => { const response = await CLIENT.send(req, { timeoutMs: 10000 }); const text = response?.result?.content?.[0]?.text || ''; - expect(text.startsWith('# Documentation from')).toBe(true); + expect(text.startsWith('# Documentation')).toBe(true); expect(text).toMatchSnapshot(); }); }); -describe('Hosted mode, --docs-host', () => { +describe('Builtin resources, STDIO', () => { + let FETCH_MOCK: Awaited> | undefined; let CLIENT: StdioTransportClient; - beforeEach(async () => { - CLIENT = await startServer({ args: ['--docs-host'] }); + beforeAll(async () => { + FETCH_MOCK = await setupFetchMock({ + port: 5011, + routes: [ + { + url: /\/README\.md$/, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: `# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration` + }, + { + url: /.*\.md$/, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: '# Test Document\n\nThis is a test document for mocking remote HTTP requests.' + } + ] + }); + + CLIENT = await startServer(); }); - afterEach(async () => CLIENT.stop()); + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + } - it('should read llms-files and includes expected tokens', async () => { - const req = { - method: 'tools/call', - params: { - name: 'usePatternFlyDocs', - arguments: { urlList: ['react-core/6.0.0/llms.txt'] } - } - }; - const resp = await CLIENT.send(req); - const text = resp?.result?.content?.[0]?.text || ''; + if (FETCH_MOCK) { + await FETCH_MOCK.cleanup(); + } + }); + + it('should expose expected resources and templates', async () => { + const resources = await CLIENT.send({ method: 'resources/list' }); + const updatedResources = resources?.result?.resources || []; + const resourceNames = updatedResources.map((resource: any) => resource.uri).sort(); + + const templates = await CLIENT.send({ method: 'resources/templates/list' }); + const updatedTemplates = templates?.result?.resourceTemplates || []; + const templateNames = updatedTemplates.map((template: any) => template.uriTemplate).sort(); + + expect({ resourceNames, templateNames }).toMatchSnapshot(); + }); + + it('should read the patternfly-context resource', async () => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri: 'patternfly://context' } + }); + const content = response?.result.contents[0]; + + expect(content.text).toContain('PatternFly is an open-source design system'); + expect(content.mimeType).toBe('text/markdown'); + }); + + it('should read the patternfly-docs-index', async () => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri: 'patternfly://docs/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://docs/index'); + expect(content.text).toContain('PatternFly Documentation Index'); + }); + + it('should read the patternfly-schemas-index', async () => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri: 'patternfly://schemas/index' } + }); + const content = response?.result.contents[0]; - expect(text.startsWith('# Documentation from')).toBe(true); - expect(text.includes('react-core')).toBe(true); - expect(text.split(/\n/g).filter(Boolean).splice(1)).toMatchSnapshot(); + expect(content.uri).toBe('patternfly://schemas/index'); + expect(content.text).toContain('PatternFly Component Names Index'); }); }); diff --git a/tests/utils/httpTransportClient.ts b/tests/utils/httpTransportClient.ts index 9e29c18..79e143b 100644 --- a/tests/utils/httpTransportClient.ts +++ b/tests/utils/httpTransportClient.ts @@ -4,7 +4,15 @@ */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { ListToolsResultSchema, ResultSchema, LoggingMessageNotificationSchema, type LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { + ListToolsResultSchema, + ResultSchema, + ReadResourceResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + LoggingMessageNotificationSchema, + type LoggingLevel +} from '@modelcontextprotocol/sdk/types.js'; // @ts-ignore - dist/index.js isn't necessarily built yet, remember to build before running tests import { start, type PfMcpOptions, type PfMcpSettings, type ServerLogEvent } from '../../dist/index.js'; @@ -173,7 +181,24 @@ export const startServer = async ( sessionId: transport.sessionId, async send(request: { method: string; params?: any }): Promise { - // Use the SDK client's request method + if (request.method === 'resources/list') { + const result = await mcpClient.request(request, ListResourcesResultSchema); + + return { jsonrpc: '2.0', id: null, result: result as any }; + } + + if (request.method === 'resources/templates/list') { + const result = await mcpClient.request(request, ListResourceTemplatesResultSchema); + + return { jsonrpc: '2.0', id: null, result: result as any }; + } + + if (request.method === 'resources/read') { + const result = await mcpClient.request(request, ReadResourceResultSchema); + + return { jsonrpc: '2.0', id: null, result: result as any }; + } + // For tools/list, use the proper schema if (request.method === 'tools/list') { const result = await mcpClient.request(request, ListToolsResultSchema); diff --git a/tests/utils/stdioTransportClient.ts b/tests/utils/stdioTransportClient.ts index 91fe5e1..6081a2a 100644 --- a/tests/utils/stdioTransportClient.ts +++ b/tests/utils/stdioTransportClient.ts @@ -164,6 +164,26 @@ export const startServer = async ({ async send(request: { method: string; params?: any }, _opts?: { timeoutMs?: number }): Promise { try { // Use high-level SDK methods when available for better type safety + if (request.method === 'resources/list') { + const result = await mcpClient.listResources(request.params); + + return { jsonrpc: '2.0', id: null, result }; + } + + if (request.method === 'resources/templates/list') { + const result = await mcpClient.listResourceTemplates(request.params); + + return { jsonrpc: '2.0', id: null, result }; + } + + if (request.method === 'resources/read' && request.params?.uri) { + const result = await mcpClient.readResource({ + uri: request.params.uri + }); + + return { jsonrpc: '2.0', id: null, result }; + } + if (request.method === 'tools/list') { const result = await mcpClient.listTools(request.params);