diff --git a/README.md b/README.md index 855de3a..1b0d872 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ The Model Context Protocol (MCP) is an open standard that enables AI assistants ## Prerequisites - Node.js 20.0.0 or higher + - Note: External tool plugins require 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 @@ -83,6 +84,258 @@ 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. +### External tools (Plugins) + +Add external tools at startup. External tools run out‑of‑process in a separate Tools Host (Node >= 22). Built‑in tools are always in‑process and register first. + +- Node version gate + - Node < 22 → external tools are skipped with a single startup warning; built‑ins still register. + - Node >= 22 → external tools run out‑of‑process via the Tools Host. + +- CLI + - `--tool ` Add one or more external tools. Repeat the flag or pass a comma‑separated list. + - Examples: `--tool @acme/my-plugin`, `--tool ./plugins/my-tools.js`, `--tool ./a.js,./b.js` + - `--plugin-isolation ` Tools Host permission preset. + - Defaults: `strict` when any `--tool` is provided; otherwise `none`. + +- Behavior + - External tools run in a single Tools Host child process. + - In `strict` isolation (default with externals): network and fs write are denied; fs reads are allow‑listed to your project and resolved plugin directories. + +- Supported `--tool` inputs + - ESM packages (installed in node_modules) + - Local ESM files (paths are normalized to `file://` URLs internally) + +- Not supported as `--tool` inputs + - Raw TypeScript sources (`.ts`) — the Tools Host does not install a TS loader + - Remote `http(s):` or `data:` URLs — these will fail to load and appear in startup warnings/errors + +- Troubleshooting + - If external tools don't appear, verify you're running on Node >= 22 (see Node version gate above) and check startup `load:ack` warnings/errors. + - Startup `load:ack` warnings/errors from plugins are logged when stderr/protocol logging is enabled. + - If `tools/list` fails or `tools/call` rejects due to argument validation (e.g., messages about `safeParseAsync is not a function`), ensure your `inputSchema` is either a valid JSON Schema object or a Zod schema. Plain JSON Schema objects are automatically converted, but malformed schemas may cause issues. See the [Input Schema Format](#input-schema-format) section for details. + +### Embedding the server (Programmatic API) + +You can embed the MCP server inside another Node/TypeScript application and register tools programmatically. + +Tools as plugins can be + - Inline creators, or an array/list of inline creators, provided through the convenience wrapper `createMcpTool`, i.e. `createMcpTool({ name: 'echoAMessage', ... })` or `createMcpTool([{ name: 'echoAMessage', ... }])`. + - Local file paths and local file URLs (Node >= 22 required), i.e. `a string representing a local file path or file URL starting with file://` + - Local NPM package names (Node >= 22 required), i.e. `a string representing a local NPM package name like @loremIpsum/my-plugin` + +> Note: Consuming remote/external files, such as YML, and NPM packages is targeted for the near future. + +Supported export shapes for external modules (Node >= 22 only): + +- Default export: function returning a realized tool tuple. It is called once with ToolOptions and cached. Example shape: `export default function (opts) { return ['name', { description, inputSchema }, handler] }` +- Default export: function returning an array of creator functions. Example shape: `export default function (opts) { return [() => [...], () => [...]] }` +- Default export: array of creator functions. Example shape: `export default [ () => ['name', {...}, handler] ]` +- Fallback: a named export that is an array of creator functions (only used if default export is not present). + +Not supported (Phase A+B): + +- Directly exporting a bare tuple as the module default (wrap it in a function instead) +- Plugin objects like `{ createCreators, createTools }` + +Performance and determinism note: + +- If your default export is a function that returns a tuple, we invoke it once during load with a minimal ToolOptions object and cache the result. Use a creators‑factory (a function returning an array of creators) if you need per‑realization variability by options. + +External module examples (Node >= 22): + +Function returning a tuple (called once with options): + +```js +// plugins/echo.js +export default function createEchoTool(opts) { + return [ + 'echo_plugin_tool', + { description: 'Echo', inputSchema: { additionalProperties: true } }, + async (args) => ({ content: [{ type: 'text', text: JSON.stringify({ args, opts }) }] }) + ]; +} +``` + +Function returning multiple creators: + +```js +// plugins/multi.js +const t1 = () => ['one', { description: 'One', inputSchema: {} }, async () => ({})]; +const t2 = () => ['two', { description: 'Two', inputSchema: {} }, async () => ({})]; + +export default function creators(opts) { + // You can use opts to conditionally include creators + return [t1, t2]; +} +``` + +Array of creators directly: + +```js +// plugins/direct-array.js +export default [ + () => ['hello', { description: 'Hello', inputSchema: {} }, async () => ({})] +]; +``` + +#### Example +```typescript +// app.ts +import { start, createMcpTool, type PfMcpInstance, type PfMcpLogEvent, type ToolCreator } from '@patternfly/patternfly-mcp'; + +// Define a simple inline MCP tool. `createMcpTool` is a convenience wrapper to help you start writing a MCP tool. +const echoTool: ToolCreator = createMcpTool({ + // The unique name of the tool, used in the `tools/list` response, related to the MCP client. + // A MCP client can help Models use this, so make it informative and clear. + name: 'echoAMessage', + + // A short description of the tool, used in the `tools/list` response, related to the MCP client. + // A MCP client can help Models can use this, so make it informative and clear. + description: 'Echo back the provided user message.', + + // The input schema defines the shape of interacting with your handler, related to the Model. + // In this scenario the `args` object has a `string` `message` property intended to be passed back + // towards the tool `handler` when the Model calls it. + inputSchema: { + type: 'object', // Type of the input schema, in this case the object + properties: { message: { type: 'string' } }, // The properties, with types, to pass back to the handler + required: ['message'] // Required properties, in this case `message` + }, + + // The handler, async or sync. The Model calls the handler per the client and inputSchema and inputs the + // `message`. The handler parses the `message` and returns it. The Model receives the parsed `message` + // and uses it. + handler: async (args: { message: string }) => ({ text: `You said: ${args.message}` }) +}); + +async function main() { + // Start the server. + const server: PfMcpInstance = await start({ + // Add one or more in‑process tools directly. Default tools will be registered first. + toolModules: [ + // You can pass: + // - a string module (package or file) for external plugins (Tools Host, Node ≥ 22), or + // - a creator function returned by createMcpTool(...) for in‑process tools. + echoTool + ] + // Optional: enable all logging through stderr and/or protocol. + // logging: { level: 'info', stderr: true }, + }); + + // Optional: observe refined server logs in‑process + server.onLog((event: PfMcpLogEvent) => { + // A good habit to get into is avoiding `console.log` and `console.info` in production paths, they pollute stdio + // communication and can create noise. Use `console.error`, `console.warn`, or `process.stderr.write` instead. + if (event.level !== 'debug') { + // process.stderr.write(`[${event.level}] ${event.msg || ''}\n`); + // console.error(`[${event.level}] ${event.msg || ''}`); + console.warn(`[${event.level}] ${event.msg || ''}`); + } + }); + + // Stop the server after 10 seconds. + setTimeout(async () => server.stop(), 10000); +} + +// Run the program. +main().catch((err) => { + // In programmatic mode, unhandled errors throw unless allowProcessExit=true + console.error(err); + process.exit(1); +}); +``` + +#### Development notes +- Built‑in tools are always registered first. +- Consuming the MCP server comes with a not-so-obvious limitation, avoiding `console.log` and `console.info`. + - In `stdio` server run mode `console.log` and `console.info` can create unnecessary noise between server and client, and potentially the Model. Instead, use `console.error`, `console.warn`, or `process.stderr.write`. + - In `http` server run mode `console.log` and `console.info` can be used, but it's still recommended you get in the habit of avoiding their use. + +### Authoring external tools with `createMcpTool` + +Export an ESM module using `createMcpTool`. The server adapts single or multiple tool definitions automatically. + +Single tool: + +```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}!`; + } +}); +``` + +Multiple tools: + +```ts +import { createMcpTool } from '@patternfly/patternfly-mcp'; + +export default createMcpTool([ + { name: 'hi', description: 'Greets', inputSchema: { type: 'object' }, handler: () => 'hi' }, + { name: 'bye', description: 'Farewell', inputSchema: { type: 'object' }, handler: () => 'bye' } +]); +``` + +Named group: + +```ts +import { createMcpTool } from '@patternfly/patternfly-mcp'; + +export default createMcpTool({ + name: 'my-plugin', + tools: [ + { name: 'alpha', description: 'A', inputSchema: { type: 'object' }, handler: () => 'A' }, + { name: 'beta', description: 'B', inputSchema: { type: 'object' }, handler: () => 'B' } + ] +}); +``` + +Notes +- External tools must be ESM modules (packages or ESM files). The Tools Host imports your module via `import()`. +- The `handler` receives `args` per your `inputSchema`. A reserved `options?` parameter may be added in a future release; it is not currently passed. + +### 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. + +**JSON Schema (recommended for simplicity):** +``` +inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name'] +} +``` + +**Zod Schema (for advanced validation):** +``` +import { z } from 'zod'; + +inputSchema: { + name: z.string(), + age: z.number().optional() +} +``` + +**Important:** The MCP SDK expects Zod-compatible schemas internally. Plain JSON Schema objects are automatically converted to equivalent Zod schemas when tools are registered. This conversion handles common cases like: +- `{ type: 'object', additionalProperties: true }` → `z.object({}).passthrough()` +- Simple object schemas → `z.object({...})` + +If you encounter validation errors, ensure your JSON Schema follows standard JSON Schema format, or use Zod schemas directly for more control. + ## 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. @@ -333,7 +586,68 @@ npx @modelcontextprotocol/inspector-cli \ ## 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. + +## External tools (plugins) + +You can load external MCP tool modules at runtime using a single CLI flag or via programmatic options. Modules must be ESM-importable (absolute/relative path or package). + +CLI examples (single `--tool` flag): + +```bash +# Single module +npm run start:dev -- --tool ./dist/my-tool.js + +# Multiple modules (repeatable) +npm run start:dev -- --tool ./dist/t1.js --tool ./dist/t2.js + +# Multiple modules (comma-separated) +npm run start:dev -- --tool ./dist/t1.js,./dist/t2.js +``` + +Programmatic usage: + +```ts +import { main } from '@patternfly/patternfly-mcp'; + +await main({ + toolModules: [ + new URL('./dist/t1.js', import.meta.url).toString(), + './dist/t2.js' + ] +}); +``` + +Tools provided via `--tool`/`toolModules` are appended after the built-in tools. + +### Authoring MCP external tools +> Note: External MCP tools require using `Node >= 22` to run the server and ESM modules. TypeScript formatted tools are not directly supported. +> If you do use TypeScript, you can use the `createMcpTool` helper to define your tools as pure ESM modules. + +For `tools-as-plugin` authors, we recommend using the unified helper to define your tools as pure ESM modules: + +```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 { content: `Hello, ${name}!` }; + } +}); +``` + +Multiple tools in one module: + +```ts +import { createMcpTool } from '@patternfly/patternfly-mcp'; + +export default createMcpTool([ + { name: 'hello', description: 'Hi', inputSchema: {}, handler: () => 'hi' }, + { name: 'bye', description: 'Bye', inputSchema: {}, handler: () => 'bye' } +]); +``` ## Programmatic usage (advanced) 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..93489a2 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -336,7 +336,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..a10457d 100644 --- a/src/server.tools.ts +++ b/src/server.tools.ts @@ -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..92b905e 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; @@ -762,7 +845,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 +854,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`). @@ -795,7 +900,7 @@ normalizeTools.memo = memo(normalizeTools, { * * @throws {Error} If a configuration is invalid, an error is thrown on the first 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 +926,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.echo.js b/tests/__fixtures__/tool.echo.js new file mode 100644 index 0000000..ff89056 --- /dev/null +++ b/tests/__fixtures__/tool.echo.js @@ -0,0 +1,23 @@ +// Fixture exports a creator function directly; + +const echo_plugin_tool = options => [ + 'echo_plugin_tool', + { + description: 'Echo back the provided args, but with a different description', + inputSchema: { additionalProperties: true } + }, + args => ({ + args, + options: options ? Object.keys(options) : undefined, + content: [ + { + type: 'text', + text: JSON.stringify(args) + } + ] + }) +]; + +echo_plugin_tool.toolName = 'echo_plugin_tool'; + +export default echo_plugin_tool; diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index 0f41955..d438163 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,25 @@ exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] ], } `; + +exports[`Tools should interact with the new 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", + ], +} +`; diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts index 38fbd29..8adbda8 100644 --- a/tests/httpTransport.test.ts +++ b/tests/httpTransport.test.ts @@ -1,12 +1,13 @@ /** * Requires: npm run build prior to running Jest. */ -import { - startServer, - type HttpTransportClient, - type RpcRequest -} from './utils/httpTransportClient'; +// import { resolve } from 'node:path'; +// import { pathToFileURL } from 'node:url'; +// @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'; +// Use public types from dist to avoid type identity mismatches between src and dist describe('PatternFly MCP, HTTP Transport', () => { let FETCH_MOCK: Awaited> | undefined; @@ -41,7 +42,7 @@ 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 () => { @@ -96,6 +97,9 @@ describe('PatternFly MCP, HTTP Transport', () => { const response = await CLIENT?.send(req); const text = response?.result?.content?.[0]?.text || ''; + // expect(CLIENT?.logs()).toMatchSnapshot(); + // expect(CLIENT?.protocolLogs()).toMatchSnapshot(); + expect(text.startsWith('# Documentation from')).toBe(true); expect(text).toMatchSnapshot(); }); @@ -125,3 +129,101 @@ describe('PatternFly MCP, HTTP Transport', () => { 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..d9e9d91 100644 --- a/tests/stdioTransport.test.ts +++ b/tests/stdioTransport.test.ts @@ -1,6 +1,8 @@ /** * Requires: npm run build prior to running Jest. */ +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { startServer, type StdioTransportClient, @@ -87,7 +89,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); @@ -172,3 +174,77 @@ describe('Logging', () => { await CLIENT.stop(); }); }); + +describe('Tools', () => { + let CLIENT: StdioTransportClient; + + beforeEach(async () => { + const abs = resolve(process.cwd(), 'tests/__fixtures__/tool.echo.js'); + const url = pathToFileURL(abs).href; + + CLIENT = await startServer({ args: ['--log-stderr', '--plugin-isolation', 'strict', '--tool', url] }); + }); + + afterEach(async () => CLIENT.stop()); + + itSkip(envNodeVersion >= 22)('should access a new tool', 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_plugin_tool'); + expect(names).toContain('echo_plugin_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)('should interact with the new tool', async () => { + const req = { + method: 'tools/call', + params: { + name: 'echo_plugin_tool', + arguments: { + type: 'echo', + lorem: 'ipsum', + dolor: 'sit amet' + } + } + }; + + const resp: any = await CLIENT.send(req); + + expect(resp.result).toMatchSnapshot(); + expect(resp.result.isError).toBeUndefined(); + }); + + itSkip(envNodeVersion <= 20)('should fail to interact with the new tool', async () => { + const req = { + method: 'tools/call', + params: { + name: 'echo_plugin_tool', + arguments: { + type: 'echo', + lorem: 'ipsum', + dolor: 'sit amet' + } + } + }; + + 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": [