Skip to content

Commit fd37d6f

Browse files
committed
feat: mcp tools as plugins
1 parent d80b648 commit fd37d6f

File tree

13 files changed

+699
-23
lines changed

13 files changed

+699
-23
lines changed

README.md

Lines changed: 315 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The Model Context Protocol (MCP) is an open standard that enables AI assistants
1919
## Prerequisites
2020

2121
- Node.js 20.0.0 or higher
22+
- 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.
2223
- NPM (or another Node package manager)
2324

2425
## Installation
@@ -83,6 +84,258 @@ Returned content format:
8384
- 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`.
8485
- If an entry fails to load, an inline error message is included for that entry.
8586

87+
### External tools (Plugins)
88+
89+
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.
90+
91+
- Node version gate
92+
- Node < 22 → external tools are skipped with a single startup warning; built‑ins still register.
93+
- Node >= 22 → external tools run out‑of‑process via the Tools Host.
94+
95+
- CLI
96+
- `--tool <plugin>` Add one or more external tools. Repeat the flag or pass a comma‑separated list.
97+
- Examples: `--tool @acme/my-plugin`, `--tool ./plugins/my-tools.js`, `--tool ./a.js,./b.js`
98+
- `--plugin-isolation <none|strict>` Tools Host permission preset.
99+
- Defaults: `strict` when any `--tool` is provided; otherwise `none`.
100+
101+
- Behavior
102+
- External tools run in a single Tools Host child process.
103+
- In `strict` isolation (default with externals): network and fs write are denied; fs reads are allow‑listed to your project and resolved plugin directories.
104+
105+
- Supported `--tool` inputs
106+
- ESM packages (installed in node_modules)
107+
- Local ESM files (paths are normalized to `file://` URLs internally)
108+
109+
- Not supported as `--tool` inputs
110+
- Raw TypeScript sources (`.ts`) — the Tools Host does not install a TS loader
111+
- Remote `http(s):` or `data:` URLs — these will fail to load and appear in startup warnings/errors
112+
113+
- Troubleshooting
114+
- 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.
115+
- Startup `load:ack` warnings/errors from plugins are logged when stderr/protocol logging is enabled.
116+
- 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.
117+
118+
### Embedding the server (Programmatic API)
119+
120+
You can embed the MCP server inside another Node/TypeScript application and register tools programmatically.
121+
122+
Tools as plugins can be
123+
- Inline creators, or an array/list of inline creators, provided through the convenience wrapper `createMcpTool`, i.e. `createMcpTool({ name: 'echoAMessage', ... })` or `createMcpTool([{ name: 'echoAMessage', ... }])`.
124+
- 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://`
125+
- Local NPM package names (Node >= 22 required), i.e. `a string representing a local NPM package name like @loremIpsum/my-plugin`
126+
127+
> Note: Consuming remote/external files, such as YML, and NPM packages is targeted for the near future.
128+
129+
Supported export shapes for external modules (Node >= 22 only):
130+
131+
- 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] }`
132+
- Default export: function returning an array of creator functions. Example shape: `export default function (opts) { return [() => [...], () => [...]] }`
133+
- Default export: array of creator functions. Example shape: `export default [ () => ['name', {...}, handler] ]`
134+
- Fallback: a named export that is an array of creator functions (only used if default export is not present).
135+
136+
Not supported (Phase A+B):
137+
138+
- Directly exporting a bare tuple as the module default (wrap it in a function instead)
139+
- Plugin objects like `{ createCreators, createTools }`
140+
141+
Performance and determinism note:
142+
143+
- 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.
144+
145+
External module examples (Node >= 22):
146+
147+
Function returning a tuple (called once with options):
148+
149+
```js
150+
// plugins/echo.js
151+
export default function createEchoTool(opts) {
152+
return [
153+
'echo_plugin_tool',
154+
{ description: 'Echo', inputSchema: { additionalProperties: true } },
155+
async (args) => ({ content: [{ type: 'text', text: JSON.stringify({ args, opts }) }] })
156+
];
157+
}
158+
```
159+
160+
Function returning multiple creators:
161+
162+
```js
163+
// plugins/multi.js
164+
const t1 = () => ['one', { description: 'One', inputSchema: {} }, async () => ({})];
165+
const t2 = () => ['two', { description: 'Two', inputSchema: {} }, async () => ({})];
166+
167+
export default function creators(opts) {
168+
// You can use opts to conditionally include creators
169+
return [t1, t2];
170+
}
171+
```
172+
173+
Array of creators directly:
174+
175+
```js
176+
// plugins/direct-array.js
177+
export default [
178+
() => ['hello', { description: 'Hello', inputSchema: {} }, async () => ({})]
179+
];
180+
```
181+
182+
#### Example
183+
```typescript
184+
// app.ts
185+
import { start, createMcpTool, type PfMcpInstance, type PfMcpLogEvent, type ToolCreator } from '@patternfly/patternfly-mcp';
186+
187+
// Define a simple inline MCP tool. `createMcpTool` is a convenience wrapper to help you start writing a MCP tool.
188+
const echoTool: ToolCreator = createMcpTool({
189+
// The unique name of the tool, used in the `tools/list` response, related to the MCP client.
190+
// A MCP client can help Models use this, so make it informative and clear.
191+
name: 'echoAMessage',
192+
193+
// A short description of the tool, used in the `tools/list` response, related to the MCP client.
194+
// A MCP client can help Models can use this, so make it informative and clear.
195+
description: 'Echo back the provided user message.',
196+
197+
// The input schema defines the shape of interacting with your handler, related to the Model.
198+
// In this scenario the `args` object has a `string` `message` property intended to be passed back
199+
// towards the tool `handler` when the Model calls it.
200+
inputSchema: {
201+
type: 'object', // Type of the input schema, in this case the object
202+
properties: { message: { type: 'string' } }, // The properties, with types, to pass back to the handler
203+
required: ['message'] // Required properties, in this case `message`
204+
},
205+
206+
// The handler, async or sync. The Model calls the handler per the client and inputSchema and inputs the
207+
// `message`. The handler parses the `message` and returns it. The Model receives the parsed `message`
208+
// and uses it.
209+
handler: async (args: { message: string }) => ({ text: `You said: ${args.message}` })
210+
});
211+
212+
async function main() {
213+
// Start the server.
214+
const server: PfMcpInstance = await start({
215+
// Add one or more in‑process tools directly. Default tools will be registered first.
216+
toolModules: [
217+
// You can pass:
218+
// - a string module (package or file) for external plugins (Tools Host, Node ≥ 22), or
219+
// - a creator function returned by createMcpTool(...) for in‑process tools.
220+
echoTool
221+
]
222+
// Optional: enable all logging through stderr and/or protocol.
223+
// logging: { level: 'info', stderr: true },
224+
});
225+
226+
// Optional: observe refined server logs in‑process
227+
server.onLog((event: PfMcpLogEvent) => {
228+
// A good habit to get into is avoiding `console.log` and `console.info` in production paths, they pollute stdio
229+
// communication and can create noise. Use `console.error`, `console.warn`, or `process.stderr.write` instead.
230+
if (event.level !== 'debug') {
231+
// process.stderr.write(`[${event.level}] ${event.msg || ''}\n`);
232+
// console.error(`[${event.level}] ${event.msg || ''}`);
233+
console.warn(`[${event.level}] ${event.msg || ''}`);
234+
}
235+
});
236+
237+
// Stop the server after 10 seconds.
238+
setTimeout(async () => server.stop(), 10000);
239+
}
240+
241+
// Run the program.
242+
main().catch((err) => {
243+
// In programmatic mode, unhandled errors throw unless allowProcessExit=true
244+
console.error(err);
245+
process.exit(1);
246+
});
247+
```
248+
249+
#### Development notes
250+
- Built‑in tools are always registered first.
251+
- Consuming the MCP server comes with a not-so-obvious limitation, avoiding `console.log` and `console.info`.
252+
- 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`.
253+
- 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.
254+
255+
### Authoring external tools with `createMcpTool`
256+
257+
Export an ESM module using `createMcpTool`. The server adapts single or multiple tool definitions automatically.
258+
259+
Single tool:
260+
261+
```ts
262+
import { createMcpTool } from '@patternfly/patternfly-mcp';
263+
264+
export default createMcpTool({
265+
name: 'hello',
266+
description: 'Say hello',
267+
inputSchema: {
268+
type: 'object',
269+
properties: { name: { type: 'string' } },
270+
required: ['name']
271+
},
272+
async handler({ name }) {
273+
return `Hello, ${name}!`;
274+
}
275+
});
276+
```
277+
278+
Multiple tools:
279+
280+
```ts
281+
import { createMcpTool } from '@patternfly/patternfly-mcp';
282+
283+
export default createMcpTool([
284+
{ name: 'hi', description: 'Greets', inputSchema: { type: 'object' }, handler: () => 'hi' },
285+
{ name: 'bye', description: 'Farewell', inputSchema: { type: 'object' }, handler: () => 'bye' }
286+
]);
287+
```
288+
289+
Named group:
290+
291+
```ts
292+
import { createMcpTool } from '@patternfly/patternfly-mcp';
293+
294+
export default createMcpTool({
295+
name: 'my-plugin',
296+
tools: [
297+
{ name: 'alpha', description: 'A', inputSchema: { type: 'object' }, handler: () => 'A' },
298+
{ name: 'beta', description: 'B', inputSchema: { type: 'object' }, handler: () => 'B' }
299+
]
300+
});
301+
```
302+
303+
Notes
304+
- External tools must be ESM modules (packages or ESM files). The Tools Host imports your module via `import()`.
305+
- The `handler` receives `args` per your `inputSchema`. A reserved `options?` parameter may be added in a future release; it is not currently passed.
306+
307+
### Input Schema Format
308+
309+
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.
310+
311+
**JSON Schema (recommended for simplicity):**
312+
```
313+
inputSchema: {
314+
type: 'object',
315+
properties: {
316+
name: { type: 'string' },
317+
age: { type: 'number' }
318+
},
319+
required: ['name']
320+
}
321+
```
322+
323+
**Zod Schema (for advanced validation):**
324+
```
325+
import { z } from 'zod';
326+
327+
inputSchema: {
328+
name: z.string(),
329+
age: z.number().optional()
330+
}
331+
```
332+
333+
**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:
334+
- `{ type: 'object', additionalProperties: true }``z.object({}).passthrough()`
335+
- Simple object schemas → `z.object({...})`
336+
337+
If you encounter validation errors, ensure your JSON Schema follows standard JSON Schema format, or use Zod schemas directly for more control.
338+
86339
## Logging
87340

88341
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 \
333586
## Environment variables
334587

335588
- DOC_MCP_FETCH_TIMEOUT_MS: Milliseconds to wait before aborting an HTTP fetch (default: 15000)
336-
- DOC_MCP_CLEAR_COOLDOWN_MS: Default cooldown value used in internal cache configuration. The current public API does not expose a `clearCache` tool.
589+
590+
## External tools (plugins)
591+
592+
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).
593+
594+
CLI examples (single `--tool` flag):
595+
596+
```bash
597+
# Single module
598+
npm run start:dev -- --tool ./dist/my-tool.js
599+
600+
# Multiple modules (repeatable)
601+
npm run start:dev -- --tool ./dist/t1.js --tool ./dist/t2.js
602+
603+
# Multiple modules (comma-separated)
604+
npm run start:dev -- --tool ./dist/t1.js,./dist/t2.js
605+
```
606+
607+
Programmatic usage:
608+
609+
```ts
610+
import { main } from '@patternfly/patternfly-mcp';
611+
612+
await main({
613+
toolModules: [
614+
new URL('./dist/t1.js', import.meta.url).toString(),
615+
'./dist/t2.js'
616+
]
617+
});
618+
```
619+
620+
Tools provided via `--tool`/`toolModules` are appended after the built-in tools.
621+
622+
### Authoring MCP external tools
623+
> Note: External MCP tools require using `Node >= 22` to run the server and ESM modules. TypeScript formatted tools are not directly supported.
624+
> If you do use TypeScript, you can use the `createMcpTool` helper to define your tools as pure ESM modules.
625+
626+
For `tools-as-plugin` authors, we recommend using the unified helper to define your tools as pure ESM modules:
627+
628+
```ts
629+
import { createMcpTool } from '@patternfly/patternfly-mcp';
630+
631+
export default createMcpTool({
632+
name: 'hello',
633+
description: 'Say hello',
634+
inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
635+
async handler({ name }) {
636+
return { content: `Hello, ${name}!` };
637+
}
638+
});
639+
```
640+
641+
Multiple tools in one module:
642+
643+
```ts
644+
import { createMcpTool } from '@patternfly/patternfly-mcp';
645+
646+
export default createMcpTool([
647+
{ name: 'hello', description: 'Hi', inputSchema: {}, handler: () => 'hi' },
648+
{ name: 'bye', description: 'Bye', inputSchema: {}, handler: () => 'bye' }
649+
]);
650+
```
337651

338652
## Programmatic usage (advanced)
339653

0 commit comments

Comments
 (0)