diff --git a/README.md b/README.md index ddfe365..855de3a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ These are the most relevant NPM scripts from package.json: ## Usage -The MCP server communicates over stdio and provides access to PatternFly documentation through the following tools. Both tools accept an argument named `urlList` which must be an array of strings. Each string is either: +The MCP server can communicate over **stdio** (default) or **HTTP** transport. It provides access to PatternFly documentation through the following tools. Both tools accept an argument named `urlList` which must be an array of strings. Each string is either: - An external URL (e.g., a raw GitHub URL to a .md file), or - A local file path (e.g., documentation/.../README.md). When running with the --docs-host flag, these paths are resolved under the llms-files directory instead. @@ -143,6 +143,68 @@ 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. +## HTTP transport mode + +By default, the server communicates over stdio. To run the server over HTTP instead, use the `--http` flag. This enables the server to accept HTTP requests on a specified port and host. + +### Basic HTTP usage + +```bash +npx @patternfly/patternfly-mcp --http +``` + +This starts the server on `http://127.0.0.1:8080` (default port and host). + +### HTTP options + +- `--http`: Enable HTTP transport mode (default: stdio) +- `--port `: Port number to listen on (default: 8080) +- `--host `: Host address to bind to (default: 127.0.0.1) +- `--allowed-origins `: Comma-separated list of allowed CORS origins +- `--allowed-hosts `: Comma-separated list of allowed host headers + +#### Security note: DNS rebinding protection (default) + +This server enables DNS rebinding protection by default when running in HTTP mode. If you're behind a proxy or load balancer, ensure the client sends a correct `Host` header and configure `--allowed-hosts` accordingly. Otherwise, requests may be rejected by design. For example: + +```bash +npx @patternfly/patternfly-mcp --http \ + --host 0.0.0.0 --port 8080 \ + --allowed-hosts "localhost,127.0.0.1,example.com" +``` + +If your client runs on a different origin, also set `--allowed-origins` to allow CORS. Example: + +```bash +npx @patternfly/patternfly-mcp --http \ + --allowed-origins "http://localhost:5173,https://app.example.com" +``` + +### Examples + +Start on a custom port: +```bash +npx @patternfly/patternfly-mcp --http --port 8080 +``` + +Start on a specific host: +```bash +npx @patternfly/patternfly-mcp --http --host 0.0.0.0 --port 8080 +``` + +Start with CORS allowed origins: +```bash +npx @patternfly/patternfly-mcp --http --allowed-origins "http://localhost:3001,https://example.com" +``` + +### Port conflict handling + +If the specified port is already in use, the server will: +- Display a helpful error message with the process ID (if available) +- Suggest using a different port with `--port` or stopping the process using the port + +**Note**: The server uses memoization to prevent duplicate server instances within the same process. If you need to restart the server, simply stop the existing instance and start a new one. + ## MCP client configuration examples Most MCP clients use a JSON configuration to specify how to start this server. The server itself only reads CLI flags and environment variables, not the JSON configuration. Below are examples you can adapt to your MCP client. @@ -190,6 +252,41 @@ Most MCP clients use a JSON configuration to specify how to start this server. T } ``` +### HTTP transport mode + +```json +{ + "mcpServers": { + "patternfly-docs": { + "command": "npx", + "args": ["-y", "@patternfly/patternfly-mcp@latest", "--http", "--port", "8080"], + "description": "PatternFly docs (HTTP transport)" + } + } +} +``` + +### HTTP transport with custom options + +```json +{ + "mcpServers": { + "patternfly-docs": { + "command": "npx", + "args": [ + "-y", + "@patternfly/patternfly-mcp@latest", + "--http", + "--port", "8080", + "--host", "0.0.0.0", + "--allowed-origins", "http://localhost:3001,https://example.com" + ], + "description": "PatternFly docs (HTTP transport, custom port/host/CORS)" + } + } +} +``` + ## Inspector-CLI examples (tools/call) Note: The parameter name is urlList and it must be a JSON array of strings. @@ -254,7 +351,9 @@ const serverWithOptions = await start({ docsHost: true }); // Multiple options can be overridden const customServer = await start({ docsHost: true, - // Future CLI options can be added here + http: true, + port: 8080, + host: '0.0.0.0' }); // TypeScript users can use the CliOptions type for type safety diff --git a/jest.setupTests.ts b/jest.setupTests.ts index 55f7931..6417073 100644 --- a/jest.setupTests.ts +++ b/jest.setupTests.ts @@ -5,6 +5,23 @@ */ process.env.NODE_ENV = 'local'; +/** + * Note: Mock child_process to avoid issues with execSync in tests + */ +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execSync: (...args: any) => `${JSON.stringify(args)}` +})); + +/** + * Note: Mock pid-port to avoid ES module import issues in Jest + * - Returns undefined to simulate port is free (no process found) + */ +jest.mock('pid-port', () => ({ + __esModule: true, + portToPid: jest.fn().mockResolvedValue(undefined) +})); + /** * Note: Mock @patternfly/patternfly-component-schemas/json to avoid top-level await issues in Jest * - Individual tests can override mock diff --git a/package-lock.json b/package-lock.json index 9f10c12..3f4d33e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@modelcontextprotocol/sdk": "1.19.1", "@patternfly/patternfly-component-schemas": "1.2.0", "fastest-levenshtein": "1.0.16", + "pid-port": "2.0.0", "zod": "3.25.76" }, "bin": { @@ -2597,6 +2598,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -2604,6 +2611,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -6063,6 +6082,21 @@ "bser": "2.1.1" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7072,6 +7106,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7200,6 +7246,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -8772,6 +8830,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-statements": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", @@ -8878,6 +8948,124 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pid-port": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pid-port/-/pid-port-2.0.0.tgz", + "integrity": "sha512-EDmfRxLl6lkhPjDI+19l5pkII89xVsiCP3aGjS808f7M16DyCKSXEWthD/hjyDLn5I4gKqTVw7hSgdvdXRJDTw==", + "license": "MIT", + "dependencies": { + "execa": "^9.6.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pid-port/node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/pid-port/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pid-port/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/pid-port/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pid-port/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pid-port/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pid-port/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -9006,6 +9194,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9681,7 +9884,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -10677,6 +10879,18 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11194,6 +11408,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 089f5b5..6a45d6f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "start:dev": "tsx watch src/cli.ts --verbose --log-stderr", "test": "npm run test:lint && npm run test:types && jest --roots=src/", "test:dev": "npm test -- --watchAll", - "test:integration": "npm run build && jest --roots=tests/", + "test:integration": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --roots=tests/", "test:integration-dev": "npm run test:integration -- --watchAll", "test:lint": "eslint .", "test:lint-fix": "eslint . --fix", @@ -50,6 +50,7 @@ "@modelcontextprotocol/sdk": "1.19.1", "@patternfly/patternfly-component-schemas": "1.2.0", "fastest-levenshtein": "1.0.16", + "pid-port": "2.0.0", "zod": "3.25.76" }, "devDependencies": { diff --git a/src/__tests__/__snapshots__/logger.test.ts.snap b/src/__tests__/__snapshots__/logger.test.ts.snap index 19440cd..13ddf06 100644 --- a/src/__tests__/__snapshots__/logger.test.ts.snap +++ b/src/__tests__/__snapshots__/logger.test.ts.snap @@ -3,7 +3,7 @@ exports[`createLogger should activate stderr subscriber writes only at or above level: stderr 1`] = ` [ [ - "[INFO]: lorem ipsum :123 {"a":1} + "[INFO]: lorem ipsum :123 {"a":1} ", ], ] @@ -154,7 +154,7 @@ exports[`publish should attempt to create a log entry, msg 1`] = ` exports[`registerStderrSubscriber should activate stderr subscriber writes only at or above level: stderr 1`] = ` [ [ - "[INFO]: lorem ipsum :123 {"a":1} + "[INFO]: lorem ipsum :123 {"a":1} ", ], ] diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 8fe0234..302b31a 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -6,9 +6,17 @@ exports[`options defaults should return specific properties 1`] = ` "contextPath": "/", "docsHost": false, "docsPath": "/documentation", + "http": { + "allowedHosts": [], + "allowedOrigins": [], + "host": "127.0.0.1", + "port": 8080, + }, + "isHttp": false, "llmsFilesPath": "/llms-files", "logging": { "level": "info", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", @@ -25,6 +33,9 @@ exports[`options defaults should return specific properties 1`] = ` "pfExternalExamplesTable": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-table/src/components", "repoName": "patternfly-mcp", "resourceMemoOptions": { + "default": { + "cacheLimit": 3, + }, "fetchUrl": { "cacheErrors": false, "cacheLimit": 100, @@ -56,11 +67,6 @@ exports[`options defaults should return specific properties 1`] = ` "urlRegex": /\\^\\(https\\?:\\)\\\\/\\\\//i, "version": "0.0.0", }, - "DEFAULT_SEPARATOR": " - ---- - -", "LOG_BASENAME": "pf-mcp:log", "PF_EXTERNAL": "https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content", "PF_EXTERNAL_ACCESSIBILITY": "https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility", @@ -74,30 +80,5 @@ exports[`options defaults should return specific properties 1`] = ` "PF_EXTERNAL_EXAMPLES_TABLE": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-table/src/components", "PF_EXTERNAL_EXAMPLES_VERSION": "v6.4.0", "PF_EXTERNAL_VERSION": "fb05713aba75998b5ecf5299ee3c1a259119bd74", - "RESOURCE_MEMO_OPTIONS": { - "fetchUrl": { - "cacheErrors": false, - "cacheLimit": 100, - "expire": 180000, - }, - "readFile": { - "cacheErrors": false, - "cacheLimit": 50, - "expire": 120000, - }, - }, - "TOOL_MEMO_OPTIONS": { - "fetchDocs": { - "cacheErrors": false, - "cacheLimit": 15, - "expire": 60000, - }, - "usePatternFlyDocs": { - "cacheErrors": false, - "cacheLimit": 10, - "expire": 60000, - }, - }, - "URL_REGEX": /\\^\\(https\\?:\\)\\\\/\\\\//i, } `; diff --git a/src/__tests__/__snapshots__/options.test.ts.snap b/src/__tests__/__snapshots__/options.test.ts.snap index f62303d..98cdae1 100644 --- a/src/__tests__/__snapshots__/options.test.ts.snap +++ b/src/__tests__/__snapshots__/options.test.ts.snap @@ -1,10 +1,119 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`parseCliOptions should attempt to parse args with --allowed-hosts 1`] = ` +{ + "docsHost": false, + "http": { + "allowedHosts": [ + "localhost", + "127.0.0.1", + ], + "allowedOrigins": undefined, + "host": undefined, + "port": undefined, + }, + "isHttp": true, + "logging": { + "level": "info", + "logger": "@patternfly/patternfly-mcp", + "protocol": false, + "stderr": false, + "transport": "stdio", + }, +} +`; + +exports[`parseCliOptions should attempt to parse args with --allowed-origins 1`] = ` +{ + "docsHost": false, + "http": { + "allowedHosts": undefined, + "allowedOrigins": [ + "https://app.com", + "https://admin.app.com", + ], + "host": undefined, + "port": undefined, + }, + "isHttp": true, + "logging": { + "level": "info", + "logger": "@patternfly/patternfly-mcp", + "protocol": false, + "stderr": false, + "transport": "stdio", + }, +} +`; + exports[`parseCliOptions should attempt to parse args with --docs-host flag 1`] = ` { "docsHost": true, + "http": undefined, + "isHttp": false, + "logging": { + "level": "info", + "logger": "@patternfly/patternfly-mcp", + "protocol": false, + "stderr": false, + "transport": "stdio", + }, +} +`; + +exports[`parseCliOptions should attempt to parse args with --http and --host 1`] = ` +{ + "docsHost": false, + "http": { + "allowedHosts": undefined, + "allowedOrigins": undefined, + "host": "0.0.0.0", + "port": undefined, + }, + "isHttp": true, + "logging": { + "level": "info", + "logger": "@patternfly/patternfly-mcp", + "protocol": false, + "stderr": false, + "transport": "stdio", + }, +} +`; + +exports[`parseCliOptions should attempt to parse args with --http and --port 1`] = ` +{ + "docsHost": false, + "http": { + "allowedHosts": undefined, + "allowedOrigins": undefined, + "host": undefined, + "port": undefined, + }, + "isHttp": true, + "logging": { + "level": "info", + "logger": "@patternfly/patternfly-mcp", + "protocol": false, + "stderr": false, + "transport": "stdio", + }, +} +`; + +exports[`parseCliOptions should attempt to parse args with --http flag 1`] = ` +{ + "docsHost": false, + "http": { + "allowedHosts": undefined, + "allowedOrigins": undefined, + "host": undefined, + "port": undefined, + }, + "isHttp": true, "logging": { "level": "info", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", @@ -15,8 +124,11 @@ exports[`parseCliOptions should attempt to parse args with --docs-host flag 1`] exports[`parseCliOptions should attempt to parse args with --log-level flag 1`] = ` { "docsHost": false, + "http": undefined, + "isHttp": false, "logging": { "level": "warn", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", @@ -27,8 +139,11 @@ exports[`parseCliOptions should attempt to parse args with --log-level flag 1`] exports[`parseCliOptions should attempt to parse args with --log-stderr flag and --log-protocol flag 1`] = ` { "docsHost": false, + "http": undefined, + "isHttp": false, "logging": { "level": "info", + "logger": "@patternfly/patternfly-mcp", "protocol": true, "stderr": true, "transport": "stdio", @@ -39,8 +154,11 @@ exports[`parseCliOptions should attempt to parse args with --log-stderr flag and exports[`parseCliOptions should attempt to parse args with --verbose flag 1`] = ` { "docsHost": false, + "http": undefined, + "isHttp": false, "logging": { "level": "debug", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", @@ -51,8 +169,11 @@ exports[`parseCliOptions should attempt to parse args with --verbose flag 1`] = exports[`parseCliOptions should attempt to parse args with --verbose flag and --log-level flag 1`] = ` { "docsHost": false, + "http": undefined, + "isHttp": false, "logging": { "level": "debug", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", @@ -63,8 +184,11 @@ exports[`parseCliOptions should attempt to parse args with --verbose flag and -- exports[`parseCliOptions should attempt to parse args with other arguments 1`] = ` { "docsHost": false, + "http": undefined, + "isHttp": false, "logging": { "level": "info", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", @@ -75,8 +199,11 @@ exports[`parseCliOptions should attempt to parse args with other arguments 1`] = exports[`parseCliOptions should attempt to parse args without --docs-host flag 1`] = ` { "docsHost": false, + "http": undefined, + "isHttp": false, "logging": { "level": "info", + "logger": "@patternfly/patternfly-mcp", "protocol": false, "stderr": false, "transport": "stdio", diff --git a/src/__tests__/__snapshots__/server.http.test.ts.snap b/src/__tests__/__snapshots__/server.http.test.ts.snap new file mode 100644 index 0000000..40071db --- /dev/null +++ b/src/__tests__/__snapshots__/server.http.test.ts.snap @@ -0,0 +1,336 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`getProcessOnPort should attempt to find a process listening on a port: ps fallback 1`] = ` +{ + "command": "["ps -p 123456789 -o command=",{"encoding":"utf8","stdio":["ignore","pipe","pipe"]}]", + "pid": 123456789, +} +`; + +exports[`startHttpTransport accept and reject paths, accept a basic path 1`] = ` +{ + "isRequestCalled": true, + "requestCalls": [ + [ + { + "method": "GET", + "socket": { + "remoteAddress": "127.0.0.1", + }, + "url": "/mcp", + }, + { + "end": [MockFunction], + "setHeader": [MockFunction], + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + ], + ], + "response": { + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + "responseCalls": [], +} +`; + +exports[`startHttpTransport accept and reject paths, accept a casing insensitive path 1`] = ` +{ + "isRequestCalled": true, + "requestCalls": [ + [ + { + "method": "GET", + "socket": { + "remoteAddress": "127.0.0.1", + }, + "url": "/MCP", + }, + { + "end": [MockFunction], + "setHeader": [MockFunction], + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + ], + ], + "response": { + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + "responseCalls": [], +} +`; + +exports[`startHttpTransport accept and reject paths, accept a path with query params 1`] = ` +{ + "isRequestCalled": true, + "requestCalls": [ + [ + { + "method": "GET", + "socket": { + "remoteAddress": "127.0.0.1", + }, + "url": "/MCP/SSE?x=1", + }, + { + "end": [MockFunction], + "setHeader": [MockFunction], + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + ], + ], + "response": { + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + "responseCalls": [], +} +`; + +exports[`startHttpTransport accept and reject paths, accept a trailing slash 1`] = ` +{ + "isRequestCalled": true, + "requestCalls": [ + [ + { + "method": "GET", + "socket": { + "remoteAddress": "127.0.0.1", + }, + "url": "/mcp/", + }, + { + "end": [MockFunction], + "setHeader": [MockFunction], + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + ], + ], + "response": { + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + "responseCalls": [], +} +`; + +exports[`startHttpTransport accept and reject paths, accept a trailing slash with path 1`] = ` +{ + "isRequestCalled": true, + "requestCalls": [ + [ + { + "method": "GET", + "socket": { + "remoteAddress": "127.0.0.1", + }, + "url": "/mcp/sse", + }, + { + "end": [MockFunction], + "setHeader": [MockFunction], + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + ], + ], + "response": { + "shouldKeepAlive": undefined, + "statusCode": undefined, + }, + "responseCalls": [], +} +`; + +exports[`startHttpTransport accept and reject paths, reject a malformed path 1`] = ` +{ + "isRequestCalled": false, + "requestCalls": [], + "response": { + "shouldKeepAlive": false, + "statusCode": 404, + }, + "responseCalls": [ + [ + "Content-Type", + "text/plain", + ], + [ + "X-Content-Type-Options", + "nosniff", + ], + [ + "Not Found", + ], + ], +} +`; + +exports[`startHttpTransport accept and reject paths, reject a malformed url 1`] = ` +{ + "isRequestCalled": false, + "requestCalls": [], + "response": { + "shouldKeepAlive": false, + "statusCode": 400, + }, + "responseCalls": [ + [ + "Content-Type", + "text/plain", + ], + [ + "X-Content-Type-Options", + "nosniff", + ], + [ + "Bad Request", + ], + ], +} +`; + +exports[`startHttpTransport accept and reject paths, reject a partial path 1`] = ` +{ + "isRequestCalled": false, + "requestCalls": [], + "response": { + "shouldKeepAlive": false, + "statusCode": 404, + }, + "responseCalls": [ + [ + "Content-Type", + "text/plain", + ], + [ + "X-Content-Type-Options", + "nosniff", + ], + [ + "Not Found", + ], + ], +} +`; + +exports[`startHttpTransport accept and reject paths, reject a root path 1`] = ` +{ + "isRequestCalled": false, + "requestCalls": [], + "response": { + "shouldKeepAlive": false, + "statusCode": 404, + }, + "responseCalls": [ + [ + "Content-Type", + "text/plain", + ], + [ + "X-Content-Type-Options", + "nosniff", + ], + [ + "Not Found", + ], + ], +} +`; + +exports[`startHttpTransport accept and reject paths, reject an incorrect path 1`] = ` +{ + "isRequestCalled": false, + "requestCalls": [], + "response": { + "shouldKeepAlive": false, + "statusCode": 404, + }, + "responseCalls": [ + [ + "Content-Type", + "text/plain", + ], + [ + "X-Content-Type-Options", + "nosniff", + ], + [ + "Not Found", + ], + ], +} +`; + +exports[`startHttpTransport accept and reject paths, reject an malformed path 1`] = ` +{ + "isRequestCalled": false, + "requestCalls": [], + "response": { + "shouldKeepAlive": false, + "statusCode": 404, + }, + "responseCalls": [ + [ + "Content-Type", + "text/plain", + ], + [ + "X-Content-Type-Options", + "nosniff", + ], + [ + "Not Found", + ], + ], +} +`; + +exports[`startHttpTransport should start HTTP server, with port and host: server setup 1`] = ` +{ + "serverClose": [ + [ + [Function], + ], + ], + "setupHandlers": [ + [ + "connection", + [Function], + ], + [ + "error", + [Function], + ], + ], + "setupServer": [ + [ + { + "handleRequest": [MockFunction], + "sessionId": "test-session-123", + }, + ], + [ + 3000, + "localhost", + [Function], + ], + ], + "setupTransport": [ + [ + { + "enableDnsRebindingProtection": true, + "enableJsonResponse": false, + "onsessionclosed": [Function], + "onsessioninitialized": [Function], + "sessionIdGenerator": [Function], + }, + ], + ], +} +`; diff --git a/src/__tests__/__snapshots__/server.logger.test.ts.snap b/src/__tests__/__snapshots__/server.logger.test.ts.snap index c672c59..6903046 100644 --- a/src/__tests__/__snapshots__/server.logger.test.ts.snap +++ b/src/__tests__/__snapshots__/server.logger.test.ts.snap @@ -52,11 +52,19 @@ exports[`createServerLogger should attempt to subscribe and unsubscribe from a c exports[`createServerLogger should return a memoized server logger that avoids duplicate sinks; teardown stops emissions: stderr 1`] = ` [ [ - "[INFO]: lorem ipsum, dolor sit info + "[DEBUG]: a ", ], [ - "[INFO]: dolor sit amet + "[DEBUG]: b +", + ], + [ + "[INFO]: lorem ipsum, dolor sit info +", + ], + [ + "[INFO]: dolor sit amet ", ], ] diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index aa17daa..75449a4 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -1,17 +1,86 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`runServer should allow server to be stopped, http stop server: diagnostics 1`] = ` +{ + "events": [ + [ + "Registered tool: usePatternFlyDocs", + ], + [ + "Registered tool: fetchDocs", + ], + [ + "Registered tool: componentSchemas", + ], + [ + "test-server server running on HTTP transport", + ], + [ + " +test-server server shutting down... ", + ], + [ + "test-server shutting down...", + ], + [ + "...closing HTTP transport", + ], + [ + "...closing Server", + ], + [ + "test-server closed! +", + ], + ], +} +`; + +exports[`runServer should allow server to be stopped, stdio stop server: diagnostics 1`] = ` +{ + "events": [ + [ + "Registered tool: usePatternFlyDocs", + ], + [ + "Registered tool: fetchDocs", + ], + [ + "Registered tool: componentSchemas", + ], + [ + "test-server server running on stdio transport", + ], + [ + " +test-server server shutting down... ", + ], + [ + "test-server shutting down...", + ], + [ + "...closing Server", + ], + [ + "test-server closed! +", + ], + ], +} +`; + exports[`runServer should attempt to run server, create transport, connect, and log success message: diagnostics 1`] = ` { "events": [ [ - "PatternFly MCP server running on stdio", + "test-server-4 server running on stdio transport", ], ], "mcpServer": [ [ { - "name": "@patternfly/patternfly-mcp", - "version": "0.0.0", + "name": "test-server-4", + "version": "1.0.0", }, { "capabilities": { @@ -34,14 +103,14 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos { "events": [ [ - "PatternFly MCP server running on stdio", + "test-server-7 server running on stdio transport", ], ], "mcpServer": [ [ { - "name": "@patternfly/patternfly-mcp", - "version": "0.0.0", + "name": "test-server-7", + "version": "1.0.0", }, { "capabilities": { @@ -59,14 +128,14 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl { "events": [ [ - "PatternFly MCP server running on stdio", + "test-server-8 server running on stdio transport", ], ], "mcpServer": [ [ { - "name": "@patternfly/patternfly-mcp", - "version": "0.0.0", + "name": "test-server-8", + "version": "1.0.0", }, { "capabilities": { @@ -92,14 +161,14 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` "Registered tool: loremIpsum", ], [ - "PatternFly MCP server running on stdio", + "test-server-5 server running on stdio transport", ], ], "mcpServer": [ [ { - "name": "@patternfly/patternfly-mcp", - "version": "0.0.0", + "name": "test-server-5", + "version": "1.0.0", }, { "capabilities": { @@ -137,14 +206,14 @@ exports[`runServer should attempt to run server, register multiple tools: diagno "Registered tool: dolorSit", ], [ - "PatternFly MCP server running on stdio", + "test-server-6 server running on stdio transport", ], ], "mcpServer": [ [ { - "name": "@patternfly/patternfly-mcp", - "version": "0.0.0", + "name": "test-server-6", + "version": "1.0.0", }, { "capabilities": { @@ -184,13 +253,13 @@ exports[`runServer should attempt to run server, use custom options: diagnostics { "events": [ [ - "PatternFly MCP server running on stdio", + "test-server-3 server running on stdio transport", ], ], "mcpServer": [ [ { - "name": "test-server", + "name": "test-server-3", "version": "1.0.0", }, { @@ -210,7 +279,7 @@ exports[`runServer should attempt to run server, use custom options: diagnostics } `; -exports[`runServer should attempt to run server, use default tools: diagnostics 1`] = ` +exports[`runServer should attempt to run server, use default tools, http: diagnostics 1`] = ` { "events": [ [ @@ -223,14 +292,525 @@ exports[`runServer should attempt to run server, use default tools: diagnostics "Registered tool: componentSchemas", ], [ - "PatternFly MCP server running on stdio", + "test-server-2 server running on HTTP transport", ], ], "mcpServer": [ [ { - "name": "@patternfly/patternfly-mcp", - "version": "0.0.0", + "name": "test-server-2", + "version": "1.0.0", + }, + { + "capabilities": { + "tools": {}, + }, + }, + ], + ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], + "registerTool": [ + [ + "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 .md files or local file paths that the user has made available. + + + [@patternfly/AboutModal - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/about-modal/about-modal.md) +[@patternfly/AboutModal - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/about-modal/about-modal.md) +[@patternfly/AboutModal - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/AboutModal/examples/AboutModal.md) +[@patternfly/Accordion - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/accordion/accordion.md) +[@patternfly/Accordion - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/accordion/accordion.md) +[@patternfly/Accordion - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Accordion/examples/Accordion.md) +[@patternfly/ActionList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/action-list/action-list.md) +[@patternfly/ActionList - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/action-list/action-list.md) +[@patternfly/ActionList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/ActionList/examples/ActionList.md) +[@patternfly/Alert - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/alert/alert.md) +[@patternfly/Alert - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/alert/alert.md) +[@patternfly/Alert - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Alert/examples/Alert.md) +[@patternfly/ApplicationLauncher - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/application-launcher/application-launcher.md) +[@patternfly/ApplicationLauncher - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/application-launcher/application-launcher.md) +[@patternfly/Avatar - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/avatar/avatar.md) +[@patternfly/Avatar - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/avatar/avatar.md) +[@patternfly/Avatar - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Avatar/examples/Avatar.md) +[@patternfly/BackToTop - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/back-to-top/back-to-top.md) +[@patternfly/BackToTop - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/BackToTop/examples/BackToTop.md) +[@patternfly/Backdrop - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/backdrop/backdrop.md) +[@patternfly/Backdrop - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/backdrop/backdrop.md) +[@patternfly/Backdrop - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Backdrop/examples/Backdrop.md) +[@patternfly/BackgroundImage - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/background-image/background-image.md) +[@patternfly/BackgroundImage - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/background-image/background-image.md) +[@patternfly/BackgroundImage - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/BackgroundImage/examples/BackgroundImage.md) +[@patternfly/Badge - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/badge/badge.md) +[@patternfly/Badge - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/badge/badge.md) +[@patternfly/Badge - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Badge/examples/Badge.md) +[@patternfly/Banner - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/banner/banner.md) +[@patternfly/Banner - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/banner/banner.md) +[@patternfly/Banner - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Banner/examples/Banner.md) +[@patternfly/Brand - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/brand/brand.md) +[@patternfly/Brand - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/brand/brand.md) +[@patternfly/Brand - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Brand/examples/Brand.md) +[@patternfly/Breadcrumb - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/breadcrumb/breadcrumb.md) +[@patternfly/Breadcrumb - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/breadcrumb/breadcrumb.md) +[@patternfly/Breadcrumb - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Breadcrumb/examples/Breadcrumb.md) +[@patternfly/Button - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md) +[@patternfly/Button - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md) +[@patternfly/Button - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Button/examples/Button.md) +[@patternfly/CalendarMonth - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/calendar-month/calendar-month.md) +[@patternfly/CalendarMonth - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/calendar-month/calendar-month.md) +[@patternfly/CalendarMonth - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/CalendarMonth/examples/CalendarMonth.md) +[@patternfly/Card - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/card/card.md) +[@patternfly/Card - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/card/card.md) +[@patternfly/Card - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Card/examples/Card.md) +[@patternfly/Checkbox - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/checkbox/checkbox.md) +[@patternfly/Checkbox - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/checkbox/checkbox.md) +[@patternfly/Checkbox - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Checkbox/examples/Checkbox.md) +[@patternfly/ChipDeprecated - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/chip/chip.md) +[@patternfly/ChipDeprecated - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/chip/chip.md) +[@patternfly/ClipboardCopy - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/clipboard-copy/clipboard-copy.md) +[@patternfly/ClipboardCopy - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/clipboard-copy/clipboard-copy.md) +[@patternfly/ClipboardCopy - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/ClipboardCopy/examples/ClipboardCopy.md) +[@patternfly/CodeBlock - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/code-block/code-block.md) +[@patternfly/CodeBlock - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/CodeBlock/examples/CodeBlock.md) +[@patternfly/CodeEditor - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/code-editor/code-editor.md) +[@patternfly/CodeEditor - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/code-editor/code-editor.md) +[@patternfly/Content - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/content/content.md) +[@patternfly/Content - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Content/examples/Content.md) +[@patternfly/DataList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/data-list/data-list.md) +[@patternfly/DataList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/DataList/examples/DataList.md) +[@patternfly/DatePicker - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/date-picker/date-picker.md) +[@patternfly/DatePicker - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/DatePicker/examples/DatePicker.md) +[@patternfly/DescriptionList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/description-list/description-list.md) +[@patternfly/DescriptionList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/DescriptionList/examples/DescriptionList.md) +[@patternfly/Divider - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/divider/divider.md) +[@patternfly/Divider - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Divider/examples/Divider.md) +[@patternfly/DragAndDrop - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/drag-and-drop/drag.md) +[@patternfly/Drawer - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/drawer/drawer.md) +[@patternfly/Drawer - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Drawer/examples/Drawer.md) +[@patternfly/Dropdown - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/dropdown/dropdown.md) +[@patternfly/Dropdown - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Dropdown/examples/Dropdown.md) +[@patternfly/DualListSelector - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/dual-list-selector/dual-list-selector.md) +[@patternfly/DualListSelector - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md) +[@patternfly/EmptyState - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/empty-state/empty-state.md) +[@patternfly/EmptyState - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/EmptyState/examples/EmptyState.md) +[@patternfly/ExpandableSection - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/expandable-section/expandable-section.md) +[@patternfly/ExpandableSection - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/expandable-section/expandable-section.md) +[@patternfly/ExpandableSection - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/ExpandableSection/examples/ExpandableSection.md) +[@patternfly/FileUpload - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/file-upload/file-upload.md) +[@patternfly/FileUpload - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/FileUpload/examples/FileUpload.md) +[@patternfly/Form - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/form/forms.md) +[@patternfly/Form - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Form/examples/Form.md) +[@patternfly/FormControl - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/form-control/form-control.md) +[@patternfly/FormSelect - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/form-select/form-select.md) +[@patternfly/FormSelect - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/FormSelect/examples/FormSelect.md) +[@patternfly/HelperText - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/helper-text/helper-text.md) +[@patternfly/HelperText - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/helper-text/helper-text.md) +[@patternfly/HelperText - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/HelperText/examples/HelperText.md) +[@patternfly/Hint - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/hint/hint.md) +[@patternfly/Hint - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Hint/examples/Hint.md) +[@patternfly/Icon - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Icon/examples/Icon.md) +[@patternfly/InlineEdit - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/inline-edit/inline-edit.md) +[@patternfly/InputGroup - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/input-group/input-group.md) +[@patternfly/InputGroup - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/InputGroup/examples/InputGroup.md) +[@patternfly/JumpLinks - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/jump-link/jump-link.md) +[@patternfly/JumpLinks - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/jump-links/jump-links.md) +[@patternfly/JumpLinks - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/JumpLinks/examples/JumpLinks.md) +[@patternfly/Label - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/label/label.md) +[@patternfly/Label - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/label/label.md) +[@patternfly/Label - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Label/examples/Label.md) +[@patternfly/List - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/list/list.md) +[@patternfly/List - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/List/examples/List.md) +[@patternfly/LoginPage - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/login-page/login-page.md) +[@patternfly/LoginPage - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/LoginPage/examples/LoginPage.md) +[@patternfly/Masthead - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/masthead/masthead.md) +[@patternfly/Masthead - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Masthead/examples/Masthead.md) +[@patternfly/Menu - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/menu/menu.md) +[@patternfly/Menu - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/menu/menu.md) +[@patternfly/Menu - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Menu/examples/Menu.md) +[@patternfly/MenuToggle - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/menu-toggle/menu-toggle.md) +[@patternfly/MenuToggle - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/menu-toggle/menu-toggle.md) +[@patternfly/MenuToggle - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/MenuToggle/examples/MenuToggle.md) +[@patternfly/Modal - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/modal/modal.md) +[@patternfly/Modal - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/modal/modal.md) +[@patternfly/Modal - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Modal/examples/Modal.md) +[@patternfly/Navigation - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/navigation/navigation.md) +[@patternfly/Navigation - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/navigation/navigation.md) +[@patternfly/NotificationBadge - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/notification-badge/notification-badge.md) +[@patternfly/NotificationBadge - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/NotificationBadge/examples/NotificationBadge.md) +[@patternfly/NotificationDrawer - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/notification-drawer/notification-drawer.md) +[@patternfly/NotificationDrawer - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/NotificationDrawer/examples/NotificationDrawer.md) +[@patternfly/NumberInput - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/number%20input/number-input.md) +[@patternfly/NumberInput - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/NumberInput/examples/NumberInput.md) +[@patternfly/OverflowMenu - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/overflow-menu/overflow-menu.md) +[@patternfly/OverflowMenu - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/OverflowMenu/examples/OverflowMenu.md) +[@patternfly/Page - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/page/page.md) +[@patternfly/Page - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/page/page.md) +[@patternfly/Page - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Page/examples/Page.md) +[@patternfly/Pagination - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/pagination/pagination.md) +[@patternfly/Pagination - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Pagination/examples/Pagination.md) +[@patternfly/Panel - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/panel/panel.md) +[@patternfly/Panel - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Panel/examples/Panel.md) +[@patternfly/Popover - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/popover/popover.md) +[@patternfly/Popover - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Popover/examples/Popover.md) +[@patternfly/Progress - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/progress/progress.md) +[@patternfly/Progress - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/progress/progress.md) +[@patternfly/Progress - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Progress/examples/Progress.md) +[@patternfly/ProgressStepper - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/progress-stepper/progress-stepper.md) +[@patternfly/ProgressStepper - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/progress-stepper/progress-stepper.md) +[@patternfly/ProgressStepper - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/ProgressStepper/examples/ProgressStepper.md) +[@patternfly/Radio - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/radio/radio.md) +[@patternfly/Radio - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/radio/radio.md) +[@patternfly/Radio - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Radio/examples/Radio.md) +[@patternfly/SearchInput - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/search-input/search-input.md) +[@patternfly/SearchInput - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/SearchInput/examples/SearchInput.md) +[@patternfly/Select - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/select/select.md) +[@patternfly/Select - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Select/examples/Select.md) +[@patternfly/Sidebar - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/sidebar/sidebar.md) +[@patternfly/Sidebar - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/sidebar/sidebar.md) +[@patternfly/Sidebar - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Sidebar/examples/Sidebar.md) +[@patternfly/SimpleList - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/simple-list/simple-list.md) +[@patternfly/SimpleList - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/SimpleList/examples/SimpleList.md) +[@patternfly/Skeleton - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/skeleton/skeleton.md) +[@patternfly/Skeleton - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/skeleton/skeleton.md) +[@patternfly/Skeleton - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Skeleton/examples/Skeleton.md) +[@patternfly/SkipToContent - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/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/fb05713aba75998b5ecf5299ee3c1a259119bd74/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/tags/v6.4.0/packages/react-core/src/components/SkipToContent/examples/SkipToContent.md) +[@patternfly/Slider - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/slider/slider.md) +[@patternfly/Slider - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Slider/examples/Slider.md) +[@patternfly/Spinner - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/spinner/spinner.md) +[@patternfly/Spinner - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Spinner/examples/Spinner.md) +[@patternfly/Switch - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/switch/switch.md) +[@patternfly/Switch - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/switch/switch.md) +[@patternfly/Switch - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Switch/examples/Switch.md) +[@patternfly/Table - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/table/table.md) +[@patternfly/Table - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-table/src/components/Table/examples/Table.md) +[@patternfly/Tabs - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tabs/tabs.md) +[@patternfly/Tabs - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/tabs/tabs.md) +[@patternfly/Tabs - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Tabs/examples/Tabs.md) +[@patternfly/TextArea - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/text-area/text-area.md) +[@patternfly/TextArea - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/TextArea/examples/TextArea.md) +[@patternfly/TextInput - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/text-input/text-input.md) +[@patternfly/TextInput - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/TextInput/examples/TextInput.md) +[@patternfly/TextInputGroup - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/text-input-group/text-input-group.md) +[@patternfly/TextInputGroup - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/TextInputGroup/examples/TextInputGroup.md) +[@patternfly/TileDeprecated - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tile/tile.md) +[@patternfly/TimePicker - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/time%20picker/time-picker.md) +[@patternfly/TimePicker - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/TimePicker/examples/TimePicker.md) +[@patternfly/Timestamp - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/timestamp/timestamp.md) +[@patternfly/Timestamp - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Timestamp/examples/Timestamp.md) +[@patternfly/Title - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/title/title.md) +[@patternfly/Title - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/title/title.md) +[@patternfly/Title - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Title/examples/Title.md) +[@patternfly/ToggleGroup - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/toggle-group/toggle-group.md) +[@patternfly/ToggleGroup - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/ToggleGroup/examples/ToggleGroup.md) +[@patternfly/Toolbar - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/toolbar/toolbar.md) +[@patternfly/Toolbar - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Toolbar/examples/Toolbar.md) +[@patternfly/Tooltip - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tooltip/tooltip.md) +[@patternfly/Tooltip - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/tooltip/tooltip.md) +[@patternfly/Tooltip - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Tooltip/examples/Tooltip.md) +[@patternfly/TreeView - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/tree-view/tree-view.md) +[@patternfly/TreeView - Accessibility](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/tree-view/tree-view.md) +[@patternfly/TreeView - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/TreeView/examples/TreeView.md) +[@patternfly/Truncate - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/truncate/truncate.md) +[@patternfly/Truncate - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Truncate/examples/Truncate.md) +[@patternfly/Wizard - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/wizard/wizard.md) +[@patternfly/Wizard - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Wizard/examples/Wizard.md) + [@patternfly/Bullseye - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/bullseye.md) +[@patternfly/Bullseye - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Bullseye/examples/Bullseye.md) +[@patternfly/Flex - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/flex.md) +[@patternfly/Flex - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Flex/examples/Flex.md) +[@patternfly/Gallery - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/gallery.md) +[@patternfly/Gallery - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Gallery/examples/Gallery.md) +[@patternfly/Grid - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/grid.md) +[@patternfly/Grid - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Grid/examples/Grid.md) +[@patternfly/Level - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/level.md) +[@patternfly/Level - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Level/examples/Level.md) +[@patternfly/Split - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/split.md) +[@patternfly/Split - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Split/examples/Split.md) +[@patternfly/Stack - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts/stack.md) +[@patternfly/Stack - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts/Stack/examples/Stack.md) + [@patternfly/Charts - Colors for Charts - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartTheme/examples/ChartTheme.md) +[@patternfly/Charts - Area Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/area-chart/area-chart.md) +[@patternfly/Charts - Area Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartArea/examples/ChartArea.md) +[@patternfly/Charts - Bar Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/bar-chart/bar-chart.md) +[@patternfly/Charts - Bar Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartBar/examples/ChartBar.md) +[@patternfly/Charts - Box Plot Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartBoxPlot/examples/ChartBoxPlot.md) +[@patternfly/Charts - Bullet Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/bullet-chart/bullet-chart.md) +[@patternfly/Charts - Bullet Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartBullet/examples/ChartBullet.md) +[@patternfly/Charts - Donut Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/donut-chart/donut-chart.md) +[@patternfly/Charts - Donut Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartDonut/examples/ChartDonut.md) +[@patternfly/Charts - Donut Utilization Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/donut-utilization-chart/donut-utilization-chart.md) +[@patternfly/Charts - Donut Utilization Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartDonutUtilization/examples/ChartDonutUtilization.md) +[@patternfly/Charts - Line Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/line-chart/line-chart.md) +[@patternfly/Charts - Line Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartLine/examples/ChartLine.md) +[@patternfly/Charts - Pie Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/pie-chart/pie-chart.md) +[@patternfly/Charts - Pie Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartPie/examples/ChartPie.md) +[@patternfly/Charts - Scatter Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/scatter-chart/scatter-chart.md) +[@patternfly/Charts - Scatter Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartScatter/examples/ChartScatter.md) +[@patternfly/Charts - Sparkline Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/sparkline-chart/sparkline-chart.md) +[@patternfly/Charts - Sparkline Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/Sparkline/examples/sparkline.md) +[@patternfly/Charts - Stack Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/stacked-chart/stacked-chart.md) +[@patternfly/Charts - Stack Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartStack/examples/ChartStack.md) +[@patternfly/Charts - Threshold Chart - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/threshold-chart/threshold-chart.md) +[@patternfly/Charts - Threshold Chart - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartThreshold/examples/ChartThreshold.md) +[@patternfly/Charts - Legend - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/legend-chart/legend-chart.md) +[@patternfly/Charts - Legend - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartLegend/examples/ChartLegend.md) +[@patternfly/Charts - Tooltip - Design Guidelines](https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/charts/tooltip-chart/tooltip-chart.md) +[@patternfly/Charts - Tooltip - Examples](https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-charts/src/victory/components/ChartTooltip/examples/ChartTooltip.md) + [@patternfly/react-charts](/documentation/charts/README.md) +[@patternfly/react-chatbot](/documentation/chatbot/README.md) +[@patternfly/react-component-groups](/documentation/component-groups/README.md) +[@patternfly/react-components](/documentation/components/README.md) +[@patternfly/react-guidelines](/documentation/guidelines/README.md) +[@patternfly/react-resources](/documentation/resources/README.md) +[@patternfly/react-setup](/documentation/setup/README.md) +[@patternfly/react-troubleshooting](/documentation/troubleshooting/README.md) + + + 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 .md file + 3. Then fetch specific documentation pages relevant to the user's question with the subsequent tool call.", + "inputSchema": { + "urlList": ZodArray { + "_def": { + "description": "The list of urls to fetch the documentation from", + "exactLength": null, + "maxLength": null, + "minLength": null, + "type": ZodString { + "_def": { + "checks": [], + "coerce": false, + "typeName": "ZodString", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodArray", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], + ], + [ + "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": ZodArray { + "_def": { + "description": "The list of URLs to fetch documentation from", + "exactLength": null, + "maxLength": null, + "minLength": null, + "type": ZodString { + "_def": { + "checks": [], + "coerce": false, + "typeName": "ZodString", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodArray", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], + ], + [ + "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.", + "inputSchema": { + "componentName": ZodString { + "_def": { + "checks": [], + "coerce": false, + "description": "Name of the PatternFly component (e.g., "Button", "Table")", + "typeName": "ZodString", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], + ], + ], +} +`; + +exports[`runServer should attempt to run server, use default tools, stdio: diagnostics 1`] = ` +{ + "events": [ + [ + "Registered tool: usePatternFlyDocs", + ], + [ + "Registered tool: fetchDocs", + ], + [ + "Registered tool: componentSchemas", + ], + [ + "test-server-1 server running on stdio transport", + ], + ], + "mcpServer": [ + [ + { + "name": "test-server-1", + "version": "1.0.0", }, { "capabilities": { diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index af15156..184f2ef 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,7 +1,7 @@ -import { main, start, type CliOptions } from '../index'; +import { main, start, type PfMcpOptions, type CliOptions } from '../index'; import { parseCliOptions, type GlobalOptions } from '../options'; import { DEFAULT_OPTIONS } from '../options.defaults'; -import { setOptions } from '../options.context'; +import { getSessionOptions, runWithSession, setOptions } from '../options.context'; import { runServer } from '../server'; // Mock dependencies @@ -12,6 +12,8 @@ jest.mock('../server'); const mockParseCliOptions = parseCliOptions as jest.MockedFunction; const mockSetOptions = setOptions as jest.MockedFunction; const mockRunServer = runServer as jest.MockedFunction; +const mockGetSessionOptions = getSessionOptions as jest.MockedFunction; +const mockRunWithSession = runWithSession as jest.MockedFunction; describe('main', () => { let consoleErrorSpy: jest.SpyInstance; @@ -35,19 +37,34 @@ describe('main', () => { return { docsHost: false, logging: defaultLogging } as unknown as CliOptions; }); + mockSetOptions.mockImplementation(options => { callOrder.push('set'); return Object.freeze({ ...DEFAULT_OPTIONS, ...options }) as unknown as GlobalOptions; }); + + mockGetSessionOptions.mockReturnValue({ + sessionId: 'test-session-id', + channelName: 'patternfly-mcp:test-session-id' + } as any); + + mockRunWithSession.mockImplementation(async (_session, callback: any) => await callback()); + + const mockServerInstance = { + stop: jest.fn().mockResolvedValue(undefined), + isRunning: jest.fn().mockReturnValue(true), + onLog: jest.fn() + }; + mockRunServer.mockImplementation(async () => { callOrder.push('run'); - return { - stop: jest.fn().mockResolvedValue(undefined), - isRunning: jest.fn().mockReturnValue(true) - }; + return mockServerInstance; }); + + // Also mock runServer.memo since index.ts uses runServer.memo + (mockRunServer as any).memo = mockRunServer; }); afterEach(() => { @@ -145,9 +162,9 @@ describe('main', () => { }); describe('type exports', () => { - it('should export CliOptions type', () => { + it('should export PfMcpOptions type', () => { // TypeScript compilation will fail if the type is unavailable - const options: Partial = { docsHost: true }; + const options: PfMcpOptions = { docsHost: true }; expect(options).toBeDefined(); }); diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts index de3786e..fd7e240 100644 --- a/src/__tests__/options.test.ts +++ b/src/__tests__/options.test.ts @@ -35,6 +35,26 @@ describe('parseCliOptions', () => { { description: 'with other arguments', args: ['node', 'script.js', 'other', 'args'] + }, + { + description: 'with --http flag', + args: ['node', 'script.js', '--http'] + }, + { + description: 'with --http and --port', + args: ['node', 'script.js', '--http', '--port', '8080'] + }, + { + description: 'with --http and --host', + args: ['node', 'script.js', '--http', '--host', '0.0.0.0'] + }, + { + description: 'with --allowed-origins', + args: ['node', 'script.js', '--http', '--allowed-origins', 'https://app.com,https://admin.app.com'] + }, + { + description: 'with --allowed-hosts', + args: ['node', 'script.js', '--http', '--allowed-hosts', 'localhost,127.0.0.1'] } ])('should attempt to parse args $description', ({ args = [] }) => { process.argv = args; diff --git a/src/__tests__/server.http.test.ts b/src/__tests__/server.http.test.ts new file mode 100644 index 0000000..d0b349e --- /dev/null +++ b/src/__tests__/server.http.test.ts @@ -0,0 +1,189 @@ +import { createServer } from 'node:http'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { getProcessOnPort, startHttpTransport } from '../server.http'; + +// Mock dependencies +jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); +jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js'); +jest.mock('node:http'); +jest.mock('pid-port', () => ({ + __esModule: true, + portToPid: jest.fn().mockImplementation(async () => 123456789) +})); + +const MockMcpServer = McpServer as jest.MockedClass; +const MockStreamableHTTPServerTransport = StreamableHTTPServerTransport as jest.MockedClass; +const MockCreateServer = createServer as jest.MockedFunction; + +describe('getProcessOnPort', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should attempt to find a process listening on a port', async () => { + await expect(getProcessOnPort(3000)).resolves.toMatchSnapshot('ps fallback'); + }); +}); + +describe('startHttpTransport', () => { + const mockFunction = jest.fn(); + const mockEventHandler = jest.fn(); + const mockServerClose = jest.fn(); + let mockRequestHandler: ((req: any, res: any) => void) | undefined; + let mockServer: any; + let mockHttpServer: any; + let mockTransport: any; + + beforeEach(() => { + mockServer = { + connect: mockFunction, + registerTool: mockFunction + }; + mockHttpServer = { + on: mockEventHandler, + listen: mockFunction.mockImplementation((_port: any, _host: any, callback: any) => { + if (callback) { + callback(); + } + }), + close: mockServerClose.mockImplementation((callback: any) => { + callback(); + }) + }; + mockTransport = { + handleRequest: jest.fn(), + sessionId: 'test-session-123' + }; + + MockMcpServer.mockImplementation(() => mockServer); + MockStreamableHTTPServerTransport.mockImplementation(() => mockTransport); + + MockCreateServer.mockImplementation((handler: any) => { + mockRequestHandler = handler; + return mockHttpServer as any; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should start HTTP server, with port and host', async () => { + const server = await startHttpTransport(mockServer, { http: { port: 3000, host: 'localhost' } } as any); + + await server.close(); + + expect({ + setupServer: mockFunction.mock.calls, + setupTransport: MockStreamableHTTPServerTransport.mock.calls, + setupHandlers: mockEventHandler.mock.calls, + serverClose: mockServerClose.mock.calls + }).toMatchSnapshot('server setup'); + }); + + it.each([ + { + description: 'with invalid port', + options: { port: undefined, host: 'localhost' }, + error: 'are required for HTTP transport' + }, + { + description: 'with invalid host', + options: { port: 3000, host: undefined }, + error: 'are required for HTTP transport' + } + ])('should handle option errors, $description', async ({ error, options }) => { + await expect(startHttpTransport(mockServer, options as any)).rejects.toThrow(error); + }); + + it.each([ + { + description: 'accept a basic path', + url: '/mcp', + isTransportCalled: true + }, + { + description: 'accept a trailing slash', + url: '/mcp/', + isTransportCalled: true + }, + { + description: 'accept a trailing slash with path', + url: '/mcp/sse', + isTransportCalled: true + }, + { + description: 'accept a casing insensitive path', + url: '/MCP', + isTransportCalled: true + }, + { + description: 'accept a path with query params', + url: '/MCP/SSE?x=1', + isTransportCalled: true + }, + { + description: 'reject a root path', + url: '/', + isTransportCalled: false + }, + { + description: 'reject a partial path', + url: '/mc', + isTransportCalled: false + }, + { + description: 'reject an malformed path', + url: '/mcpish', + isTransportCalled: false + }, + { + description: 'reject an incorrect path', + url: '/foo/bar?x=1', + isTransportCalled: false + }, + { + description: 'reject a malformed path', + url: 'http:]//localhost:8000/mcp', + isTransportCalled: false + }, + { + description: 'reject a malformed url', + url: 'http://[', + isTransportCalled: false + } + ])('accept and reject paths, $description', async ({ url, isTransportCalled }) => { + await startHttpTransport(mockServer, { http: { port: 3000, host: 'localhost' } } as any); + + const mockResponse = jest.fn(); + const response = { + statusCode: undefined, + shouldKeepAlive: undefined, + setHeader: mockResponse, + end: mockResponse + }; + + const mockRequest = { + url, + method: 'GET', + socket: { remoteAddress: '127.0.0.1' } + }; + + await mockRequestHandler?.(mockRequest, response); + + const isRequestCalled = mockTransport.handleRequest.mock.calls.length > 0; + + expect({ + response: { + statusCode: response.statusCode, + shouldKeepAlive: response.shouldKeepAlive + }, + responseCalls: mockResponse.mock.calls, + requestCalls: mockTransport.handleRequest.mock.calls, + isRequestCalled + }).toMatchSnapshot(); + + expect(isRequestCalled).toBe(isTransportCalled); + }); +}); diff --git a/src/__tests__/server.logger.test.ts b/src/__tests__/server.logger.test.ts index a829407..3151dd5 100644 --- a/src/__tests__/server.logger.test.ts +++ b/src/__tests__/server.logger.test.ts @@ -1,5 +1,5 @@ import diagnostics_channel from 'node:diagnostics_channel'; -import { getOptions, setOptions } from '../options.context'; +import { getLoggerOptions, setOptions } from '../options.context'; import { toMcpLevel, registerMcpSubscriber, createServerLogger } from '../server.logger'; import { log } from '../logger'; @@ -50,8 +50,8 @@ describe('registerMcpSubscriber', () => { }); it('should attempt to subscribe and unsubscribe from a channel', () => { - const options = getOptions(); - const unsubscribe = registerMcpSubscriber((() => {}) as any, options); + const loggingSession = getLoggerOptions(); + const unsubscribe = registerMcpSubscriber((() => {}) as any, loggingSession); unsubscribe(); @@ -80,20 +80,20 @@ describe('createServerLogger', () => { it.each([ { description: 'with stderr, and emulated channel to pass checks', - options: { logging: { channelName: 'loremIpsum', stderr: true, protocol: false } } + options: { channelName: 'loremIpsum', stderr: true, protocol: false } }, { description: 'with stderr, protocol, and emulated channel to pass checks', - options: { logging: { channelName: 'loremIpsum', stderr: true, protocol: true } } + options: { channelName: 'loremIpsum', stderr: true, protocol: true } }, { description: 'with no logging options', - options: { logging: {} }, + options: {}, stderr: false } ])('should attempt to subscribe and unsubscribe from a channel, $description', ({ options }) => { // Use channelName to pass conditions - const unsubscribe = createServerLogger((() => {}) as any, options as any); + const { unsubscribe } = createServerLogger((() => {}) as any, options as any); unsubscribe(); @@ -104,25 +104,63 @@ describe('createServerLogger', () => { }); it('should return a memoized server logger that avoids duplicate sinks; teardown stops emissions', () => { - setOptions({ logging: { stderr: true, level: 'info' } as any }); + setOptions({ logging: { stderr: true, level: 'debug' } as any }); const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true as any); class MockServer { sendLoggingMessage = jest.fn(async () => {}); } const server = new MockServer() as any; - const unsubscribeCallOne = createServerLogger.memo(server); - const unsubscribeCallTwo = createServerLogger.memo(server); + // Create a single memoized server logger with two server-level subscription handlers + const { subscribe: subscribeCallOne, unsubscribe: unsubscribeAllCallOne } = createServerLogger.memo(server); + const { subscribe: subscribeCallTwo, unsubscribe: unsubscribeAllCallTwo } = createServerLogger.memo(server); + + // Create two lower-level subscription handlers + const mockHandlerOne = jest.fn(); + const mockHandlerTwo = jest.fn(); + const unsubscribeMockHandlerOne = subscribeCallOne(mockHandlerOne); + const unsubscribeMockHandlerTwo = subscribeCallTwo(mockHandlerTwo); + + log.debug('a'); + + expect(mockHandlerOne).toHaveBeenCalledTimes(1); + expect(mockHandlerTwo).toHaveBeenCalledTimes(1); + + // This removes the subscription for mockHandlerOne + unsubscribeMockHandlerOne(); + + log.debug('b'); + + // This was removed earlier by the "unsubscribeMockHandlerOne()" call above + expect(mockHandlerOne).toHaveBeenCalledTimes(1); + // This continues to be called + expect(mockHandlerTwo).toHaveBeenCalledTimes(2); log.info('lorem ipsum, dolor sit info'); - expect(unsubscribeCallOne).toBe(unsubscribeCallTwo); + expect(unsubscribeAllCallOne).toBe(unsubscribeAllCallTwo); log.info('dolor sit amet'); - unsubscribeCallOne(); + // This removes all subscriptions + unsubscribeAllCallOne(); log.info('hello world!'); + // This shouldn't throw an error since all subscriptions were removed + unsubscribeAllCallTwo(); + + log.debug('c'); + + // This was removed earlier by the "unsubscribeMockHandlerOne()" call above + expect(mockHandlerOne).toHaveBeenCalledTimes(1); + // This was removed by the "unsubscribeAllCallOne()" call above + expect(mockHandlerTwo).toHaveBeenCalledTimes(4); + + // This shouldn't throw an error since all subscriptions were removed + unsubscribeMockHandlerTwo(); + + log.info('goodbye world!'); + expect(stderrSpy.mock.calls).toMatchSnapshot('stderr'); stderrSpy.mockRestore(); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index a370b97..78c1fa5 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1,8 +1,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { runServer } from '../server'; -import { type GlobalOptions } from '../options'; import { log } from '../logger'; +import { startHttpTransport, type HttpServerHandle } from '../server.http'; // Mock dependencies jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); @@ -13,14 +13,18 @@ jest.mock('../server.logger', () => ({ memo: jest.fn().mockImplementation(() => {}) } })); +jest.mock('../server.http'); const MockMcpServer = McpServer as jest.MockedClass; const MockStdioServerTransport = StdioServerTransport as jest.MockedClass; +const MockStartHttpTransport = startHttpTransport as jest.MockedFunction; const MockLog = log as jest.MockedObject; describe('runServer', () => { let mockServer: any; let mockTransport: any; + let mockHttpHandle: HttpServerHandle; + let mockClose: jest.Mock; let processOnSpy: jest.SpyInstance; beforeEach(() => { @@ -39,48 +43,71 @@ describe('runServer', () => { MockMcpServer.mockImplementation(() => mockServer); MockStdioServerTransport.mockImplementation(() => mockTransport); + // Mock HTTP transport + mockClose = jest.fn().mockResolvedValue(undefined); + mockHttpHandle = { + close: mockClose + }; + + MockStartHttpTransport.mockResolvedValue(mockHttpHandle); + // Spy on process.on method processOnSpy = jest.spyOn(process, 'on').mockImplementation(); + + // Mock process.exit to prevent Jest from exiting + jest.spyOn(process, 'exit').mockImplementation((() => {}) as never); }); afterEach(() => { processOnSpy.mockRestore(); + // Note: We don't call jest.restoreAllMocks() here as it would clear module mocks + // The memoization cache persists across tests, which is expected behavior }); it.each([ { - description: 'use default tools', - options: undefined, - tools: undefined + description: 'use default tools, stdio', + options: { name: 'test-server-1', version: '1.0.0' }, + tools: undefined, + transportMethod: MockStdioServerTransport + }, + { + description: 'use default tools, http', + options: { name: 'test-server-2', version: '1.0.0', isHttp: true }, + tools: undefined, + transportMethod: MockStartHttpTransport }, { description: 'use custom options', options: { - name: 'test-server', + name: 'test-server-3', version: '1.0.0' // logging: { protocol: false } }, - tools: [] + tools: [], + transportMethod: MockStdioServerTransport }, { description: 'create transport, connect, and log success message', - options: undefined, - tools: [] + options: { name: 'test-server-4', version: '1.0.0' }, + tools: [], + transportMethod: MockStdioServerTransport }, { description: 'register a tool', - options: undefined, + options: { name: 'test-server-5', version: '1.0.0' }, tools: [ jest.fn().mockReturnValue([ 'loremIpsum', { description: 'Lorem Ipsum', inputSchema: {} }, jest.fn() ]) - ] + ], + transportMethod: MockStdioServerTransport }, { description: 'register multiple tools', - options: undefined, + options: { name: 'test-server-6', version: '1.0.0' }, tools: [ jest.fn().mockReturnValue([ 'loremIpsum', @@ -92,36 +119,65 @@ describe('runServer', () => { { description: 'Dolor Sit', inputSchema: {} }, jest.fn() ]) - ] + ], + transportMethod: MockStdioServerTransport }, { description: 'disable SIGINT handler', - options: undefined, + options: { name: 'test-server-7', version: '1.0.0' }, tools: [], - enableSigint: false + enableSigint: false, + transportMethod: MockStdioServerTransport }, { description: 'enable SIGINT handler explicitly', - options: undefined, + options: { name: 'test-server-8', version: '1.0.0' }, tools: [], - enableSigint: true + enableSigint: true, + transportMethod: MockStdioServerTransport } - ])('should attempt to run server, $description', async ({ options, tools, enableSigint }) => { + ])('should attempt to run server, $description', async ({ options, tools, enableSigint, transportMethod }) => { const settings = { ...(tools && { tools }), - ...(enableSigint !== undefined && { enableSigint }) + ...(enableSigint !== undefined && { enableSigint }), + allowProcessExit: false // Prevent process.exit in tests }; - await runServer(options as GlobalOptions, Object.keys(settings).length > 0 ? settings : undefined); - - expect(MockStdioServerTransport).toHaveBeenCalled(); + const serverInstance = await runServer(options as any, Object.keys(settings).length > 0 ? settings : { allowProcessExit: false }); + expect(transportMethod).toHaveBeenCalled(); + expect(serverInstance.isRunning()).toBe(true); expect({ events: MockLog.info.mock.calls, registerTool: mockServer.registerTool.mock.calls, mcpServer: MockMcpServer.mock.calls, process: processOnSpy.mock.calls }).toMatchSnapshot('diagnostics'); + + // Clean up: stop the server to prevent cache pollution + await serverInstance.stop(); + }); + + it.each([ + { + description: 'stdio stop server', + options: undefined + }, + { + description: 'http stop server', + options: { isHttp: true } + } + ])('should allow server to be stopped, $description', async ({ options }) => { + const serverInstance = await runServer({ ...options, name: 'test-server' } as any, { allowProcessExit: false }); + + expect(serverInstance.isRunning()).toBe(true); + + await serverInstance.stop(); + + expect(serverInstance.isRunning()).toBe(false); + expect({ + events: MockLog.info.mock.calls + }).toMatchSnapshot('diagnostics'); }); it('should handle errors during server creation', async () => { diff --git a/src/cli.ts b/src/cli.ts index a6ef06d..39d5827 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ import { main } from './index'; -main().catch(error => { +main({ mode: 'cli' }).catch(error => { // Use console.error, log.error requires initialization console.error('Failed to start server:', error); process.exit(1); diff --git a/src/index.ts b/src/index.ts index 65f294a..8227bd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,95 @@ import { parseCliOptions, type CliOptions, type DefaultOptions } from './options'; -import { setOptions } from './options.context'; -import { runServer, type ServerInstance } from './server'; +import { + getSessionOptions, + setOptions, + runWithSession +} from './options.context'; +import { + runServer, + type ServerInstance, + type ServerSettings, + type ServerOnLog, + type ServerOnLogHandler, + type ServerLogEvent +} from './server'; + +/** + * Options for "programmatic" use. Extends the `DefaultOptions` interface. + * + * @interface + * + * @property {('cli' | 'programmatic' | 'test')} [mode] - Optional string property that specifies the mode of operation. + * Defaults to `'programmatic'`. + * - `'cli'`: Functionality is being executed in a cli context. Allows process exits. + * - `'programmatic'`: Functionality is invoked programmatically. Allows process exits. + * - `'test'`: Functionality is being tested. Does NOT allow process exits. + */ +type PfMcpOptions = Partial & { + mode?: 'cli' | 'programmatic' | 'test'; +}; + +/** + * Additional settings for programmatic control. + * + * @property {boolean} allowProcessExit - Override process exits. Useful for tests + * or programmatic use to avoid exiting. + * - Setting directly overrides `mode` property defaults. + * - When `mode=cli` or `mode=programmatic` or `undefined`, defaults to `true`. + * - When `mode=test`, defaults to `false`. + */ +type PfMcpSettings = Pick; /** * Main function - CLI entry point with optional programmatic overrides * - * @param programmaticOptions - Optional programmatic options that override CLI options + * @param [pfMcpOptions] - User configurable options + * @param [pfMcpSettings] - MCP server settings + * * @returns {Promise} Server-instance with shutdown capability + * + * @throws {Error} If the server fails to start or any error occurs during initialization, + * and `allowProcessExit` is set to `false`, the error will be thrown rather than exiting + * the process. */ -const main = async (programmaticOptions?: Partial): Promise => { +const main = async ( + pfMcpOptions: PfMcpOptions = {}, + pfMcpSettings: PfMcpSettings = {} +): Promise => { + const { mode, ...options } = pfMcpOptions; + const { allowProcessExit } = pfMcpSettings; + + const modes = ['cli', 'programmatic', 'test']; + const updatedMode = mode && modes.includes(mode) ? mode : 'programmatic'; + const updatedAllowProcessExit = allowProcessExit ?? updatedMode !== 'test'; + try { - // Parse CLI options const cliOptions = parseCliOptions(); + const mergedOptions = setOptions({ ...cliOptions, ...options }); + const session = getSessionOptions(); - // Apply options to context. setOptions merges with session and DEFAULT_OPTIONS internally - setOptions({ ...cliOptions, ...programmaticOptions }); - - return await runServer(); + // use runWithSession to enable session in listeners + return await runWithSession(session, async () => + // `runServer` doesn't require it, but `memo` does for "uniqueness", pass in the merged options for a hashable argument + runServer.memo(mergedOptions, { allowProcessExit: updatedAllowProcessExit })); } catch (error) { - // Use console.error, log.error requires initialization console.error('Failed to start server:', error); - process.exit(1); + + if (updatedAllowProcessExit) { + process.exit(1); + } else { + throw error; + } } }; -export { main, main as start, type CliOptions, type ServerInstance }; +export { + main, + main as start, + type CliOptions, + type PfMcpOptions, + type PfMcpSettings, + type ServerInstance, + type ServerLogEvent, + type ServerOnLog, + type ServerOnLogHandler +}; diff --git a/src/logger.ts b/src/logger.ts index 66cee7e..146cbe6 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -4,6 +4,15 @@ import { getLoggerOptions } from './options.context'; type LogLevel = LoggingSession['level']; +/** + * Unsubscribe function returned by `subscribeToChannel`. + * + * @note We purposefully don't handle the return `boolean` given by `diagnostics_channel.unsubscribe`. The `unsubscribe` + * returns a function that returns a boolean indicating whether the subscription was successfully removed. + * https://nodejs.org/api/diagnostics_channel.html#diagnostics_channel_channel_unsubscribe_listener + */ +type Unsubscribe = () => void; + /** * Log an event with detailed information about a specific action. * @@ -76,35 +85,6 @@ const publish = (level: LogLevel, options: LoggingSession = getLoggerOptions(), } }; -/** - * Subscribe to the diagnostics channel and invoke a handler for each event. - * - * If the event doesn't contain a valid `level` property, the handler is not invoked. - * - * @param handler - Callback function to handle log events - * @param {LoggingSession} [options] - * @returns Function to unsubscribe from the log channel - */ -const subscribeToChannel = (handler: (message: LogEvent) => void, options: LoggingSession = getLoggerOptions()) => { - const channelName = options?.channelName; - - if (!channelName) { - throw new Error('subscribeToChannel called without a configured logging channelName'); - } - - const updatedHandler = (event: LogEvent) => { - if (!event?.level) { - return; - } - - handler.call(null, event); - }; - - subscribe(channelName, updatedHandler as (message: unknown) => void); - - return () => unsubscribe(channelName, updatedHandler as (message: unknown) => void); -}; - /** * Console-like API for publishing structured log events to the diagnostics channel. * @@ -136,6 +116,44 @@ const log = { } }; +/** + * Subscribe to the diagnostics channel and invoke a handler for each event. + * + * If the event doesn't contain a valid `level` property, the handler is not invoked. + * + * @param handler - Callback function to handle log events + * @param {LoggingSession} [options] + * @returns Function to unsubscribe from the log channel + */ +const subscribeToChannel = ( + handler: (message: LogEvent) => void, + options: LoggingSession = getLoggerOptions() +): Unsubscribe => { + const channelName = options?.channelName; + + if (!channelName) { + throw new Error('subscribeToChannel called without a configured logging channelName'); + } + + const updatedHandler = (event: LogEvent) => { + if (!event?.level) { + return; + } + + try { + handler.call(null, event); + } catch (error) { + log.debug('Error invoking logging subscriber', event, error); + } + }; + + subscribe(channelName, updatedHandler as (message: unknown) => void); + + return () => { + unsubscribe(channelName, updatedHandler as (message: unknown) => void); + }; +}; + /** * Register a handler that writes formatted log lines to `process.stderr`. * @@ -145,7 +163,7 @@ const log = { * @param [formatter] - Optional custom formatter for log events. Default prints: `[LEVEL] msg ...args` * @returns Unsubscribe function to remove the subscriber */ -const registerStderrSubscriber = (options: LoggingSession, formatter?: (e: LogEvent) => string) => { +const registerStderrSubscriber = (options: LoggingSession, formatter?: (e: LogEvent) => string): Unsubscribe => { const format = formatter || ((event: LogEvent) => { const eventLevel = `[${event.level.toUpperCase()}]`; const message = event.msg || ''; @@ -158,7 +176,7 @@ const registerStderrSubscriber = (options: LoggingSession, formatter?: (e: LogEv }).join(' ') || ''; const separator = rest ? '\t:' : ''; - return `${eventLevel}:\t${message}${separator}${rest}`.trim(); + return `${eventLevel}: ${message}${separator}${rest}`.trim(); }); return subscribeToChannel((event: LogEvent) => { @@ -174,14 +192,24 @@ const registerStderrSubscriber = (options: LoggingSession, formatter?: (e: LogEv * @param {LoggingSession} [options] * @returns Unsubscribe function to remove all registered subscribers */ -const createLogger = (options: LoggingSession = getLoggerOptions()) => { - const unsubscribeLoggerFuncs: (() => void | boolean)[] = []; +const createLogger = (options: LoggingSession = getLoggerOptions()): Unsubscribe => { + const unsubscribeLoggerFuncs: Unsubscribe[] = []; if (options?.channelName && options?.stderr) { unsubscribeLoggerFuncs.push(registerStderrSubscriber(options)); } - return () => unsubscribeLoggerFuncs.forEach(unsubscribe => unsubscribe()); + return () => { + unsubscribeLoggerFuncs.forEach(unsubscribe => { + try { + unsubscribe(); + } catch (error) { + log.debug('Error unsubscribing from diagnostics channel', error); + } + }); + + unsubscribeLoggerFuncs.length = 0; + }; }; export { @@ -193,5 +221,6 @@ export { registerStderrSubscriber, subscribeToChannel, type LogEvent, - type LogLevel + type LogLevel, + type Unsubscribe }; diff --git a/src/options.context.ts b/src/options.context.ts index 4b0c2bf..b24b9dc 100644 --- a/src/options.context.ts +++ b/src/options.context.ts @@ -1,9 +1,55 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; -import { type GlobalOptions } from './options'; +import { type Session, type GlobalOptions } from './options'; import { DEFAULT_OPTIONS, LOG_BASENAME, type LoggingSession, type DefaultOptions } from './options.defaults'; import { mergeObjects, freezeObject, isPlainObject } from './server.helpers'; +/** + * AsyncLocalStorage instance for a per-instance session state. + * + * The `sessionContext` allows sharing a common context without explicitly + * passing it as a parameter. + */ +const sessionContext = new AsyncLocalStorage(); + +/** + * Initialize and return session data. + * + * @returns {Session} Immutable session with a session ID and channel name. + */ +const initializeSession = (): Session => { + const sessionId = (process.env.NODE_ENV === 'local' && '1234d567-1ce9-123d-1413-a1234e56c789') || randomUUID(); + const channelName = `${LOG_BASENAME}:${sessionId}`; + + return freezeObject({ sessionId, channelName }); +}; + +/** + * Set and return the current session options. + * + * @param {Session} [session] + * @returns {Session} + */ +const setSessionOptions = (session: Session = initializeSession()) => { + sessionContext.enterWith(session); + + return session; +}; + +/** + * Get the current session options or set a new session with defaults. + */ +const getSessionOptions = (): Session => sessionContext.getStore() || setSessionOptions(); + +const runWithSession = async ( + session: Session, + callback: () => TReturn | Promise +) => { + const frozen = freezeObject(structuredClone(session)); + + return sessionContext.run(frozen, callback); +}; + /** * AsyncLocalStorage instance for per-instance options * @@ -15,29 +61,20 @@ const optionsContext = new AsyncLocalStorage(); /** * Set and freeze cloned options in the current async context. * - * - Applies a unique session ID and logging channel name - * - Certain settings are not allowed to be overridden by the caller to ensure consistency across instances - * * @param {Partial} [options] - Optional options to set in context. Merged with DEFAULT_OPTIONS. * @returns {GlobalOptions} Cloned frozen default options object with session. */ const setOptions = (options?: Partial): GlobalOptions => { const base = mergeObjects(DEFAULT_OPTIONS, options, { allowNullValues: false, allowUndefinedValues: false }); - const sessionId = (process.env.NODE_ENV === 'local' && '1234d567-1ce9-123d-1413-a1234e56c789') || randomUUID(); - const baseLogging = isPlainObject(base.logging) ? base.logging : DEFAULT_OPTIONS.logging; - const baseName = LOG_BASENAME; - const channelName = `${baseName}:${sessionId}`; const merged: GlobalOptions = { ...base, - sessionId, logging: { level: baseLogging.level, + logger: baseLogging.logger, stderr: baseLogging.stderr, protocol: baseLogging.protocol, - transport: baseLogging.transport, - baseName, - channelName + transport: baseLogging.transport }, resourceMemoOptions: DEFAULT_OPTIONS.resourceMemoOptions, toolMemoOptions: DEFAULT_OPTIONS.toolMemoOptions @@ -60,31 +97,29 @@ const setOptions = (options?: Partial): GlobalOptions => { * * @returns {GlobalOptions} Current options from context or defaults */ -const getOptions = (): GlobalOptions => { - const context = optionsContext.getStore(); - - if (context) { - return context; - } - - return setOptions({}); -}; +const getOptions = (): GlobalOptions => optionsContext.getStore() || setOptions(); /** * Get logging options from the current context. * + * @param {Session} [session] - Session options to use in context. * @returns {LoggingSession} Logging options from context. */ -const getLoggerOptions = (): LoggingSession => getOptions().logging; +const getLoggerOptions = (session = getSessionOptions()): LoggingSession => { + const base = getOptions().logging; + + return { ...base, channelName: session.channelName }; +}; /** * Run a function with specific options context. Useful for testing or programmatic usage. * + * @template TReturn * @param options - Options to use in context * @param callback - Function to apply options context against * @returns Result of function */ -const runWithOptions = async ( +const runWithOptions = async ( options: GlobalOptions, callback: () => TReturn | Promise ) => { @@ -93,5 +128,16 @@ const runWithOptions = async ( return optionsContext.run(frozen, callback); }; -export { getOptions, getLoggerOptions, optionsContext, runWithOptions, setOptions }; +export { + getLoggerOptions, + getOptions, + getSessionOptions, + initializeSession, + optionsContext, + runWithOptions, + runWithSession, + sessionContext, + setOptions, + setSessionOptions +}; diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 811adb1..66007cf 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -10,6 +10,8 @@ import packageJson from '../package.json'; * @property contextPath - Current working directory. * @property docsHost - Flag indicating whether to use the docs-host. * @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 name - Name of the package. @@ -33,6 +35,8 @@ interface DefaultOptions { contextPath: string; docsHost: boolean; docsPath: string; + http: HttpOptions | undefined; + isHttp: boolean; llmsFilesPath: string; logging: TLogOptions; name: string; @@ -53,48 +57,54 @@ interface DefaultOptions { version: string; } -/** - * Session defaults, not user-configurable - */ -/** - * Represents the default session configuration with an associated session ID, - * inheriting properties from the DefaultOptions interface. - * - * @extends DefaultOptions - * @property sessionId The unique identifier for the session. - */ -interface DefaultSession extends DefaultOptions { - readonly sessionId: string; -} - /** * Logging options. * * @interface LoggingOptions - * @default { level: 'info', stderr: false, protocol: false, baseName: `${packageJson.name}:log`, transport: 'stdio' } + * @default { level: 'debug', logger: packageJson.name, stderr: false, protocol: false, transport: 'stdio' } * * @property level Logging level. + * @property logger Logger name. Human-readable/configurable logger name used in MCP protocol messages. Isolated + * to make passing logging options between modules easier. This does not change the session unique + * diagnostics-channel name and is intended to label messages forwarded over the MCP protocol. * @property stderr Flag indicating whether to log to stderr. * @property protocol Flag indicating whether to log protocol details. * @property transport Transport mechanism for logging. */ interface LoggingOptions { level: 'debug' | 'info' | 'warn' | 'error'; + logger: string; stderr: boolean; protocol: boolean; transport: 'stdio' | 'mcp'; } +/** + * HTTP server options. + * + * @interface HttpOptions + * @default { port: 8080, host: '127.0.0.1', allowedOrigins: [], allowedHosts: [] } + * + * @property port Port number. + * @property host Host name. + * @property allowedOrigins List of allowed origins. + * @property allowedHosts List of allowed hosts. + */ +interface HttpOptions { + port: number; + host: string; + allowedOrigins: string[]; + allowedHosts: string[]; +} + /** * Logging session options, non-configurable by the user. * * @interface LoggingSession * @extends LoggingOptions - * @property baseName Name of the logging channel. * @property channelName Unique identifier for the logging channel. */ interface LoggingSession extends LoggingOptions { - readonly baseName: string; readonly channelName: string; } @@ -103,11 +113,22 @@ interface LoggingSession extends LoggingOptions { */ const LOGGING_OPTIONS: LoggingOptions = { level: 'info', + logger: packageJson.name, stderr: false, protocol: false, transport: 'stdio' }; +/** + * Base HTTP options. + */ +const HTTP_OPTIONS: HttpOptions = { + port: 8080, + host: '127.0.0.1', + allowedOrigins: [], + allowedHosts: [] +}; + /** * Default separator for joining multiple document contents */ @@ -117,6 +138,9 @@ const DEFAULT_SEPARATOR = '\n\n---\n\n'; * Resource-level memoization options */ const RESOURCE_MEMO_OPTIONS = { + default: { + cacheLimit: 3 + }, fetchUrl: { cacheLimit: 100, expire: 3 * 60 * 1000, // 3 minute sliding cache @@ -226,6 +250,8 @@ const DEFAULT_OPTIONS: DefaultOptions = { docsHost: false, contextPath: (process.env.NODE_ENV === 'local' && '/') || process.cwd(), docsPath: (process.env.NODE_ENV === 'local' && '/documentation') || join(process.cwd(), 'documentation'), + isHttp: false, + http: HTTP_OPTIONS, llmsFilesPath: (process.env.NODE_ENV === 'local' && '/llms-files') || join(process.cwd(), 'llms-files'), logging: LOGGING_OPTIONS, name: packageJson.name, @@ -261,12 +287,8 @@ export { PF_EXTERNAL_ACCESSIBILITY, LOG_BASENAME, DEFAULT_OPTIONS, - DEFAULT_SEPARATOR, - RESOURCE_MEMO_OPTIONS, - TOOL_MEMO_OPTIONS, - URL_REGEX, type DefaultOptions, - type DefaultSession, + type HttpOptions, type LoggingOptions, type LoggingSession }; diff --git a/src/options.ts b/src/options.ts index 9ec3e28..54ee455 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,15 +1,60 @@ -import { DEFAULT_OPTIONS, type DefaultOptions, type DefaultSession, type LoggingOptions } from './options.defaults'; +import { DEFAULT_OPTIONS, type DefaultOptions, type LoggingOptions, type HttpOptions } from './options.defaults'; import { type LogLevel, logSeverity } from './logger'; /** - * Combined options object + * Session defaults, not user-configurable */ -type GlobalOptions = DefaultSession; +type Session = { + readonly sessionId: string; + readonly channelName: string +}; + +/** + * Global options, convenience type for `DefaultOptions` + */ +type GlobalOptions = DefaultOptions; /** * Options parsed from CLI arguments */ -type CliOptions = { docsHost: boolean; logging: LoggingOptions }; +type CliOptions = { + docsHost: boolean; + http: HttpOptions | undefined; + isHttp: boolean; + logging: LoggingOptions; +}; + +/** + * Get argument value from process.argv + * + * @param flag - CLI flag to search for + * @param defaultValue - Default arg value + */ +const getArgValue = (flag: string, defaultValue?: unknown) => { + const index = process.argv.indexOf(flag); + + if (index === -1) { + return defaultValue; + } + + const value = process.argv[index + 1]; + + if (!value || value.startsWith('-')) { + return defaultValue; + } + + if (typeof defaultValue === 'number') { + const num = parseInt(value, 10); + + if (isNaN(num)) { + return defaultValue; + } + + return num; + } + + return value; +}; /** * Parses CLI options and return config options for the application. @@ -20,6 +65,11 @@ type CliOptions = { docsHost: boolean; logging: LoggingOptions }; * - `--verbose`: Log all severity levels. Shortcut to set the logging level to `debug`. * - `--log-stderr`: Enables terminal logging of channel events * - `--log-protocol`: Enables MCP protocol logging. Forward server logs to MCP clients (requires advertising `capabilities.logging`). + * - `--http`: Indicates if the `--http` option is enabled. + * - `--port`: The port number specified via `--port`, or defaults to `3000` if not provided. + * - `--host`: The host name specified via `--host`, or defaults to `'127.0.0.1'` if not provided. + * - `--allowed-origins`: List of allowed origins derived from the `--allowed-origins` parameter, split by commas, or undefined if not provided. + * - `--allowed-hosts`: List of allowed hosts derived from the `--allowed-hosts` parameter, split by commas, or undefined if not provided. * * @param argv - Command-line arguments to parse. Defaults to `process.argv`. * @returns Parsed command-line options. @@ -27,7 +77,6 @@ type CliOptions = { docsHost: boolean; logging: LoggingOptions }; const parseCliOptions = (argv: string[] = process.argv): CliOptions => { const docsHost = argv.includes('--docs-host'); const levelIndex = argv.indexOf('--log-level'); - const logging: LoggingOptions = { ...DEFAULT_OPTIONS.logging, stderr: argv.includes('--log-stderr'), @@ -44,13 +93,37 @@ const parseCliOptions = (argv: string[] = process.argv): CliOptions => { } } - return { docsHost, logging }; + const isHttp = argv.includes('--http'); + let http: HttpOptions | undefined; + + if (isHttp) { + let port = getArgValue('--port'); + const host = getArgValue('--host'); + const allowedOrigins = (getArgValue('--allowed-origins') as string)?.split(',')?.filter((origin: string) => origin.trim()); + const allowedHosts = (getArgValue('--allowed-hosts') as string)?.split(',')?.filter((host: string) => host.trim()); + + const isPortValid = (typeof port === 'number') && (port > 0 && port < 65536); + + port = isPortValid ? port : undefined; + + http = { + port, + host, + allowedHosts, + allowedOrigins + } as HttpOptions; + } + + return { docsHost, logging, isHttp, http }; }; export { parseCliOptions, + getArgValue, type CliOptions, - type LoggingOptions, type DefaultOptions, - type GlobalOptions + type GlobalOptions, + type HttpOptions, + type LoggingOptions, + type Session }; diff --git a/src/server.http.ts b/src/server.http.ts new file mode 100644 index 0000000..7b878d9 --- /dev/null +++ b/src/server.http.ts @@ -0,0 +1,261 @@ +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { Socket } from 'node:net'; +import { execSync } from 'node:child_process'; +import { platform } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { StreamableHTTPServerTransport, type StreamableHTTPServerTransportOptions } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { portToPid } from 'pid-port'; +import { getOptions } from './options.context'; +import { log } from './logger'; + +/** + * Fixed base path for MCP transport endpoints. + * + * @note Clients should use http://host:port/mcp and transport-managed subpaths like `/mcp/sse`. + */ +const MCP_BASE_PATH = '/mcp'; + +/** + * The base URL of the MCP server. + */ +const MCP_HOST = 'http://mcp.local'; + +/** + * Get process information for a port + * + * @param port - Port number to check + * @returns Process info or undefined if port is free + */ +const getProcessOnPort = async (port: number) => { + if (!port) { + return undefined; + } + + try { + // Cross-platform PID lookup using pid-port + const pid = await portToPid(port); + + if (!pid) { + return undefined; + } + + // Minimal OS-specific code for command name + const isWindows = platform() === 'win32'; + let command = 'unknown'; + + try { + if (isWindows) { + // Use PowerShell to get the full command with arguments (for error messages) + try { + const psCmd = `powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pid}\\").CommandLine"`; + + command = execSync(psCmd, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch { + // Fallback to "tasklist" if PowerShell fails (only provides process name, not full command line) + try { + command = execSync(`tasklist /FI "PID eq ${pid}" /FO LIST /NH`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch { + // Ignore - command stays 'unknown' + } + } + } else { + try { + command = execSync(`ps -p ${pid} -o command=`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim(); + } catch { + // If ps fails, confirm pid then construct the command from process.argv + if (pid === process.pid) { + const argv = process.argv; + + if (argv && argv.length > 0) { + command = argv.join(' '); + } + } + } + } + } catch { + // Ignore - command stays 'unknown' + } + + return { pid, command }; + } catch { + return undefined; + } +}; + +/** + * Create Streamable HTTP transport + * + * @param {DefaultSession} [options] + */ +const createStreamableHttpTransport = (options = getOptions()) => { + const { http } = options; + + const transportOptions: StreamableHTTPServerTransportOptions = { + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: false, // Use SSE streaming + enableDnsRebindingProtection: true, + onsessioninitialized: (sessionId: string) => { + log.info(`Session initialized: ${sessionId}`); + }, + onsessionclosed: (sessionId: string) => { + log.info(`Session closed: ${sessionId}`); + } + }; + + if (http?.allowedOrigins) { + transportOptions.allowedOrigins = http.allowedOrigins; + } + + if (http?.allowedHosts) { + transportOptions.allowedHosts = http.allowedHosts; + } + + return new StreamableHTTPServerTransport(transportOptions); +}; + +/** + * Handle Streamable HTTP requests + * + * @param req - HTTP request object + * @param res - HTTP response object + * @param transport - Streamable HTTP transport + */ +const handleStreamableHttpRequest = async ( + req: IncomingMessage, + res: ServerResponse, + transport: StreamableHTTPServerTransport +) => { + await transport.handleRequest(req, res); +}; + +/** + * HTTP server handle for lifecycle management + */ +type HttpServerHandle = { + close: () => Promise; +}; + +/** + * Start the HTTP transport server + * + * @param {McpServer} mcpServer + * @param {DefaultSession} [options] + * @returns Handle with close method for server lifecycle management + */ +const startHttpTransport = async (mcpServer: McpServer, options = getOptions()): Promise => { + const { name, http } = options; + + if (!http?.port || !http?.host) { + throw new Error('Port and host options are required for HTTP transport'); + } + + const transport = createStreamableHttpTransport(options); + + // Connect MCP server to transport + await mcpServer.connect(transport); + + // Set up + const connections = new Set(); + + // Gate handling to a fixed base path to avoid exposing the transport on arbitrary routes + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + try { + const url = new URL(req.url || '/', MCP_HOST); + + const pathname = (url.pathname || '/').toLowerCase(); + const basePath = MCP_BASE_PATH.toLowerCase(); + + const isExactBasePath = pathname === basePath; + const isUnderBasePath = pathname.startsWith(`${basePath}/`); + + if (!isExactBasePath && !isUnderBasePath) { + throw new Error('Unexpected path', { cause: { statusCode: 404, message: 'Not Found' } }); + } + } catch (error) { + const cause = (error as { cause?: unknown })?.cause as { statusCode?: unknown; message?: unknown } | undefined; + const statusCode = typeof cause?.statusCode === 'number' ? cause.statusCode : 400; + const message = typeof cause?.message === 'string' ? cause.message : 'Bad Request'; + const method = req?.method || 'UNKNOWN'; + const remote = req?.socket?.remoteAddress || 'unknown'; + const path = req?.url || ''; + + log.warn(`HTTP ${statusCode} [${method}] from ${remote}, unexpected path: ${path}`); + res.statusCode = statusCode; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + // Ensure socket closes after res.end() + res.shouldKeepAlive = false; + res.end(message); + + return; + } + + void handleStreamableHttpRequest(req, res, transport); + }); + + // Start the server. Port conflicts will be handled in the error handler below + await new Promise((resolve, reject) => { + server.listen(http.port, http.host, () => { + log.info(`${name} server running on http://${http.host}:${http.port}`); + resolve(); + }); + + server.on('connection', socket => { + connections.add(socket); + socket.on('close', () => connections.delete(socket)); + }); + + server.on('error', async (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + const processInfo = await getProcessOnPort(http.port); + const errorMessage = `Port ${http.port} is already in use${processInfo ? ` by PID ${processInfo.pid}` : ''}.`; + + log.error(errorMessage); + reject(processInfo ? new Error(errorMessage, { cause: processInfo }) : error); + } else { + log.error(`HTTP server error: ${error}`); + reject(error); + } + }); + }); + + return { + close: async () => { + // 1) Stop accepting new connections and finish requests quickly + // If the transport exposes a close/shutdown, call it here (pseudo): + // await transport.close?.(); // not in current SDK surface but keep as a future hook + + // 2) Proactively destroy open sockets (SSE/keep-alive) + for (const socket of connections) { + try { + socket.destroy(); + } catch {} + } + + // 3) Close the HTTP server + await new Promise(resolve => { + server.close(() => resolve()); + }); + } + }; +}; + +export { + createStreamableHttpTransport, + getProcessOnPort, + handleStreamableHttpRequest, + startHttpTransport, + MCP_BASE_PATH, + MCP_HOST, + type HttpServerHandle +}; diff --git a/src/server.logger.ts b/src/server.logger.ts index 2f7272d..5b4f15b 100644 --- a/src/server.logger.ts +++ b/src/server.logger.ts @@ -1,8 +1,8 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { type LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; -import { getOptions } from './options.context'; -import { type GlobalOptions } from './options'; -import { createLogger, logSeverity, subscribeToChannel, type LogEvent, type LogLevel } from './logger'; +import { getLoggerOptions } from './options.context'; +import { DEFAULT_OPTIONS, type LoggingSession } from './options.defaults'; +import { createLogger, log, logSeverity, subscribeToChannel, type LogEvent, type LogLevel, type Unsubscribe } from './logger'; import { memo } from './server.caching'; type McpLoggingLevel = LoggingLevel; @@ -34,12 +34,12 @@ const toMcpLevel = (level: LogLevel): McpLoggingLevel => { * - Event is fire-and-forget, swallow errors to avoid affecting app flow * * @param {McpServer} server - MCP server instance - * @param {GlobalOptions} options + * @param {LoggingSession} loggingSession * @returns Unsubscribe function to remove the subscriber. */ -const registerMcpSubscriber = (server: McpServer, { logging, name }: GlobalOptions) => +const registerMcpSubscriber = (server: McpServer, loggingSession: LoggingSession) => subscribeToChannel((event: LogEvent) => { - if (logSeverity(event.level) < logSeverity(logging?.level)) { + if (logSeverity(event.level) < logSeverity(loggingSession.level)) { return; } @@ -48,7 +48,7 @@ const registerMcpSubscriber = (server: McpServer, { logging, name }: GlobalOptio try { void server - .sendLoggingMessage({ level: toMcpLevel(event.level), logger: name, data }) + .sendLoggingMessage({ level: toMcpLevel(event.level), logger: loggingSession.logger, data }) .catch(() => {}); } catch {} }); @@ -57,27 +57,74 @@ const registerMcpSubscriber = (server: McpServer, { logging, name }: GlobalOptio * Create a logger for the server instance. * * @param {McpServer} server - * @param {GlobalOptions} options - * @returns Unsubscribe function to remove all registered subscribers + * @param {LoggingSession} [loggingSession] + * @returns An object with methods to manage logging subscriptions: + * - `subscribe`: Registers a new log event handler if a valid handler function is provided. + * - `unsubscribe`: Unsubscribes and cleans up all available registered loggers and handlers. */ -const createServerLogger = (server: McpServer, options: GlobalOptions = getOptions()) => { - const unsubscribeLoggerFuncs: (() => boolean | void)[] = []; +const createServerLogger = (server: McpServer, loggingSession: LoggingSession = getLoggerOptions()) => { + // Track active subscribers to unsubscribe on server shutdown + const unsubscribeLoggerFuncs: Unsubscribe[] = []; - if (options?.logging?.channelName) { - // Register the diagnostics channel returns a function to unsubscribe - unsubscribeLoggerFuncs.push(createLogger(options.logging)); + if (loggingSession?.channelName) { + // Register the diagnostics channel + unsubscribeLoggerFuncs.push(createLogger(loggingSession)); - if (options.logging.protocol) { - unsubscribeLoggerFuncs.push(registerMcpSubscriber(server, options)); + if (loggingSession.protocol) { + // Register the MCP subscriber + unsubscribeLoggerFuncs.push(registerMcpSubscriber(server, loggingSession)); } } - return () => unsubscribeLoggerFuncs.forEach(unsubscribe => unsubscribe()); + return { + subscribe: (handler?: (event: LogEvent) => void) => { + if (typeof handler !== 'function') { + return () => {}; + } + + const unsubscribe = subscribeToChannel(handler); + let activeSubscribe = true; + + // Wrap the unsubscribe function so it removes itself from the list of active subscribers + const wrappedUnsubscribe = () => { + if (!activeSubscribe) { + return; + } + activeSubscribe = false; + + try { + unsubscribe(); + } finally { + const index = unsubscribeLoggerFuncs.indexOf(wrappedUnsubscribe); + + if (index > -1) { + unsubscribeLoggerFuncs.splice(index, 1); + } + } + }; + + // Track for server-wide cleanup + unsubscribeLoggerFuncs.push(wrappedUnsubscribe); + + return wrappedUnsubscribe; + }, + unsubscribe: () => { + unsubscribeLoggerFuncs.forEach(unsubscribe => { + try { + unsubscribe(); + } catch (error) { + log.debug('Error unsubscribing from MCP server diagnostics channel', error); + } + }); + + unsubscribeLoggerFuncs.length = 0; + } + }; }; /** * Memoize the server logger. */ -createServerLogger.memo = memo(createServerLogger, { cacheLimit: 10 }); +createServerLogger.memo = memo(createServerLogger, DEFAULT_OPTIONS.resourceMemoOptions.default); export { createServerLogger, registerMcpSubscriber, toMcpLevel, type McpLoggingLevel }; diff --git a/src/server.ts b/src/server.ts index 1fa720d..6b25d65 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,59 +3,123 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; import { fetchDocsTool } from './tool.fetchDocs'; import { componentSchemasTool } from './tool.componentSchemas'; -import { getOptions, runWithOptions } from './options.context'; -import { type GlobalOptions } from './options'; -import { log } from './logger'; +import { startHttpTransport, type HttpServerHandle } from './server.http'; +import { memo } from './server.caching'; +import { log, type LogEvent } from './logger'; import { createServerLogger } from './server.logger'; +import { type GlobalOptions } from './options'; +import { + getOptions, + getSessionOptions, + runWithOptions, + runWithSession +} from './options.context'; +import { DEFAULT_OPTIONS } from './options.defaults'; type McpTool = [string, { description: string; inputSchema: any }, (args: any) => Promise]; type McpToolCreator = (options?: GlobalOptions) => McpTool; +/** + * Server options. Equivalent to GlobalOptions. + */ +type ServerOptions = GlobalOptions; + +/** + * Represents the configuration settings for a server. + * + * @interface ServerSettings + * + * @property {McpToolCreator[]} [tools] - An optional array of tool 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[]; + enableSigint?: boolean; + allowProcessExit?: boolean; +} + +/** + * Server log event. + */ +type ServerLogEvent = LogEvent; + +/** + * A handler function to subscribe to server logs. Automatically unsubscribed on server shutdown. + * + * @param {ServerLogEvent} entry + */ +type ServerOnLogHandler = (entry: ServerLogEvent) => void; + +/** + * Subscribes a handler function to server logs. Automatically unsubscribed on server shutdown. + */ +type ServerOnLog = (handler: ServerOnLogHandler) => () => void; + /** * Server instance with shutdown capability + * + * @property stop - Stops the server, gracefully. + * @property isRunning - Indicates whether the server is running. + * @property {ServerOnLog} onLog - Subscribes to server logs. Automatically unsubscribed on server shutdown. */ interface ServerInstance { - - /** - * Stop the server gracefully - */ stop(): Promise; - - /** - * Check if server is running - */ isRunning(): boolean; + onLog: ServerOnLog; } /** * Create and run a server with shutdown, register tool and errors. * - * @param [options] - * @param [settings] + * @param [options] Server options + * @param [settings] Server settings (tools, signal handling, etc.) * @param [settings.tools] * @param [settings.enableSigint] + * @param [settings.allowProcessExit] + * @returns Server instance */ -const runServer = async (options = getOptions(), { +const runServer = async (options: ServerOptions = getOptions(), { tools = [ usePatternFlyDocsTool, fetchDocsTool, componentSchemasTool ], - enableSigint = true -}: { tools?: McpToolCreator[]; enableSigint?: boolean } = {}): Promise => { + enableSigint = true, + allowProcessExit = true +}: ServerSettings = {}): Promise => { + const session = getSessionOptions(); + let server: McpServer | null = null; let transport: StdioServerTransport | null = null; + let httpHandle: HttpServerHandle | null = null; let unsubscribeServerLogger: (() => void) | null = null; let running = false; + let onLogSetup: ServerOnLog = () => () => {}; const stopServer = async () => { + log.info(`\n${options.name} server shutting down... `); + if (server && running) { + log.info(`${options.name} shutting down...`); + + if (httpHandle) { + log.info('...closing HTTP transport'); + await httpHandle.close(); + httpHandle = null; + } + + log.info('...closing Server'); await server?.close(); running = false; - log.info('PatternFly MCP server stopped'); + + log.info(`${options.name} closed!\n`); unsubscribeServerLogger?.(); - process.exit(0); + + if (allowProcessExit) { + process.exit(0); + } } }; @@ -75,27 +139,48 @@ const runServer = async (options = getOptions(), { } ); - unsubscribeServerLogger = createServerLogger.memo(server); + const subUnsub = createServerLogger.memo(server); + + if (subUnsub) { + const { subscribe, unsubscribe } = subUnsub; + + // Track active logging subscriptions to clean up on stop() + unsubscribeServerLogger = unsubscribe; + + // Setup server logging for external handlers + onLogSetup = (handler: ServerOnLogHandler) => subscribe(handler); + } tools.forEach(toolCreator => { const [name, schema, callback] = toolCreator(options); log.info(`Registered tool: ${name}`); - server?.registerTool(name, schema, (args = {}) => runWithOptions(options, async () => await callback(args))); + server?.registerTool(name, schema, (args = {}) => + runWithSession(session, async () => + runWithOptions(options, async () => await callback(args)))); }); if (enableSigint) { - process.on('SIGINT', async () => stopServer()); + process.on('SIGINT', () => { + void stopServer(); + }); } - transport = new StdioServerTransport(); + if (options.isHttp) { + httpHandle = await startHttpTransport(server, options); + } else { + transport = new StdioServerTransport(); + await server.connect(transport); + } - await server.connect(transport); + 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('PatternFly MCP server running on stdio'); } catch (error) { - log.error('Error creating MCP server:', error); + log.error(`Error creating ${options.name} server:`, error); throw error; } @@ -106,13 +191,57 @@ const runServer = async (options = getOptions(), { isRunning(): boolean { return running; + }, + + onLog(handler: ServerOnLogHandler): () => void { + return onLogSetup(handler); } }; }; +/** + * Memoized version of runServer. + * - Automatically cleans up servers when cache entries are rolled off (cache limit reached) + * - Prevents port conflicts by returning the same server instance via memoization + * - `onCacheRollout` closes servers that were rolled out of caching due to cache limit + */ +runServer.memo = memo( + runServer, + { + ...DEFAULT_OPTIONS.resourceMemoOptions.default, + debug: info => { + log.debug(`Server memo: ${JSON.stringify(info, null, 2)}`); + }, + onCacheRollout: async ({ removed }) => { + const results: PromiseSettledResult[] = await Promise.allSettled(removed); + + for (const result of results) { + if (result.status === 'fulfilled') { + const server = result.value; + + if (server?.isRunning?.()) { + try { + await server.stop(); + } catch (error) { + console.error(`Error stopping server: ${error}`); + } + } + } else { + console.error(`Error cleaning up server: ${result?.reason?.message || result?.reason || 'Unknown error'}`); + } + } + } + } +); + export { runServer, type McpTool, type McpToolCreator, - type ServerInstance + type ServerInstance, + type ServerLogEvent, + type ServerOnLog, + type ServerOnLogHandler, + type ServerOptions, + type ServerSettings }; diff --git a/tests/__snapshots__/httpTransport.test.ts.snap b/tests/__snapshots__/httpTransport.test.ts.snap new file mode 100644 index 0000000..e7ac195 --- /dev/null +++ b/tests/__snapshots__/httpTransport.test.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator with two local files 1`] = ` +"# Documentation from documentation/guidelines/README.md + +# PatternFly Guidelines + +Core development rules for AI coders building PatternFly React applications. + +## Related Files + +- [**Component Rules**](./component-architecture.md) - Component structure requirements +- [**Styling Rules**](./styling-standards.md) - CSS and styling requirements +- [**Layout Rules**](../components/layout/README.md) - Page structure requirements + +## Essential Rules + +### Version Requirements + +- ✅ **ALWAYS use PatternFly v6** - Use \`pf-v6-\` prefixed classes only +- ❌ **NEVER use legacy versions** - No \`pf-v5-\`, \`pf-v4-\`, or \`pf-c-\` classes +- ✅ **Match component and CSS versions** - Ensure compatibility + +### Component Usage Rules + +- ✅ **Use PatternFly components first** - Before creating custom solutions +- ✅ **Compose components** - Build complex UIs by combining PatternFly components +- ❌ **Don't override component internals** - Use provided props and APIs + +### Tokenss +- ✅ **ALWAYS use PatternFly tokens** - Use \`pf-t-\` prefixed classes over \`pf-v6-\` classes (e.g., \`var(--pf-t--global--spacer--sm)\` not \`var(--pf-v6-global--spacer--sm)\`) + +### Text Components (v6+) +\`\`\`jsx +// ✅ Correct +import { Content } from '@patternfly/react-core'; +Title + +// ❌ Wrong - Don't use old Text components +Title +\`\`\` + +### Icon Usage +\`\`\`jsx +// ✅ Correct - Wrap with Icon component +import { Icon } from '@patternfly/react-core'; +import { UserIcon } from '@patternfly/react-icons'; + +\`\`\` + +### Styling Rules + +- ✅ **Use PatternFly utilities** - Before writing custom CSS +- ✅ **Use semantic design tokens** for custom CSS (e.g., \`var(--pf-t--global--text--color--regular)\`), not base tokens with numbers (e.g., \`--pf-t--global--text--color--100\`) or hardcoded values +- ❌ **Don't mix PatternFly versions** - Stick to v6 throughout + +### Documentation Requirements + +1. **Check [PatternFly.org](https://www.patternfly.org/) first** - Primary source for APIs +2. **Check the [PatternFly React GitHub repository](https://github.com/patternfly/patternfly-react)** for the latest source code, examples, and release notes +3. **Use "View Code" sections** - Copy working examples +4. **Reference version-specific docs** - Match your project's PatternFly version +5. **Provide context to AI** - Share links and code snippets when asking for help + +> For the most up-to-date documentation, use both the official docs and the source repositories. When using AI tools, encourage them to leverage context7 to fetch the latest documentation from these sources. + +### Accessibility Requirements + +- ✅ **WCAG 2.1 AA compliance** - All components must meet standards +- ✅ **Proper ARIA labels** - Use semantic markup and labels +- ✅ **Keyboard navigation** - Ensure full keyboard accessibility +- ✅ **Focus management** - Logical focus order and visible indicators + +## Quality Checklist + +- [ ] Uses PatternFly v6 classes only +- [ ] Components render correctly across browsers +- [ ] Responsive on mobile and desktop +- [ ] Keyboard navigation works +- [ ] Screen readers can access content +- [ ] No console errors or warnings +- [ ] Performance is acceptable + +## When Issues Occur + +1. **Check [PatternFly.org](https://www.patternfly.org/)** - Verify component API +2. **Inspect elements** - Use browser dev tools for PatternFly classes +3. **Search [GitHub issues](https://github.com/patternfly/patternfly-react/issues)** - Look for similar problems +4. **Provide context** - Share code snippets and error messages + +See [Common Issues](../troubleshooting/common-issues.md) for specific problems. + +--- + +# Documentation from documentation/components/README.md + +# PatternFly React Components + +You can find documentation on PatternFly's components at [PatternFly All components documentation](https://www.patternfly.org/components/all-components) + +## Specific info on Components + +- [AboutModal](https://www.patternfly.org/components/about-modal) +- [Accordion](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/components/accordion/accordion.md) +- [ActionList](https://www.patternfly.org/components/action-list) +- [Alert](https://www.patternfly.org/components/alert) +- [ApplicationLauncher](https://www.patternfly.org/components/application-launcher) +" +`; + +exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator with two remote files 1`] = ` +"# Documentation from https://www.patternfly.org/notARealPath/README.md + +# 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 + +--- + +# Documentation from https://www.patternfly.org/notARealPath/AboutModal.md + +# Test Document + +This is a test document for mocking remote HTTP requests." +`; + +exports[`PatternFly MCP, HTTP Transport should expose expected tools and stable shape 1`] = ` +{ + "toolNames": [ + "componentSchemas", + "fetchDocs", + "usePatternFlyDocs", + ], +} +`; + +exports[`PatternFly MCP, HTTP Transport should initialize MCP session over HTTP 1`] = ` +{ + "baseUrl": "http://127.0.0.1:5001", + "name": "@patternfly/patternfly-mcp", + "version": "2024-11-05", +} +`; diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index 14f5208..c1290d4 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -270,13 +270,13 @@ exports[`Logging should allow setting logging options, default 1`] = `[]`; exports[`Logging should allow setting logging options, stderr 1`] = ` [ - "[INFO]: Registered tool: usePatternFlyDocs + "[INFO]: Registered tool: usePatternFlyDocs ", - "[INFO]: Registered tool: fetchDocs + "[INFO]: Registered tool: fetchDocs ", - "[INFO]: Registered tool: componentSchemas + "[INFO]: Registered tool: componentSchemas ", - "[INFO]: PatternFly MCP server running on stdio + "[INFO]: @patternfly/patternfly-mcp server running on stdio transport ", ] `; @@ -288,7 +288,7 @@ exports[`Logging should allow setting logging options, with mcp protocol 1`] = ` { "method": "notifications/message", "params": { - "data": "PatternFly MCP server running on stdio", + "data": "@patternfly/patternfly-mcp server running on stdio transport", "level": "info", "logger": "@patternfly/patternfly-mcp", }, diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts new file mode 100644 index 0000000..38fbd29 --- /dev/null +++ b/tests/httpTransport.test.ts @@ -0,0 +1,127 @@ +/** + * Requires: npm run build prior to running Jest. + */ +import { + startServer, + type HttpTransportClient, + type RpcRequest +} from './utils/httpTransportClient'; +import { setupFetchMock } from './utils/fetchMock'; + +describe('PatternFly MCP, 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: /.*\.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.' + } + ], + excludePorts: [5001] + }); + + CLIENT = await startServer({ http: { port: 5001 } }); + }); + + afterAll(async () => { + if (CLIENT) { + // You may still receive jest warnings about a running process, but clean up case we forget at the test level. + await CLIENT.close(); + CLIENT = undefined; + } + + if (FETCH_MOCK) { + await FETCH_MOCK.cleanup(); + } + }); + + it('should initialize MCP session over HTTP', async () => { + const response = await CLIENT?.initialize(); + + expect({ + version: response?.result?.protocolVersion, + name: (response as any)?.result?.serverInfo?.name, + baseUrl: CLIENT?.baseUrl + }).toMatchSnapshot(); + }); + + it('should expose expected tools and stable shape', async () => { + const response = await CLIENT?.send({ + method: 'tools/list', + params: {} + }); + const tools = response?.result?.tools || []; + const toolNames = tools.map((tool: any) => tool.name).sort(); + + expect({ toolNames }).toMatchSnapshot(); + }); + + it('should concatenate headers and separator with two local files', async () => { + const req = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'usePatternFlyDocs', + arguments: { + urlList: [ + 'documentation/guidelines/README.md', + 'documentation/components/README.md' + ] + } + } + } as RpcRequest; + + const response = await CLIENT?.send(req); + const text = response?.result?.content?.[0]?.text || ''; + + expect(text.startsWith('# Documentation from')).toBe(true); + expect(text).toMatchSnapshot(); + }); + + it('should concatenate headers and separator with two remote files', async () => { + const CLIENT = await startServer({ http: { port: 5002 } }); + const req = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'fetchDocs', + arguments: { + urlList: [ + 'https://www.patternfly.org/notARealPath/README.md', + 'https://www.patternfly.org/notARealPath/AboutModal.md' + ] + } + } + } as RpcRequest; + + const response = await CLIENT.send(req); + const text = response?.result?.content?.[0]?.text || ''; + + expect(text.startsWith('# Documentation from')).toBe(true); + expect(text).toMatchSnapshot(); + CLIENT.close(); + }); +}); diff --git a/tests/utils/httpTransportClient.ts b/tests/utils/httpTransportClient.ts new file mode 100644 index 0000000..766b9c3 --- /dev/null +++ b/tests/utils/httpTransportClient.ts @@ -0,0 +1,236 @@ +/** + * HTTP Transport Client for E2E Testing + * Uses the MCP SDK's built-in Client and StreamableHTTPClientTransport + */ +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'; + +// @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'; + +export type { Request as RpcRequest } from '@modelcontextprotocol/sdk/types.js'; + +export type StartHttpServerOptions = { + docsHost?: boolean; + http?: Partial; + isHttp?: boolean; + logging?: Partial & { level?: LoggingLevel }; +}; + +export type StartHttpServerSettings = PfMcpSettings; + +export interface RpcResponse { + jsonrpc?: '2.0'; + id: number | string | null; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +} + +export interface HttpTransportClient { + baseUrl: string; + sessionId?: string | undefined; + send: (request: { method: string; params?: any }) => Promise; + initialize: () => Promise; + close: () => Promise; + logs: () => string[]; + inProcessLogs: () => string[]; + protocolLogs: () => string[]; +} + +/** + * Start an HTTP server using the programmatic API and return a client for testing + * + * @param options - Server configuration options + * @param settings - Additional settings for the server (e.g., allowProcessExit) + */ +export const startServer = async ( + options: StartHttpServerOptions = {}, + settings: StartHttpServerSettings = {} +): Promise => { + const updatedOptions: PfMcpOptions = { + isHttp: true, + docsHost: false, + ...options, + http: { + port: 5000, + host: '127.0.0.1', + allowedOrigins: [], + allowedHosts: [], + ...options.http + }, + logging: { + logger: '@patternfly/patternfly-mcp', + level: options.logging?.level || 'info', + stderr: options.logging?.stderr || false, + protocol: options.logging?.protocol || false, + transport: 'mcp' + }, + mode: 'test' + }; + + const { host, port } = updatedOptions.http || {}; + + // Start server using public API from dist/index.js (tests the actual compiled output) + const server = await start(updatedOptions, settings); + + // Collect all server logs in-process + const inProcessLogs: string[] = []; + + server.onLog((event: ServerLogEvent) => { + inProcessLogs.push(event.msg || JSON.stringify(event)); + }); + + // Verify server is running + if (!server?.isRunning()) { + throw new Error(`Server failed to start on port ${port}`); + } + + let httpClientUrl: URL; + + try { + // Construct base URL from options + const baseUrl = `http://${host}:${port}/mcp`; + httpClientUrl = new URL(baseUrl); + } catch (error) { + throw new Error(`Failed to construct base URL: ${error}, host: ${host}, port: ${port}`); + } + + // Create MCP SDK client and transport + const transport = new StreamableHTTPClientTransport(httpClientUrl); + const mcpClient = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + // Track whether we're intentionally closing the client + // This allows us to suppress expected disconnection errors during cleanup + let isClosing = false; + + // Set up error handler - only log unexpected errors + mcpClient.onerror = error => { + // Only log errors that occur when not intentionally closing + // SSE stream disconnection during cleanup is expected behavior + if (!isClosing) { + console.error('MCP Client error:', error); + } + }; + + // Collect protocol logs (MCP notifications/message) when enabled via CLI arg + const protocolLogs: any[] = []; + + // Register the handler BEFORE connect so we don't miss early server messages + if (updatedOptions.logging?.protocol) { + try { + mcpClient.setNotificationHandler(LoggingMessageNotificationSchema, (params: any) => { + protocolLogs.push(params); + }); + } catch {} + } + + // Connect client to transport (this automatically initializes the session) + await mcpClient.connect(transport as any); + + // Negotiate protocol logging level if the server advertises it + if (updatedOptions.logging?.protocol) { + try { + await mcpClient.setLoggingLevel(updatedOptions.logging.level as LoggingLevel); + } catch {} + } + + // Wait for the server to be ready + await new Promise(resolve => { + const timer = setTimeout(resolve, 50); + + timer.unref(); + }); + + return { + baseUrl: `http://${host}:${port}`, + sessionId: transport.sessionId, + + async send(request: { method: string; params?: any }): Promise { + // Use the SDK client's request method + // For tools/list, use the proper schema + if (request.method === 'tools/list') { + const result = await mcpClient.request(request, ListToolsResultSchema); + + return { + jsonrpc: '2.0', + id: null, + result: result as any + }; + } + // For other requests, use the client's request method with generic ResultSchema + const result = await mcpClient.request(request as any, ResultSchema); + + return { + jsonrpc: '2.0', + id: null, + result: result as any + }; + }, + + async initialize(): Promise { + // Client is already initialized via connect(), but return the initialize result + // We can't get it back, so we'll just return a success response + return { + jsonrpc: '2.0', + id: null, + result: { + protocolVersion: '2024-11-05', + capabilities: {}, + serverInfo: { + name: '@patternfly/patternfly-mcp', + version: '0.1.0' + } + } + } as RpcResponse; + }, + + async close(): Promise { + // Mark that we're intentionally closing to suppress expected disconnection errors + isClosing = true; + + // Remove error handler to prevent any error logging during cleanup + // @ts-ignore + mcpClient.onerror = null; + + // Close transport first (this closes all connections and sessions) + // This may trigger SSE stream disconnection, which is expected + await transport.close(); + + // Minor wait for transport cleanup to complete. Increase delay to ensure SSE stream and all event listeners are cleaned up + await new Promise(resolve => { + const timer = setTimeout(resolve, 50); + + timer.unref(); + }); + // Stop the server after transport is fully closed + await server.stop(); + + // Additional small delay after server stop to ensure all cleanup completes + await new Promise(resolve => { + const timer = setTimeout(resolve, 50); + + timer.unref(); + }); + }, + + inProcessLogs: () => inProcessLogs.slice(), + logs: () => [ + ...inProcessLogs, + ...protocolLogs + ], + protocolLogs: () => protocolLogs.slice() + }; +}; diff --git a/tests/utils/stdioTransportClient.ts b/tests/utils/stdioTransportClient.ts index 8b3fe13..91fe5e1 100644 --- a/tests/utils/stdioTransportClient.ts +++ b/tests/utils/stdioTransportClient.ts @@ -219,6 +219,6 @@ export const startServer = async ({ stderrLogs: () => stderrLogs.slice(), protocolLogs: () => protocolLogs.slice(), stop, - close: stop + close: stop // Alias for stop, align with the http transport client }; };