diff --git a/README.md b/README.md index 855de3a..6aa453d 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,10 @@ A Model Context Protocol (MCP) server that provides access to PatternFly React d The Model Context Protocol (MCP) is an open standard that enables AI assistants to securely access external data sources and tools. This server provides a standardized way to expose PatternFly documentation and development rules to MCP-compatible clients. -## Features - -- **TypeScript**: Full type safety and modern JavaScript features -- **PatternFly Documentation Access**: Browse, search, and retrieve PatternFly development rules -- **Component Schemas**: Access JSON Schema validation for PatternFly React components -- **Comprehensive Rule Coverage**: Access setup, guidelines, components, charts, chatbot, and troubleshooting documentation -- **Smart Search**: Find specific rules and patterns across all documentation -- **Error Handling**: Robust error handling with proper MCP error codes -- **Modern Node.js**: Uses ES modules and the latest Node.js features - ## Prerequisites - Node.js 20.0.0 or higher + - **Note**: Loading **Tool Plugins** from an external file or package requires Node.js >= 22 at runtime. On Node < 22, the server starts with built‑in tools only and logs a one‑time warning. - NPM (or another Node package manager) ## Installation @@ -56,161 +47,45 @@ npm install @patternfly/patternfly-mcp npx @patternfly/patternfly-mcp ``` -## Scripts - -These are the most relevant NPM scripts from package.json: - -- `build`: Build the TypeScript project (cleans dist, type-checks, bundles) -- `build:clean`: Remove dist -- `build:watch`: Build in watch mode -- `start`: Run the built server, CLI (node dist/cli.js) -- `start:dev`: Run with tsx in watch mode (development) -- `test`: Run linting, type-check, and unit tests in src/ -- `test:dev`: Jest watch mode for unit tests -- `test:integration`: Build and run integration tests in tests/ -- `test:integration-dev`: Watch mode for integration tests -- `test:lint`: Run ESLint (code quality checks) -- `test:lint-fix`: Run ESLint with auto-fix -- `test:types`: TypeScript type-check only (no emit) - ## Usage -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. - -Returned content format: -- For each entry in urlList, the server loads its content, prefixes it with a header like: `# Documentation from ` and joins multiple entries using a separator: `\n\n---\n\n`. -- If an entry fails to load, an inline error message is included for that entry. +The MCP server can communicate over **stdio** (default) or **HTTP** transport. It provides access to PatternFly documentation through built-in tools. -## Logging - -The server uses a `diagnostics_channel`–based logger that keeps STDIO stdout pure by default. No terminal output occurs unless you enable a sink. +### Built-in Tools -- Defaults: `level='info'`, `stderr=false`, `protocol=false` -- Sinks (opt‑in): `--log-stderr`, `--log-protocol` (forwards to MCP clients; requires advertising `capabilities.logging`) -- Transport tag: `transport: 'stdio' | 'http'` (no I/O side effects) -- Environment variables: not used for logging in this version -- Process scope: logger is process‑global; recommend one server per process - -CLI examples: - -```bash -patternfly-mcp # default (no terminal output) -patternfly-mcp --verbose # level=debug (still no stderr) -patternfly-mcp --log-stderr # enable stderr sink -patternfly-mcp --log-level warn --log-stderr -patternfly-mcp --log-protocol --log-level info # forward logs to MCP clients -``` - -Programmatic: - -```ts -await start({ logging: { level: 'info', stderr: false, protocol: false } }); -``` +All tools accept an argument named `urlList` (array of strings) or `componentName` (string). -### Tool: usePatternFlyDocs +#### Tool: usePatternFlyDocs +Use this to fetch high-level index content (for example, a local `README.md` that contains relevant links, or `llms.txt` files in docs-host mode). From that content, you can select specific URLs to pass to `fetchDocs`. -Use this to fetch high-level index content (for example, a local README.md that contains relevant links, or llms.txt files in docs-host mode). From that content, you can select specific URLs to pass to fetchDocs. +- **Parameters**: `urlList`: `string[]` (required) -Parameters: -- `urlList`: string[] (required) +#### Tool: fetchDocs +Use this to fetch one or more specific documentation pages (e.g., concrete design guidelines or accessibility pages) after you’ve identified them via `usePatternFlyDocs`. -Response (tools/call): -- content[0].type = "text" -- content[0].text = concatenated documentation content (one or more sources) +- **Parameters**: `urlList`: `string[]` (required) -### Tool: fetchDocs +#### Tool: componentSchemas +Use this to fetch the JSON Schema for a specific PatternFly component. -Use this to fetch one or more specific documentation pages (e.g., concrete design guidelines or accessibility pages) after you’ve identified them via usePatternFlyDocs. +- **Parameters**: `componentName`: `string` (required) -Parameters: -- `urlList`: string[] (required) +### Docs-host mode (local llms.txt mode) -Response (tools/call): -- content[0].type = "text" -- content[0].text = concatenated documentation content (one or more sources) - -## Docs-host mode (local llms.txt mode) - -If you run the server with --docs-host, local paths you pass in urlList are resolved relative to the llms-files folder at the repository root. This is useful when you have pre-curated llms.txt files locally. +If you run the server with `--docs-host`, local paths you pass in `urlList` are resolved relative to the `llms-files` folder at the repository root. This is useful when you have pre-curated `llms.txt` files locally. Example: - ```bash npx @patternfly/patternfly-mcp --docs-host ``` -Then, passing a local path such as react-core/6.0.0/llms.txt in urlList will load from llms-files/react-core/6.0.0/llms.txt. - -## 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 +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`. -**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 -## 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. - -### Minimal client config (npx) +Most MCP clients use a JSON configuration to specify how to start this server. Below are examples you can adapt to your MCP client. +#### Minimal client config (npx) ```json { "mcpServers": { @@ -223,50 +98,33 @@ Most MCP clients use a JSON configuration to specify how to start this server. T } ``` -### Docs-host mode - +#### HTTP transport mode ```json { "mcpServers": { "patternfly-docs": { "command": "npx", - "args": ["-y", "@patternfly/patternfly-mcp@latest", "--docs-host"], - "description": "PatternFly docs (docs-host mode)" - } - } -} -``` - -### Local development (after build) - -```json -{ - "mcpServers": { - "patternfly-docs": { - "command": "node", - "args": ["dist/cli.js"], - "cwd": "/path/to/patternfly-mcp", - "description": "PatternFly docs (local build)" + "args": ["-y", "@patternfly/patternfly-mcp@latest", "--http", "--port", "8080"], + "description": "PatternFly docs (HTTP transport)" } } } ``` -### HTTP transport mode - +#### Docs-host mode ```json { "mcpServers": { "patternfly-docs": { "command": "npx", - "args": ["-y", "@patternfly/patternfly-mcp@latest", "--http", "--port", "8080"], - "description": "PatternFly docs (HTTP transport)" + "args": ["-y", "@patternfly/patternfly-mcp@latest", "--docs-host"], + "description": "PatternFly docs (docs-host mode)" } } } ``` -### HTTP transport with custom options +#### Custom local tool ```json { @@ -276,180 +134,197 @@ Most MCP clients use a JSON configuration to specify how to start this server. T "args": [ "-y", "@patternfly/patternfly-mcp@latest", - "--http", - "--port", "8080", - "--host", "0.0.0.0", - "--allowed-origins", "http://localhost:3001,https://example.com" + "--tool", + "./mcp-tools/local-custom-tool.js" ], - "description": "PatternFly docs (HTTP transport, custom port/host/CORS)" + "description": "PatternFly MCP with a local custom tool" } } } ``` -## Inspector-CLI examples (tools/call) +## Features -Note: The parameter name is urlList and it must be a JSON array of strings. +### HTTP Transport -usePatternFlyDocs (example with a local README): +By default, the server uses stdio. Use the `--http` flag to enable HTTP transport. -```bash -npx @modelcontextprotocol/inspector-cli \ - --config ./mcp-config.json \ - --server patternfly-docs \ - --cli \ - --method tools/call \ - --tool-name usePatternFlyDocs \ - --tool-arg urlList='["documentation/guidelines/README.md"]' -``` +#### Options +- `--http`: Enable HTTP transport mode. +- `--port `: Port to listen on (default: 8080). +- `--host `: Host 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. -fetchDocs (example with external URLs): +#### DNS Rebinding Protection +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. -```bash -npx @modelcontextprotocol/inspector-cli \ - --config ./mcp-config.json \ - --server patternfly-docs \ - --cli \ - --method tools/call \ - --tool-name fetchDocs \ - --tool-arg urlList='[ - "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components/about-modal/about-modal.md", - "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility/components/about-modal/about-modal.md" - ]' -``` +### Logging + +The server uses a `diagnostics_channel`–based logger that keeps STDIO stdout pure by default. No terminal output occurs unless you enable a sink. -componentSchemas (get component JSON Schema): +- `--log-stderr`: Enable terminal logging. +- `--log-protocol`: Forward logs to MCP clients (requires advertising `capabilities.logging`). +- `--log-level `: Set log level (`debug`, `info`, `warn`, `error`). Default: `info`. +- `--verbose`: Shortcut for `debug` level. +Example: ```bash -npx @modelcontextprotocol/inspector-cli \ - --config ./mcp-config.json \ - --server patternfly-docs \ - --cli \ - --method tools/call \ - --tool-name componentSchemas \ - --tool-arg componentName='Button' +npx @patternfly/patternfly-mcp --log-stderr --log-level debug ``` -## Environment variables - -- DOC_MCP_FETCH_TIMEOUT_MS: Milliseconds to wait before aborting an HTTP fetch (default: 15000) -- DOC_MCP_CLEAR_COOLDOWN_MS: Default cooldown value used in internal cache configuration. The current public API does not expose a `clearCache` tool. - -## Programmatic usage (advanced) +### MCP Tool Plugins -The package provides programmatic access through the `start()` function: +You can extend the server's capabilities by loading **Tool Plugins** at startup. These plugins run out‑of‑process in an isolated **Tools Host** (Node.js >= 22) to ensure security and stability. -```typescript -import { start, main, type CliOptions, type ServerInstance } from '@patternfly/patternfly-mcp'; +#### CLI Usage +- `--tool `: Load one or more plugins. You can provide a path to a local file or the name of an installed NPM package. + - *Examples*: `--tool @acme/my-plugin`, `--tool ./local-plugins/weather-tool.js`, `--tool ./a.js,./b.js` +- `--plugin-isolation `: Tools Host permission preset. + - **Default**: `strict`. In strict mode, network and filesystem write access are denied; fs reads are allow‑listed to your project and resolved plugin directories. -// Use with default options (equivalent to CLI without flags) -const server = await start(); +#### Behavior and Limitations +- **Node version gate**: Node < 22 skips loading plugins from external sources with a warning; built‑ins still register. +- **Supported inputs**: ESM packages (installed in `node_modules`) and local ESM files with default exports. +- **Not supported**: Raw TypeScript sources (`.ts`) or remote `http(s):` URLs. -// Override CLI options programmatically -const serverWithOptions = await start({ docsHost: true }); +#### Troubleshooting +- If tool plugins don't appear, verify the Node version and check logs (enable `--log-stderr`). +- Startup `load:ack` warnings/errors from tool plugins are logged when stderr/protocol logging is enabled. +- If `tools/call` rejects with schema errors, ensure `inputSchema` is valid. See [Authoring Tools](#authoring-tools) for details. +- If the tool is having network access issues, you may need to configure `--plugin-isolation none`. This is generally discouraged for security reasons but may be necessary in some cases. -// Multiple options can be overridden -const customServer = await start({ - docsHost: true, - http: true, - port: 8080, - host: '0.0.0.0' -}); +#### Terminology +- **`Tool`**: The low-level tuple format `[name, schema, handler]`. +- **`Tool Config`**: The authoring object format `{ name, description, inputSchema, handler }`. +- **`Tool Factory`**: A function wrapper `(options) => Tool` (internal). +- **`Tool Module`**: The programmatic result of `createMcpTool`, representing a collection of tools. -// TypeScript users can use the CliOptions type for type safety -const options: Partial = { docsHost: true }; -const typedServer = await start(options); +#### Authoring Tools -// Server instance provides shutdown control -console.log('Server running:', server.isRunning()); // true +We recommend using the `createMcpTool` helper to define tools. It ensures your tools are properly normalized for the server. -// Graceful shutdown -await server.stop(); -console.log('Server running:', server.isRunning()); // false +##### Authoring a single Tool Module +```ts +import { createMcpTool } from '@patternfly/patternfly-mcp'; + +export default createMcpTool({ + name: 'hello', + description: 'Say hello', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }, + async handler({ name }) { + return `Hello, ${name}!`; + } +}); ``` -### ServerInstance Interface +##### Authoring multiple tools in one module +```ts +import { createMcpTool } from '@patternfly/patternfly-mcp'; -The `start()` function returns a `ServerInstance` object with the following methods: +export default createMcpTool([ + { name: 'hi', description: 'Greets', inputSchema: {}, handler: () => 'hi' }, + { name: 'bye', description: 'Farewell', inputSchema: {}, handler: () => 'bye' } +]); +``` -```typescript -interface ServerInstance { - /** - * Stop the server gracefully - */ - stop(): Promise; +##### Input Schema Format +The `inputSchema` property accepts either **plain JSON Schema objects** or **Zod schemas**. Both formats are automatically converted to the format required by the MCP SDK. - /** - * Check if server is running - */ - isRunning(): boolean; +**JSON Schema (recommended):** +```ts +inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name'] } ``` -**Usage Examples**: -```typescript -const server = await start(); +**Zod Schema:** +```ts +import { z } from 'zod'; -// Check if server is running -if (server.isRunning()) { - console.log('Server is active'); +inputSchema: { + name: z.string(), + age: z.number().optional() } - -// Graceful shutdown -await server.stop(); - -// Verify shutdown -console.log('Server running:', server.isRunning()); // false ``` -## Returned content details +### Embedding the Server -For each provided path or URL, the server returns a section: -- Header: `# Documentation from ` -- Body: the raw file content fetched from disk or network -- Sections are concatenated with `\n\n---\n\n` +You can embed the MCP server inside your application using the `start()` function and provide **Tool Modules** directly. -This makes it easier to see where each chunk of content came from when multiple inputs are provided. +```ts +import { start, createMcpTool, type PfMcpInstance, type ToolModule } from '@patternfly/patternfly-mcp'; + +const echoTool: ToolModule = createMcpTool({ + name: 'echoAMessage', + description: 'Echo back the provided user message.', + inputSchema: { + type: 'object', + properties: { message: { type: 'string' } }, + required: ['message'] + }, + handler: async (args: { message: string }) => ({ text: `You said: ${args.message}` }) +}); -## Publishing +async function main() { + const server: PfMcpInstance = await start({ + toolModules: [ + echoTool + ] + }); + + // Optional: observe refined server logs in‑process + server.onLog((event) => { + if (event.level !== 'debug') { + console.warn(`[${event.level}] ${event.msg || ''}`); + } + }); -To make this package available via npx, you need to publish it to npm: + // Graceful shutdown + process.on('SIGINT', async () => { + await server.stop(); + process.exit(0); + }); +} -1. Ensure you have an npm account and are logged in: -```bash -npm login +main(); ``` -2. Update the version in package.json if needed: -```bash -npm version patch # or minor/major -``` +## Development and Maintenance -3. Publish to npm: -```bash -npm publish -``` +### Scripts +- `npm run build`: Build the project (cleans dist, type-checks, bundles). +- `npm test`: Run unit tests. +- `npm run test:integration`: Run e2e tests. +- `npm run start:dev`: Run with `tsx` in watch mode. +- `npm run test:lint`: Run ESLint. -After publishing, users can run your MCP server with: +### Environment Variables +- `DOC_MCP_FETCH_TIMEOUT_MS`: Milliseconds to wait before aborting an HTTP fetch (default: 15000). + +### Inspector-CLI Examples ```bash -npx @patternfly/patternfly-mcp +npx @modelcontextprotocol/inspector-cli \ + --config ./mcp-config.json \ + --server patternfly-docs \ + --cli \ + --method tools/call \ + --tool-name usePatternFlyDocs \ + --tool-arg urlList='["documentation/guidelines/README.md"]' ``` -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests if applicable -5. Submit a pull request - -## License - -MIT License - see LICENSE file for details. - ## Resources - -- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) -- [MCP SDK Documentation](https://github.com/modelcontextprotocol/typescript-sdk) +- [Model Context Protocol](https://modelcontextprotocol.io/) +- [PatternFly React](https://www.patternfly.org/) +- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) - [Node.js Documentation](https://nodejs.org/en/docs/) -- [TypeScript Documentation](https://www.typescriptlang.org/docs/) +- [TypeScript Documentation](https://www.typescriptlang.org/docs/) diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index b639fca..eb4596d 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -37,7 +37,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "invokeTimeoutMs": 10000, "loadTimeoutMs": 5000, }, - "pluginIsolation": "none", + "pluginIsolation": "strict", "repoName": "patternfly-mcp", "resourceMemoOptions": { "default": { diff --git a/src/__tests__/__snapshots__/options.test.ts.snap b/src/__tests__/__snapshots__/options.test.ts.snap index 3f817c6..6e56c0e 100644 --- a/src/__tests__/__snapshots__/options.test.ts.snap +++ b/src/__tests__/__snapshots__/options.test.ts.snap @@ -17,6 +17,8 @@ exports[`parseCliOptions should attempt to parse args with --allowed-hosts 1`] = "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -37,6 +39,8 @@ exports[`parseCliOptions should attempt to parse args with --allowed-origins 1`] "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -52,6 +56,8 @@ exports[`parseCliOptions should attempt to parse args with --docs-host flag 1`] "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -69,6 +75,8 @@ exports[`parseCliOptions should attempt to parse args with --http and --host 1`] "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -86,6 +94,8 @@ exports[`parseCliOptions should attempt to parse args with --http and --port 1`] "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -101,6 +111,8 @@ exports[`parseCliOptions should attempt to parse args with --http and invalid -- "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -116,6 +128,8 @@ exports[`parseCliOptions should attempt to parse args with --http flag 1`] = ` "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -131,6 +145,8 @@ exports[`parseCliOptions should attempt to parse args with --log-level flag 1`] "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -146,6 +162,8 @@ exports[`parseCliOptions should attempt to parse args with --log-stderr flag and "stderr": true, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -161,6 +179,8 @@ exports[`parseCliOptions should attempt to parse args with --verbose flag 1`] = "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -176,6 +196,8 @@ exports[`parseCliOptions should attempt to parse args with --verbose flag and -- "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -191,6 +213,8 @@ exports[`parseCliOptions should attempt to parse args with other arguments 1`] = "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; @@ -206,5 +230,7 @@ exports[`parseCliOptions should attempt to parse args without --docs-host flag 1 "stderr": false, "transport": "stdio", }, + "pluginIsolation": undefined, + "toolModules": [], } `; diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index a1a12ab..4fc88d5 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -6,6 +6,9 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "Registered tool: usePatternFlyDocs", ], @@ -35,6 +38,9 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "Registered tool: usePatternFlyDocs", ], @@ -64,6 +70,9 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "test-server-4 server running on stdio transport", ], @@ -98,6 +107,9 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "test-server-7 server running on stdio transport", ], @@ -127,6 +139,9 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "test-server-8 server running on stdio transport", ], @@ -161,12 +176,18 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "Registered tool: loremIpsum", ], [ "test-server-5 server running on stdio transport", ], + [ + "Built-in tool at index 0 is missing the static name property, "toolName"", + ], [ "Tool "loremIpsum" has a non Zod inputSchema. This may cause unexpected issues.", ], @@ -203,6 +224,9 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "Registered tool: loremIpsum", ], @@ -212,6 +236,12 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "test-server-6 server running on stdio transport", ], + [ + "Built-in tool at index 0 is missing the static name property, "toolName"", + ], + [ + "Built-in tool at index 1 is missing the static name property, "toolName"", + ], [ "Tool "loremIpsum" has a non Zod inputSchema. This may cause unexpected issues.", ], @@ -252,6 +282,9 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "test-server-3 server running on stdio transport", ], @@ -286,6 +319,9 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "Registered tool: usePatternFlyDocs", ], @@ -333,6 +369,9 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Server logging enabled.", ], + [ + "No external tools loaded.", + ], [ "Registered tool: usePatternFlyDocs", ], diff --git a/src/__tests__/__snapshots__/server.tools.test.ts.snap b/src/__tests__/__snapshots__/server.tools.test.ts.snap index cd5c620..32be3a4 100644 --- a/src/__tests__/__snapshots__/server.tools.test.ts.snap +++ b/src/__tests__/__snapshots__/server.tools.test.ts.snap @@ -17,6 +17,9 @@ exports[`composeTools should attempt to setup creators, file package creators 1` exports[`composeTools should attempt to setup creators, file package creators, Node.js 20 1`] = ` { "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], [ "External tool plugins require Node >= 22; skipping file-based tools.", ], @@ -27,7 +30,11 @@ exports[`composeTools should attempt to setup creators, file package creators, N exports[`composeTools should attempt to setup creators, file package creators, Node.js 24 1`] = ` { - "log": [], + "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], + ], "toolsCount": 5, } `; @@ -35,6 +42,9 @@ exports[`composeTools should attempt to setup creators, file package creators, N exports[`composeTools should attempt to setup creators, file package creators, Node.js undefined 1`] = ` { "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], [ "External tool plugins require Node >= 22; skipping file-based tools.", ], @@ -46,6 +56,9 @@ exports[`composeTools should attempt to setup creators, file package creators, N exports[`composeTools should attempt to setup creators, file package duplicate creators 1`] = ` { "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], [ "Skipping tool plugin "@patternfly/tools" – name already used by built-in/inline tool.", ], @@ -56,7 +69,11 @@ exports[`composeTools should attempt to setup creators, file package duplicate c exports[`composeTools should attempt to setup creators, inline and file package creators 1`] = ` { - "log": [], + "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], + ], "toolsCount": 7, } `; @@ -64,6 +81,9 @@ exports[`composeTools should attempt to setup creators, inline and file package exports[`composeTools should attempt to setup creators, inline and file package creators duplicate builtin creators 1`] = ` { "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], [ "Skipping inline tool "loremipsum" because a tool with the same name is already provided (built-in or earlier).", ], @@ -78,6 +98,9 @@ exports[`composeTools should attempt to setup creators, inline and file package exports[`composeTools should attempt to setup creators, inline and file package creators, duplicates 1`] = ` { "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], [ "Skipping tool plugin "@patternfly/tools" – name already used by built-in/inline tool.", ], @@ -92,6 +115,9 @@ exports[`composeTools should attempt to setup creators, inline and file package exports[`composeTools should attempt to setup creators, inline and file package creators, duplicates, Node.js 20 1`] = ` { "log": [ + [ + "Existing Tools Host session detected test-session-id. Shutting down the existing host before creating a new one.", + ], [ "External tool plugins require Node >= 22; skipping file-based tools.", ], diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 8c18b89..a630608 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -8,6 +8,7 @@ import { runServer } from '../server'; jest.mock('../options'); jest.mock('../options.context'); jest.mock('../server'); +jest.mock('../server.tools'); const mockParseCliOptions = parseCliOptions as jest.MockedFunction; const mockSetOptions = setOptions as jest.MockedFunction; diff --git a/src/__tests__/options.context.test.ts b/src/__tests__/options.context.test.ts index f970f01..123fc08 100644 --- a/src/__tests__/options.context.test.ts +++ b/src/__tests__/options.context.test.ts @@ -13,24 +13,33 @@ const MockStdioServerTransport = StdioServerTransport as jest.MockedClass { it('should ignore valid but incorrect options for merged options', () => { - const updatedOptions = setOptions({ logging: 'oops' as any, resourceMemoOptions: 'gotcha' as any, toolMemoOptions: 'really?' as any }); + const updatedOptions = setOptions({ + logging: 'oops' as any, + resourceMemoOptions: 'gotcha' as any, + toolMemoOptions: 'really?' as any, + pluginIsolation: 'fun' as any + }); expect(updatedOptions.logging.protocol).toBe(DEFAULT_OPTIONS.logging.protocol); expect(updatedOptions.resourceMemoOptions?.readFile?.expire).toBe(DEFAULT_OPTIONS.resourceMemoOptions?.readFile?.expire); expect(updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.fetchDocs?.expire); + expect(updatedOptions.pluginIsolation).toBe(DEFAULT_OPTIONS.pluginIsolation); }); it('should ignore null/invalid nested overrides safely', () => { - const updatedOptions = setOptions({ logging: null as any, resourceMemoOptions: null as any }); + const updatedOptions = setOptions({ logging: null as any, resourceMemoOptions: null as any, pluginIsolation: null as any }); - expect(typeof updatedOptions.logging.protocol === 'boolean').toBe(true); + expect(typeof updatedOptions.logging.protocol).toBe('boolean'); expect(updatedOptions.logging.protocol).toBe(DEFAULT_OPTIONS.logging.protocol); - expect(typeof updatedOptions.resourceMemoOptions?.readFile?.expire === 'number').toBe(true); + expect(typeof updatedOptions.resourceMemoOptions?.readFile?.expire).toBe('number'); expect(updatedOptions.resourceMemoOptions?.readFile?.expire).toBe(DEFAULT_OPTIONS.resourceMemoOptions?.readFile?.expire); - expect(typeof updatedOptions.toolMemoOptions?.fetchDocs?.expire === 'number').toBe(true); + expect(typeof updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe('number'); expect(updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.fetchDocs?.expire); + + expect(typeof updatedOptions.pluginIsolation).toBe('string'); + expect(updatedOptions.pluginIsolation).toBe(DEFAULT_OPTIONS.pluginIsolation); }); }); diff --git a/src/__tests__/server.toolsUser.test.ts b/src/__tests__/server.toolsUser.test.ts index 41984c2..6693ea6 100644 --- a/src/__tests__/server.toolsUser.test.ts +++ b/src/__tests__/server.toolsUser.test.ts @@ -18,7 +18,7 @@ import { sanitizeStaticToolName, type Tool, type ToolCreator, - type MultiToolConfig, + type ToolMultiConfig, type ToolConfig } from '../server.toolsUser'; import { isZodSchema } from '../server.schema'; @@ -710,7 +710,7 @@ describe('createMcpTool', () => { createMcpTool(['dolorSit', { description: 'dolor sit', inputSchema: { type: 'object', properties: {} } }, () => {}]), createMcpTool('@scope/pkg4'), '@scope/pkg5' - ] as MultiToolConfig + ] as ToolMultiConfig } ])('should normalize configs, $description', ({ config }) => { const result = createMcpTool(config); diff --git a/src/index.ts b/src/index.ts index c749a8b..377711e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,19 @@ import { type ServerOnLogHandler, type ServerLogEvent } from './server'; +import { + createMcpTool, + type ToolCreator, + type ToolModule, + type ToolConfig, + type ToolMultiConfig, + type ToolExternalOptions, + type ToolInternalOptions +} from './server.toolsUser'; /** * 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. @@ -36,21 +43,81 @@ type PfMcpOptions = DefaultOptionsOverrides & { type PfMcpSettings = Pick; /** - * Main function - CLI entry point with optional programmatic overrides + * Server instance with shutdown capability + * + * @alias ServerInstance + */ +type PfMcpInstance = ServerInstance; + +/** + * Subscribes a handler function, `PfMcpOnLogHandler`, to server logs. Automatically unsubscribed on server shutdown. + * + * @alias ServerOnLog + */ +type PfMcpOnLog = ServerOnLog; + +/** + * The handler function passed by `onLog`, `PfMcpOnLog`, to subscribe to server logs. Automatically unsubscribed on server shutdown. + * + * @alias ServerOnLogHandler + */ +type PfMcpOnLogHandler = ServerOnLogHandler; + +/** + * The log event passed to the `onLog` handler, `PfMcpOnLogHandler`. + * + * @alias ServerLogEvent + */ +type PfMcpLogEvent = ServerLogEvent; + +/** + * Main function - Programmatic and CLI entry point with optional overrides * * @param [pfMcpOptions] - User configurable options * @param [pfMcpSettings] - MCP server settings * - * @returns {Promise} Server-instance with shutdown capability + * @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. + * + * @example Programmatic: A MCP server with STDIO (Standard Input Output) transport. + * import { start } from '@patternfly/patternfly-mcp'; + * const { stop, isRunning } = await start(); + * + * if (isRunning()) { + * stop(); + * } + * + * @example Programmatic: A MCP server with HTTP transport. + * import { start } from '@patternfly/patternfly-mcp'; + * const { stop, isRunning } = await start({ http: { port: 8000 } }); + * + * if (isRunning()) { + * stop(); + * } + * + * @example Programmatic: A MCP server with inline tool configuration. + * import { start, createMcpTool } from '@patternfly/patternfly-mcp'; + * + * const myToolModule = createMcpTool({ + * name: 'my-tool', + * description: 'My tool description', + * inputSchema: {}, + * handler: async (args) => args + * }); + * + * const { stop, isRunning } = await start({ toolModules: [myToolModule] }); + * + * if (isRunning()) { + * stop(); + * } */ const main = async ( pfMcpOptions: PfMcpOptions = {}, pfMcpSettings: PfMcpSettings = {} -): Promise => { +): Promise => { const { mode, ...options } = pfMcpOptions; const { allowProcessExit } = pfMcpSettings; @@ -65,8 +132,8 @@ const main = async ( // 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 })); + // `runServer` doesn't require options in the memo key, but we pass fully merged options for stable hashing + await runServer.memo(mergedOptions, { allowProcessExit: updatedAllowProcessExit })); } catch (error) { console.error('Failed to start server:', error); @@ -79,13 +146,20 @@ const main = async ( }; export { + createMcpTool, main, main as start, type CliOptions, type PfMcpOptions, type PfMcpSettings, - type ServerInstance, - type ServerLogEvent, - type ServerOnLog, - type ServerOnLogHandler + type PfMcpInstance, + type PfMcpLogEvent, + type PfMcpOnLog, + type PfMcpOnLogHandler, + type ToolCreator, + type ToolModule, + type ToolConfig, + type ToolMultiConfig, + type ToolExternalOptions, + type ToolInternalOptions }; diff --git a/src/options.context.ts b/src/options.context.ts index 6739f0e..1a64cf9 100644 --- a/src/options.context.ts +++ b/src/options.context.ts @@ -61,26 +61,36 @@ const optionsContext = new AsyncLocalStorage(); /** * Set and freeze cloned options in the current async context. * + * @note Look at adding a re-validation helper here, and potentially in `runWithOptions`, that aligns with + * CLI options parsing. We need to account for both CLI and programmatic use. + * * @param {DefaultOptionsOverrides} [options] - Optional overrides merged with DEFAULT_OPTIONS. * @returns {GlobalOptions} Cloned frozen default options object with session. */ const setOptions = (options?: DefaultOptionsOverrides): GlobalOptions => { const base = mergeObjects(DEFAULT_OPTIONS, options, { allowNullValues: false, allowUndefinedValues: false }); const baseLogging = isPlainObject(base.logging) ? base.logging : DEFAULT_OPTIONS.logging; + const basePluginIsolation = ['strict', 'none'].includes(base.pluginIsolation) ? base.pluginIsolation : DEFAULT_OPTIONS.pluginIsolation; + const merged: GlobalOptions = { ...base, logging: { - level: baseLogging.level, + level: ['debug', 'info', 'warn', 'error'].includes(baseLogging.level) ? baseLogging.level : DEFAULT_OPTIONS.logging.level, logger: baseLogging.logger, stderr: baseLogging.stderr, protocol: baseLogging.protocol, - transport: baseLogging.transport + transport: ['stdio', 'mcp'].includes(baseLogging.transport) ? baseLogging.transport : DEFAULT_OPTIONS.logging.transport }, + pluginIsolation: basePluginIsolation, resourceMemoOptions: DEFAULT_OPTIONS.resourceMemoOptions, toolMemoOptions: DEFAULT_OPTIONS.toolMemoOptions }; - const frozen = freezeObject(structuredClone(merged)); + // Avoid cloning toolModules + const originalToolModules = Array.isArray(merged.toolModules) ? merged.toolModules : []; + const cloned = structuredClone({ ...merged, toolModules: [] as unknown[] }); + const restoreOriginalToolModules: GlobalOptions = { ...cloned, toolModules: originalToolModules } as GlobalOptions; + const frozen = freezeObject(restoreOriginalToolModules); optionsContext.enterWith(frozen); @@ -123,7 +133,11 @@ const runWithOptions = async ( options: GlobalOptions, callback: () => TReturn | Promise ) => { - const frozen = freezeObject(structuredClone(options)); + // Avoid cloning toolModules + const originalToolModules = Array.isArray((options as any).toolModules) ? (options as any).toolModules : []; + const cloned = structuredClone({ ...(options as any), toolModules: [] as unknown[] }); + const restoreOriginalToolModules = { ...cloned, toolModules: originalToolModules } as GlobalOptions; + const frozen = freezeObject(restoreOriginalToolModules); return optionsContext.run(frozen, callback); }; diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 5aca992..43d0c5f 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -137,19 +137,6 @@ interface PluginHostOptions { gracePeriodMs: number; } -/** - * Tools Host options (pure data). Centralized defaults live here. - * - * @property loadTimeoutMs Timeout for child spawn + hello/load/manifest (ms). - * @property invokeTimeoutMs Timeout per external tool invocation (ms). - * @property gracePeriodMs Grace period for external tool invocations (ms). - */ -interface PluginHostOptions { - loadTimeoutMs: number; - invokeTimeoutMs: number; - gracePeriodMs: number; -} - /** * Logging session options, non-configurable by the user. * @@ -336,7 +323,7 @@ const DEFAULT_OPTIONS: DefaultOptions = { logging: LOGGING_OPTIONS, name: packageJson.name, nodeVersion: (process.env.NODE_ENV === 'local' && 22) || getNodeMajorVersion(), - pluginIsolation: 'none', + pluginIsolation: 'strict', pluginHost: PLUGIN_HOST_OPTIONS, pfExternal: PF_EXTERNAL, pfExternalDesignComponents: PF_EXTERNAL_DESIGN_COMPONENTS, diff --git a/src/options.tools.ts b/src/options.tools.ts index b0716a0..f6d6ac1 100644 --- a/src/options.tools.ts +++ b/src/options.tools.ts @@ -1,7 +1,7 @@ import { type GlobalOptions } from './options'; /** - * Options for tools. + * Options for tools. A limited subset of options. * * @property serverName - Name of the server instance. * @property serverVersion - Version of the server instance. diff --git a/src/options.ts b/src/options.ts index ebced89..eb5b4e6 100644 --- a/src/options.ts +++ b/src/options.ts @@ -22,6 +22,13 @@ type CliOptions = { http?: Partial; isHttp: boolean; logging: Partial; + toolModules: string[]; + + /** + * Isolation preset for external plugins (CLI-provided). If omitted, defaults + * to 'strict' when external tools are requested, otherwise 'none'. + */ + pluginIsolation: 'none' | 'strict' | undefined; }; /** @@ -72,6 +79,9 @@ const getArgValue = (flag: string, { defaultValue, argv = process.argv }: { defa * - `--host`: The host name specified via `--host` * - `--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. + * - `--plugin-isolation `: Isolation preset for external tools-as-plugins. + * - `--tool `: Either a repeatable single tool-as-plugin specification or a comma-separated list of tool-as-plugin specifications. Each tool-as-plugin + * specification is a local module name or path. * * @param [argv] - Command-line arguments to parse. Defaults to `process.argv`. * @returns Parsed command-line options. @@ -133,7 +143,61 @@ const parseCliOptions = (argv: string[] = process.argv): CliOptions => { } } - return { docsHost, logging, isHttp, http }; + // Parse external tool modules: single canonical flag `--tool` + // Supported forms: + // --tool a --tool b (repeatable) + // --tool a,b (comma-separated) + const toolModules: string[] = []; + const seenSpecs = new Set(); + + const addSpec = (spec?: string) => { + const trimmed = String(spec || '').trim(); + + if (!trimmed || seenSpecs.has(trimmed)) { + return; + } + + seenSpecs.add(trimmed); + toolModules.push(trimmed); + }; + + for (let argIndex = 0; argIndex < argv.length; argIndex += 1) { + const token = argv[argIndex]; + const next = argv[argIndex + 1]; + + if (token === '--tool' && typeof next === 'string' && !next.startsWith('-')) { + next + .split(',') + .map(value => value.trim()) + .filter(Boolean) + .forEach(addSpec); + + argIndex += 1; + } + } + + // Parse isolation preset: --plugin-isolation + let pluginIsolation: CliOptions['pluginIsolation'];// = DEFAULT_OPTIONS.pluginIsolation; + const isolationIndex = argv.indexOf('--plugin-isolation'); + + if (isolationIndex >= 0) { + const val = String(argv[isolationIndex + 1] || '').toLowerCase(); + + switch (val) { + case 'none': + case 'strict': + pluginIsolation = val; + } + } + + return { + docsHost, + logging, + isHttp, + http, + toolModules, + pluginIsolation + }; }; export { diff --git a/src/server.tools.ts b/src/server.tools.ts index ad5c4cc..1640c47 100644 --- a/src/server.tools.ts +++ b/src/server.tools.ts @@ -227,6 +227,9 @@ const debugChild = (child: ChildProcess, { sessionId } = getSessionOptions()) => * * @param {GlobalOptions} options - Global options. * @returns Host handle used by `makeProxyCreators` and shutdown. + * + * @throws {Error} If the Tools Host entry `#toolsHost` cannot be resolved, or if the child process fails to + * spawn or respond during the handshake within the configured timeout. */ const spawnToolsHost = async ( options: GlobalOptions = getOptions() @@ -287,7 +290,7 @@ const spawnToolsHost = async ( } // Pre-compute file and package tool modules before spawning to reduce latency - const filePackageToolModules = getFilePackageToolModules(); + const filePackageToolModules = getFilePackageToolModules() || []; const child: ChildProcess = spawn(process.execPath, [...nodeArgs, updatedEntry], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] @@ -528,9 +531,6 @@ const sendToolsHostShutdown = async ( * - Node < 22, externals are skipped with a warning and only built-ins are returned. * - Registry is self-correcting for preload or midrun crashes without changing normal shutdown * - * @note Review adding a defensive check for existing hosts before creating a new one. This is - * to prevent an orphaned child process if `composeTools` is called multiple times. - * * @param builtinCreators - Built-in tool creators * @param {GlobalOptions} options - Global options. * @param {AppSession} sessionOptions - Session options. @@ -541,6 +541,13 @@ const composeTools = async ( { toolModules, nodeVersion, contextUrl, contextPath }: GlobalOptions = getOptions(), { sessionId }: AppSession = getSessionOptions() ): Promise => { + const existingSession = activeHostsBySession.get(sessionId); + + if (existingSession) { + log.warn(`Existing Tools Host session detected ${sessionId}. Shutting down the existing host before creating a new one.`); + await sendToolsHostShutdown(); + } + const toolCreators: McpToolCreator[] = [...builtinCreators]; const usedNames = getBuiltInToolNames(builtinCreators); diff --git a/src/server.toolsUser.ts b/src/server.toolsUser.ts index 57a8d1a..6eeb123 100644 --- a/src/server.toolsUser.ts +++ b/src/server.toolsUser.ts @@ -9,6 +9,22 @@ import { type ToolOptions } from './options.tools'; import { formatUnknownError } from './logger'; import { normalizeInputSchema } from './server.schema'; +/** + * Inline tool options. + * + * @alias GlobalOptions + * @note Author-facing configuration. + */ +type ToolInternalOptions = GlobalOptions; + +/** + * External tool options. + * + * @alias ToolOptions + * @note Author-facing configuration. + */ +type ToolExternalOptions = ToolOptions; + /** * A normalized tool entry for normalizing values for strings and tool creators. * @@ -49,14 +65,21 @@ type FileEntry = Pick; /** - * An MCP tool. A tool config tuple. + * An MCP tool. A tool config tuple. The handler may be async or sync. * * @alias McpTool + * @note Author-facing configuration. + * @example A tool config tuple. The handler may be async or sync. + * [ + * 'tupleTool', + * { description: 'Tool description', inputSchema: {} }, + * async (args) => { ... } + * ] */ type Tool = McpTool; /** - * Author-facing tool config. A plain object config. The handler may be async or sync. + * A plain object config. * * @template TArgs The type of arguments expected by the tool (optional). * @template TResult The type of result returned by the tool (optional). @@ -64,8 +87,17 @@ type Tool = McpTool; * @property name - Name of the tool * @property description - Description of the tool * @property inputSchema - JSON Schema or Zod schema describing the arguments expected by the tool - * @property {(args: TArgs, options?: GlobalOptions) => Promise | TResult} handler - Tool handler + * @property {(args: TArgs) => Promise | TResult} handler - Tool handler * - `args` are returned by the tool's `inputSchema`' + * + * @note Author-facing configuration. + * @example A plain object config. The handler may be async or sync. + * { + * name: 'objTool', + * description: 'Tool description', + * inputSchema: {}, + * handler: async (args) => { ... } + * } */ type ToolConfig = { name: string; @@ -75,25 +107,76 @@ type ToolConfig = { }; /** - * An MCP tool "wrapper", or "creator". A function that returns a `Tool` or `McpTool`. + * A function that returns a tuple `Tool` or `McpTool`. An MCP tool "wrapper", or "creator". + * + * - `ToolExternalOptions` is a limited subset of `ToolInternalOptions` for external filePackage creators. + * - `ToolInternalOptions` is available for inline and built-in tool creators. * - * - `ToolOptions` is a limited subset of `GlobalOptions` for external filePackage creators. - * - `GlobalOptions` is available for inline and built-in tool creators. + * @note Author-facing configuration. + * @example A creator function. The handler may be async or sync. + * () => [ + * 'creatorTool', + * { description: 'Tool description', inputSchema: {} }, + * async (args) => { ... } + * ] */ -type ToolCreator = ((options?: ToolOptions | GlobalOptions) => McpTool) & { toolName?: string }; +type ToolCreator = ((options?: ToolExternalOptions | ToolInternalOptions) => McpTool) & { toolName?: string }; /** - * Author-facing multi-tool config. An array of tool configs. + * An array of tool configs. * - * - `string` - file path or package id (Node ≥ 22 path) + * - `string` - file path or package id (Node >= 22 path) * - `Tool` - tuple form (has a name) * - `ToolConfig` - object form (has a name) * - `ToolCreator` - function creator with static toolName - */ -type MultiToolConfig = ReadonlyArray; - -/** - * Author-facing "tools as plugins" surface. An array of normalized tool config values. + * - `ToolModule` - normalized tool config values returned from `createMcpTool` + * + * @note Author-facing multi-tool configuration. + * @example A multi-tool configuration array/list + * [ + * './a/file/path/tool.mjs', + * { + * name: 'objTool', + * description: 'Tool description', + * inputSchema: {}, + * handler: (args) => { ... } + * }, + * [ + * 'tupleTool', + * { description: 'Tool description', inputSchema: {} }, + * (args) => { ... } + * ] + * () => [ + * 'creatorTool', + * { description: 'Tool description', inputSchema: {} }, + * (args) => { ... } + * ], + * createMcpTool({ + * name: 'aCreateMcpToolWrappedTool', + * description: 'Tool description', + * inputSchema: {}, + * handler: (args) => { ... } + * }); + * ]; + */ +type ToolMultiConfig = ReadonlyArray; + +/** + * An array of normalized tool config values returned from `createMcpTool`. + * + * - `string` - file path or package id (Node >= 22 path) + * - `ToolCreator` - function creator with static toolName + * + * @note Author-facing multi-tool configuration. + * @example An array/list of normalized tool config values + * [ + * './a/file/path/tool.mjs', + * () => [ + * 'creatorTool', + * { description: 'Tool description', inputSchema: {} }, + * async (args) => { ... } + * ] + * ]; */ type ToolModule = ReadonlyArray; @@ -428,9 +511,12 @@ normalizeObject.memo = memo(normalizeObject, { cacheErrors: false, keyHash: args /** * Normalize a creator function into a tool creator function. * - * @note We intentionally do not execute creator functions during normalization. This means - * deduplication will only happen if the `toolName` property is set manually or another - * configuration option is used. + * @note + * - We intentionally do not execute creator functions during normalization. This means + * deduplication will only happen if the `toolName` property is set manually or another + * configuration option is used. + * - Only minimal error handling is done here, errors for malformed tuples are surfaced + * in logs by design. * * @param config * @returns A tool creator function, or undefined if the config is invalid. @@ -454,8 +540,10 @@ const normalizeFunction = (config: unknown): CreatorEntry | undefined => { } // Currently, we only support tuples in creator functions. - if (normalizeTuple.memo(response)) { - const { value } = normalizeTuple.memo(response) || {}; + const tupleResult = normalizeTuple.memo(response); + + if (tupleResult) { + const { value } = tupleResult; return (value as ToolCreator)?.(); } @@ -762,7 +850,7 @@ normalizeTools.memo = memo(normalizeTools, { }); /** - * Author-facing helper for creating an MCP tool configuration list for PatternFly MCP server. + * Author-facing config helper for creating an MCP tool configuration list for PatternFly MCP server. * * @example A single file path string * export default createMcpTool('./a/file/path.mjs'); @@ -771,20 +859,42 @@ normalizeTools.memo = memo(normalizeTools, { * export default createMcpTool('@my-org/my-tool'); * * @example A single tool configuration tuple - * export default createMcpTool(['myTool', { description: 'My tool description' }, (args) => { ... }]); + * export default createMcpTool([ + * 'myTool', + * { description: 'My tool description' }, + * (args) => { ... } + * ]); * * @example A single tool creator function - * const myToolCreator = () => ['myTool', { description: 'My tool description' }, (args) => { ... }]; + * const myToolCreator = () => [ + * 'myTool', + * { description: 'My tool description' }, + * (args) => { ... } + * ]; + * * myToolCreator.toolName = 'myTool'; * export default createMcpTool(myToolCreator); * * @example A single tool configuration object - * export default createMcpTool({ name: 'myTool', description: 'My tool description', inputSchema: {}, handler: (args) => { ... } }); + * export default createMcpTool({ + * name: 'myTool', + * description: 'My tool description', + * inputSchema: {}, + * handler: (args) => { ... } + * }); * * @example A multi-tool configuration array/list - * export default createMcpTool(['./a/file/path.mjs', { name: 'myTool', description: 'My tool description', inputSchema: {}, handler: (args) => { ... } }]); - * - * @param config - The configuration for creating the tool(s). It can be: + * export default createMcpTool([ + * './a/file/path.mjs', + * { + * name: 'myTool', + * description: 'My tool description', + * inputSchema: {}, + * handler: async (args) => { ... } + * } + * ]); + * + * @param config - The configuration for creating the tool(s). Configuration can be any of the following: * - A single string representing the name of a local ESM module file (`file path string` or `file URL string`). Limited to Node.js 22+ * - A single string representing the name of a local ESM tool package (`package string`). Limited to Node.js 22+ * - A single inline tool configuration tuple (`Tool`). @@ -793,9 +903,10 @@ normalizeTools.memo = memo(normalizeTools, { * - An array of the aforementioned configuration types in any combination. * @returns An array of strings and/or tool creators that can be applied to the MCP server `toolModules` option. * - * @throws {Error} If a configuration is invalid, an error is thrown on the first invalid entry. + * @throws {Error} If a configuration is invalid, an error is thrown on the first invalid entry. The error message + * includes the index and a brief description of the invalid entry. */ -const createMcpTool = (config: string | Tool | ToolConfig | ToolCreator | MultiToolConfig | ToolModule): ToolModule => { +const createMcpTool = (config: string | Tool | ToolConfig | ToolCreator | ToolMultiConfig | ToolModule): ToolModule => { const entries = normalizeTools.memo(config); const err = entries.find(entry => entry.type === 'invalid'); @@ -821,10 +932,12 @@ export { sanitizeDataProp, sanitizePlainObject, sanitizeStaticToolName, - type MultiToolConfig, type NormalizedToolEntry, type ToolCreator, type Tool, type ToolConfig, - type ToolModule + type ToolModule, + type ToolMultiConfig, + type ToolInternalOptions, + type ToolExternalOptions }; diff --git a/src/server.ts b/src/server.ts index 825eef3..704a80e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { startHttpTransport, type HttpServerHandle } from './server.http'; import { memo } from './server.caching'; import { log, type LogEvent } from './logger'; import { createServerLogger } from './server.logger'; +import { composeTools, sendToolsHostShutdown } from './server.tools'; import { type GlobalOptions } from './options'; import { getOptions, @@ -147,6 +148,8 @@ const runServer = async (options: ServerOptions = getOptions(), { await server?.close(); running = false; + await sendToolsHostShutdown(); + log.info(`${options.name} closed!\n`); unsubscribeServerLogger?.(); @@ -185,6 +188,9 @@ const runServer = async (options: ServerOptions = getOptions(), { ); } + // Combine built-in tools with custom ones after logging is set up. + const updatedTools = await composeTools(tools); + if (subUnsub) { const { subscribe, unsubscribe } = subUnsub; @@ -195,7 +201,7 @@ const runServer = async (options: ServerOptions = getOptions(), { onLogSetup = (handler: ServerOnLogHandler) => subscribe(handler); } - tools.forEach(toolCreator => { + updatedTools.forEach(toolCreator => { const [name, schema, callback] = toolCreator(options); // Do NOT normalize schemas here. This is by design and is a fallback check for malformed schemas. const isZod = isZodSchema(schema?.inputSchema) || isZodRawShape(schema?.inputSchema); diff --git a/tests/__fixtures__/tool.echoBasic.js b/tests/__fixtures__/tool.echoBasic.js new file mode 100644 index 0000000..bc1f53e --- /dev/null +++ b/tests/__fixtures__/tool.echoBasic.js @@ -0,0 +1,23 @@ +// Fixture exports a creator function directly; + +const echo_plugin_tool = options => [ + 'echo_basic_tool', + { + description: 'Echo basic tool. Echos back the provided args.', + inputSchema: { additionalProperties: true } + }, + args => ({ + args, + options: options ? Object.keys(options) : undefined, + content: [ + { + type: 'text', + text: JSON.stringify(args) + } + ] + }) +]; + +echo_plugin_tool.toolName = 'echo_basic_tool'; + +export default echo_plugin_tool; diff --git a/tests/__fixtures__/tool.echoBasicError.js b/tests/__fixtures__/tool.echoBasicError.js new file mode 100644 index 0000000..fa86126 --- /dev/null +++ b/tests/__fixtures__/tool.echoBasicError.js @@ -0,0 +1,13 @@ +// Fixture exports a creator function directly; + +const echo_plugin_tool = () => [ + 'echo_basicError_tool', + { + description: 'Echo basic tool that errors. Echos back the provided args.', + inputSchema: { additionalProperties: true } + } +]; + +echo_plugin_tool.toolName = 'echo_basicError_tool'; + +export default echo_plugin_tool; diff --git a/tests/__fixtures__/tool.echoToolHelper.js b/tests/__fixtures__/tool.echoToolHelper.js new file mode 100644 index 0000000..e06d7b8 --- /dev/null +++ b/tests/__fixtures__/tool.echoToolHelper.js @@ -0,0 +1,18 @@ +// Fixture exports a createMcpTool module directly; +// eslint-disable-next-line import/no-unresolved +import { createMcpTool } from '@patternfly/patternfly-mcp'; + +export default createMcpTool({ + name: 'echo_createMcp_tool', + description: 'Echo create MCP tool. Echos back the provided args.', + inputSchema: { additionalProperties: true }, + handler: async args => ({ + args, + content: [ + { + type: 'text', + text: JSON.stringify(args) + } + ] + }) +}); diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index 0f41955..a79ce01 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -271,6 +271,8 @@ exports[`Logging should allow setting logging options, default 1`] = `[]`; exports[`Logging should allow setting logging options, stderr 1`] = ` [ "[INFO]: Server logging enabled. +", + "[INFO]: No external tools loaded. ", "[INFO]: Registered tool: usePatternFlyDocs ", @@ -439,3 +441,41 @@ exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] ], } `; + +exports[`Tools should interact with a tool, echo basic tool 1`] = ` +{ + "args": { + "dolor": "sit amet", + "lorem": "ipsum", + "type": "echo", + }, + "content": [ + { + "text": "{"type":"echo","lorem":"ipsum","dolor":"sit amet"}", + "type": "text", + }, + ], + "options": [ + "serverName", + "serverVersion", + "nodeMajor", + "repoName", + ], +} +`; + +exports[`Tools should interact with a tool, echo create MCP tool 1`] = ` +{ + "args": { + "dolor": "sit amet", + "lorem": "ipsum", + "type": "echo", + }, + "content": [ + { + "text": "{"type":"echo","lorem":"ipsum","dolor":"sit amet"}", + "type": "text", + }, + ], +} +`; diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts index 38fbd29..2c58f19 100644 --- a/tests/httpTransport.test.ts +++ b/tests/httpTransport.test.ts @@ -1,11 +1,10 @@ /** * Requires: npm run build prior to running Jest. + * - If typings are needed, use public types from dist to avoid type identity mismatches between src and dist */ -import { - startServer, - type HttpTransportClient, - type RpcRequest -} from './utils/httpTransportClient'; +// @ts-ignore - dist/index.js isn't necessarily built yet, remember to build before running tests +import { createMcpTool } from '../dist/index.js'; +import { startServer, type HttpTransportClient, type RpcRequest } from './utils/httpTransportClient'; import { setupFetchMock } from './utils/fetchMock'; describe('PatternFly MCP, HTTP Transport', () => { @@ -41,12 +40,11 @@ describe('PatternFly MCP, HTTP Transport', () => { excludePorts: [5001] }); - CLIENT = await startServer({ http: { port: 5001 } }); + CLIENT = await startServer({ http: { port: 5001 }, logging: { level: 'debug', protocol: true } }); }); 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; } @@ -122,6 +120,104 @@ describe('PatternFly MCP, HTTP Transport', () => { expect(text.startsWith('# Documentation from')).toBe(true); expect(text).toMatchSnapshot(); - CLIENT.close(); + await CLIENT.close(); + }); +}); + +describe('Inline tools over HTTP', () => { + let CLIENT: HttpTransportClient | undefined; + + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + } + }); + + it.each([ + { + description: 'inline tool module', + port: 5011, + toolName: 'inline_module', + tool: createMcpTool({ + name: 'inline_module', + description: 'Create inline', + inputSchema: { additionalProperties: true }, + handler: (args: any) => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }) + }) + }, + { + description: 'inline tool creator', + port: 5012, + toolName: 'inline_creator', + tool: (() => { + const inlineCreator = (_options: any) => [ + 'inline_creator', + { + description: 'Func inline', + inputSchema: { additionalProperties: true } + }, + (args: any) => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }) + ]; + + inlineCreator.toolName = 'inline_creator'; + + return inlineCreator; + })() + }, + { + description: 'inline object', + port: 5013, + toolName: 'inline_obj', + tool: { + name: 'inline_obj', + description: 'Obj inline', + inputSchema: { additionalProperties: true }, + handler: (args: any) => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }) + } + }, + { + description: 'inline tuple', + port: 5014, + toolName: 'inline_tuple', + tool: [ + 'inline_tuple', + { + description: 'Tuple inline', + inputSchema: { additionalProperties: true } + }, + (args: any) => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }) + ] + } + ])('should register and invoke an inline tool module, $description', async ({ port, tool, toolName }) => { + CLIENT = await startServer( + { + http: { port }, + isHttp: true, + logging: { level: 'info', protocol: true }, + toolModules: [tool as any] + }, + { allowProcessExit: false } + ); + + const list = await CLIENT.send({ method: 'tools/list', params: {} }); + const names = (list?.result?.tools || []).map((tool: any) => tool.name); + + expect(names).toEqual(expect.arrayContaining([toolName])); + + const req = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: toolName, + arguments: { x: 1, y: 'z' } + } + } as RpcRequest; + + const res = await CLIENT.send(req); + + expect(res?.result?.content?.[0]?.text).toContain('"x":1'); + + await CLIENT.close(); }); }); diff --git a/tests/jest.setupTests.ts b/tests/jest.setupTests.ts index 629bfbc..6a3f01d 100644 --- a/tests/jest.setupTests.ts +++ b/tests/jest.setupTests.ts @@ -1,6 +1,46 @@ // Shared helpers for e2e Jest tests import { jest } from '@jest/globals'; +declare global { + var envNodeVersion: number; + var itSkip: (check: unknown) => typeof it | typeof it.skip; +} + +/** + * Get the Node.js major version of the current process. + * + * @param fallback - Fallback value if the major version cannot be determined. Defaults to `0`. + */ +export const getNodeVersion = (fallback: number = 0) => { + const major = Number.parseInt(process?.versions?.node?.split?.('.')?.[0] || String(fallback), 10); + + if (Number.isFinite(major)) { + return major; + } + + return fallback; +}; + +/** + * The Node.js major version of the current process. + */ +export const envNodeVersion = getNodeVersion(22); + +global.envNodeVersion = envNodeVersion; + +/** + * Conditionally skip "it" test statements. + * + * @example + * itSkip(true)('should do a thing...', () => { ... }); + * + * @param {*|boolean} check - Any `truthy`/`falsy` value + * @returns On `truthy` returns `it`, on `falsy` returns `it.skip`. + */ +export const itSkip = (check: unknown): typeof it | typeof it.skip => (check ? it : it.skip); + +global.itSkip = itSkip; + /** * Store the original fetch implementation * Tests can access this to get the real fetch when needed diff --git a/tests/stdioTransport.test.ts b/tests/stdioTransport.test.ts index e06c977..88c5505 100644 --- a/tests/stdioTransport.test.ts +++ b/tests/stdioTransport.test.ts @@ -1,6 +1,10 @@ /** * Requires: npm run build prior to running Jest. + * - If typings are needed, use public types from dist to avoid type identity mismatches between src and dist + * - We're unable to mock fetch for stdio since it runs in a separate process, so we run a server and use that path for mocking external URLs. */ +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { startServer, type StdioTransportClient, @@ -11,7 +15,6 @@ import { setupFetchMock } from './utils/fetchMock'; describe('PatternFly MCP, STDIO', () => { let FETCH_MOCK: Awaited> | undefined; let CLIENT: StdioTransportClient; - // We're unable to mock fetch for stdio since it runs in a separate process, so we run a server and use that path for mocking external URLs. let URL_MOCK: string; beforeAll(async () => { @@ -20,7 +23,6 @@ describe('PatternFly MCP, STDIO', () => { routes: [ { url: /\/README\.md$/, - // url: '/notARealPath/README.md', status: 200, headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, body: `# PatternFly Development Rules @@ -37,7 +39,6 @@ describe('PatternFly MCP, STDIO', () => { }, { url: /.*\.md$/, - // url: '/notARealPath/AboutModal.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.' @@ -51,7 +52,6 @@ describe('PatternFly MCP, STDIO', () => { 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(); } @@ -87,7 +87,7 @@ describe('PatternFly MCP, STDIO', () => { } } as RpcRequest; - const response = await CLIENT?.send(req); + const response = await CLIENT.send(req); const text = response?.result?.content?.[0]?.text || ''; expect(text.startsWith('# Documentation from')).toBe(true); @@ -114,7 +114,7 @@ describe('PatternFly MCP, STDIO', () => { const response = await CLIENT.send(req, { timeoutMs: 10000 }); const text = response?.result?.content?.[0]?.text || ''; - // expect(text.startsWith('# Documentation from')).toBe(true); + expect(text.startsWith('# Documentation from')).toBe(true); expect(text).toMatchSnapshot(); }); }); @@ -172,3 +172,109 @@ describe('Logging', () => { await CLIENT.stop(); }); }); + +describe('Tools', () => { + let CLIENT: StdioTransportClient; + + beforeEach(async () => { + const echoBasicFileUrl = pathToFileURL(resolve(process.cwd(), 'tests/__fixtures__/tool.echoBasic.js')).href; + const echoBasicErrorFileUrl = pathToFileURL(resolve(process.cwd(), 'tests/__fixtures__/tool.echoBasicError.js')).href; + const echoToolHelperFileUrl = pathToFileURL(resolve(process.cwd(), 'tests/__fixtures__/tool.echoToolHelper.js')).href; + + CLIENT = await startServer({ + args: [ + '--log-stderr', + '--plugin-isolation', + 'strict', + '--tool', + echoBasicFileUrl, + '--tool', + echoBasicErrorFileUrl, + '--tool', + echoToolHelperFileUrl + ] + }); + }); + + afterEach(async () => CLIENT.stop()); + + itSkip(envNodeVersion >= 22)('should access new tools', async () => { + const req = { + method: 'tools/list', + params: {} + }; + + const resp = await CLIENT.send(req); + const names = (resp?.result?.tools || []).map((tool: any) => tool.name); + + expect(CLIENT.logs().join(',')).toContain('Registered tool: echo_basic_tool'); + expect(names).toContain('echo_basic_tool'); + + expect(CLIENT.logs().join(',')).toContain('No usable tool creators found from module.'); + + expect(CLIENT.logs().join(',')).toContain('Registered tool: echo_createMcp_tool'); + expect(names).toContain('echo_createMcp_tool'); + }); + + itSkip(envNodeVersion <= 20)('should fail to access a new tool', async () => { + const req = { + method: 'tools/list', + params: {} + }; + + await CLIENT.send(req); + + expect(CLIENT.logs().join(',')).toContain('External tool plugins require Node >= 22; skipping file-based tools.'); + }); + + itSkip(envNodeVersion >= 22).each([ + { + description: 'echo basic tool', + name: 'echo_basic_tool', + args: { type: 'echo', lorem: 'ipsum', dolor: 'sit amet' } + }, + { + description: 'echo create MCP tool', + name: 'echo_createMcp_tool', + args: { type: 'echo', lorem: 'ipsum', dolor: 'sit amet' } + } + ])('should interact with a tool, $description', async ({ name, args }) => { + const req = { + method: 'tools/call', + params: { + name, + arguments: args + } + }; + + const resp: any = await CLIENT.send(req); + + expect(resp.result).toMatchSnapshot(); + expect(resp.result.isError).toBeUndefined(); + }); + + itSkip(envNodeVersion <= 20).each([ + { + description: 'echo basic tool', + name: 'echo_basic_tool', + args: { type: 'echo', lorem: 'ipsum', dolor: 'sit amet' } + }, + { + description: 'echo create MCP tool', + name: 'echo_createMcp_tool', + args: { type: 'echo', lorem: 'ipsum', dolor: 'sit amet' } + } + ])('should fail to interact with a tool, $description', async ({ name, args }) => { + const req = { + method: 'tools/call', + params: { + name, + arguments: args + } + }; + + const resp: any = await CLIENT.send(req); + + expect(resp.result.isError).toBe(true); + }); +}); diff --git a/tests/utils/httpTransportClient.ts b/tests/utils/httpTransportClient.ts index 54ae40a..ca688c7 100644 --- a/tests/utils/httpTransportClient.ts +++ b/tests/utils/httpTransportClient.ts @@ -16,6 +16,7 @@ export type StartHttpServerOptions = { http?: Partial; isHttp?: boolean; logging?: Partial & { level?: LoggingLevel }; + toolModules?: PfMcpOptions['toolModules']; }; export type StartHttpServerSettings = PfMcpSettings; diff --git a/tsconfig.json b/tsconfig.json index b44ca37..8c47af0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "exactOptionalPropertyTypes": true, "resolveJsonModule": true, "noEmit": true, + "stripInternal": true, "rootDirs": ["./src", "./tests"] }, "include": [