Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 315 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <resolved-path-or-url>` 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 <plugin>` 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 <none|strict>` 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.
Expand Down Expand Up @@ -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)

Expand Down
25 changes: 24 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,30 @@ export default {
roots: ['src'],
testMatch: ['<rootDir>/src/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setupTests.ts'],
...baseConfig
...baseConfig,
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
...tsConfig,
diagnostics: {
ignoreCodes: [1343]
},
astTransformers: {
before: [
{
path: 'ts-jest-mock-import-meta',
options: {
metaObjectReplacement: {
url: 'file:///mock/import-meta-url'
}
}
}
]
}
}
]
}
},
{
displayName: 'e2e',
Expand Down
Loading
Loading