diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 9219708..0ace401 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -32,6 +32,11 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "pfExternalExamplesComponents": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components", "pfExternalExamplesLayouts": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/layouts", "pfExternalExamplesTable": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-table/src/components", + "pluginHost": { + "gracePeriodMs": 2000, + "invokeTimeoutMs": 10000, + "loadTimeoutMs": 5000, + }, "repoName": "patternfly-mcp", "resourceMemoOptions": { "default": { diff --git a/src/__tests__/__snapshots__/options.tools.test.ts.snap b/src/__tests__/__snapshots__/options.tools.test.ts.snap new file mode 100644 index 0000000..dd5e233 --- /dev/null +++ b/src/__tests__/__snapshots__/options.tools.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`setToolOptions should set a subset of options for tools, default 1`] = ` +{ + "nodeMajor": "22", + "repoName": "dolor-sit-amet", + "serverName": "lorem ipsum", + "serverVersion": "1.2.3", +} +`; + +exports[`setToolOptions should set a subset of options for tools, random keys 1`] = ` +{ + "nodeMajor": undefined, + "repoName": undefined, + "serverName": undefined, + "serverVersion": undefined, +} +`; diff --git a/src/__tests__/__snapshots__/server.toolsHost.test.ts.snap b/src/__tests__/__snapshots__/server.toolsHost.test.ts.snap new file mode 100644 index 0000000..334882d --- /dev/null +++ b/src/__tests__/__snapshots__/server.toolsHost.test.ts.snap @@ -0,0 +1,549 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with JSON inputSchema 1`] = ` +{ + "manifestSchema": { + "additionalProperties": true, + "type": "object", + }, + "normalizedSchema": "[object Object], isZod=true", + "tool": [ + "lorem ipsum", + { + "description": "lorem ipsum", + "inputSchema": "[object Object], isZod=true", + }, + [Function], + ], + "warnings": [], +} +`; + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with invalid JSON inputSchema 1`] = ` +{ + "manifestSchema": { + "additionalProperties": "busted", + "type": "object", + }, + "normalizedSchema": "[object Object], isZod=true", + "tool": [ + "lorem ipsum", + { + "description": "lorem ipsum", + "inputSchema": "[object Object], isZod=true", + }, + [Function], + ], + "warnings": [], +} +`; + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with partial 1`] = ` +{ + "manifestSchema": { + "additionalProperties": true, + "type": "object", + }, + "normalizedSchema": "undefined, isZod=false", + "tool": [ + "lorem ipsum", + { + "description": "lorem ipsum", + "inputSchema": "undefined, isZod=false", + }, + [Function], + ], + "warnings": [ + "Using permissive JSON Schema fallback. Failed to convert Zod to JSON Schema for lorem ipsum.", + "Permissive JSON schemas may have unintended side-effects. Review lorem ipsum's inputSchema and ensure it's a valid JSON or Zod schema.", + ], +} +`; + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with undefined name 1`] = ` +{ + "manifestSchema": { + "additionalProperties": true, + "type": "object", + }, + "normalizedSchema": "[object Object], isZod=true", + "tool": [ + undefined, + { + "description": "lorem ipsum", + "inputSchema": "[object Object], isZod=true", + }, + [Function], + ], + "warnings": [], +} +`; + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with undefined name, schema 1`] = ` +{ + "manifestSchema": { + "additionalProperties": true, + "type": "object", + }, + "normalizedSchema": "undefined, isZod=false", + "tool": [ + undefined, + { + "description": undefined, + "inputSchema": "undefined, isZod=false", + }, + [Function], + ], + "warnings": [ + "Using permissive JSON Schema fallback. Failed to convert Zod to JSON Schema for creator.", + "Permissive JSON schemas may have unintended side-effects. Review creator's inputSchema and ensure it's a valid JSON or Zod schema.", + ], +} +`; + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with undefined schema 1`] = ` +{ + "manifestSchema": { + "additionalProperties": true, + "type": "object", + }, + "normalizedSchema": "undefined, isZod=false", + "tool": [ + "lorem ipsum", + { + "description": undefined, + "inputSchema": "undefined, isZod=false", + }, + [Function], + ], + "warnings": [ + "Using permissive JSON Schema fallback. Failed to convert Zod to JSON Schema for lorem ipsum.", + "Permissive JSON schemas may have unintended side-effects. Review lorem ipsum's inputSchema and ensure it's a valid JSON or Zod schema.", + ], +} +`; + +exports[`normalizeCreatorSchema should attempt to normalize a schema, with valid zod inputSchema 1`] = ` +{ + "manifestSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + }, + "normalizedSchema": "[object Object], isZod=true", + "tool": [ + "lorem ipsum", + { + "description": "lorem ipsum", + "inputSchema": "[object Object], isZod=true", + }, + [Function], + ], + "warnings": [], +} +`; + +exports[`requestFallback should send error response, with request id 1`] = ` +{ + "error": "Test error", + "id": "test-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestFallback should send error response, with string error 1`] = ` +{ + "error": "String error", + "id": "test-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestFallback should send error response, without request id 1`] = ` +{ + "error": "Test error", + "id": "n/a", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestHello should send hello:ack message, with different id 1`] = ` +[ + [ + { + "id": "test-id-2", + "t": "hello:ack", + }, + ], +] +`; + +exports[`requestHello should send hello:ack message, with valid request 1`] = ` +[ + [ + { + "id": "test-id-1", + "t": "hello:ack", + }, + ], +] +`; + +exports[`requestInvoke should attempt tool invocation, handler attempting to return a DOMException-like object, with name, message and multiline line stack 1`] = ` +{ + "error": "Internal error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler attempting to return a browser-like ErrorEvent-like object, with name, message and multiline line stack 1`] = ` +{ + "error": "Internal error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler attempting to return an error-like object, with message 1`] = ` +{ + "id": "request-id", + "ok": true, + "result": { + "message": "Handler error", + }, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler attempting to return an error-like object, with name and multiline line stack 1`] = ` +{ + "error": "Internal error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler attempting to return an error-like object, with name and single line stack 1`] = ` +{ + "error": "Internal error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler attempting to return an error-like object, with single line stack 1`] = ` +{ + "id": "request-id", + "ok": true, + "result": { + "message": "Handler error", + "stack": "Stack trace", + }, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler returning AggregateError 1`] = ` +{ + "error": "Internal error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler returning error 1`] = ` +{ + "error": "Internal error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler returning null 1`] = ` +{ + "id": "request-id", + "ok": true, + "result": null, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler returning promise 1`] = ` +{ + "id": "request-id", + "ok": true, + "result": { + "data": "async-result", + }, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler returning undefined 1`] = ` +{ + "id": "request-id", + "ok": true, + "result": undefined, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, handler throwing error 1`] = ` +{ + "error": "Handler error", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, mismatched state and request tool IDs 1`] = ` +{ + "error": "Unknown toolId", + "id": "request-id", + "ok": false, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should attempt tool invocation, successful handler 1`] = ` +{ + "id": "request-id", + "ok": true, + "result": { + "data": "result", + }, + "t": "invoke:result", +} +`; + +exports[`requestInvoke should timeout when handler takes too long 1`] = ` +[ + [ + { + "error": { + "message": "Invoke timeout", + }, + "id": "request-id", + "ok": false, + "t": "invoke:result", + }, + ], +] +`; + +exports[`requestLoad should send load:ack message, with empty warnings and errors 1`] = ` +[ + [ + { + "errors": [], + "id": "test-id", + "t": "load:ack", + "warnings": [], + }, + ], +] +`; + +exports[`requestLoad should send load:ack message, with only errors 1`] = ` +[ + [ + { + "errors": [ + "error1", + ], + "id": "test-id", + "t": "load:ack", + "warnings": [], + }, + ], +] +`; + +exports[`requestLoad should send load:ack message, with only warnings 1`] = ` +[ + [ + { + "errors": [], + "id": "test-id", + "t": "load:ack", + "warnings": [ + "warning1", + ], + }, + ], +] +`; + +exports[`requestLoad should send load:ack message, with undefined warnings and errors 1`] = ` +[ + [ + { + "errors": [], + "id": "test-id", + "t": "load:ack", + "warnings": [], + }, + ], +] +`; + +exports[`requestLoad should send load:ack message, with warnings and errors 1`] = ` +[ + [ + { + "errors": [ + "error1", + ], + "id": "test-id", + "t": "load:ack", + "warnings": [ + "warning1", + "warning2", + ], + }, + ], +] +`; + +exports[`requestManifestGet should send manifest:result message, with empty descriptors 1`] = ` +[ + [ + { + "id": "test-id", + "t": "manifest:result", + "tools": [], + }, + ], +] +`; + +exports[`requestManifestGet should send manifest:result message, with multiple tool descriptors 1`] = ` +[ + [ + { + "id": "test-id", + "t": "manifest:result", + "tools": [ + { + "description": "Description 1", + "id": "tool-1", + "inputSchema": { + "type": "object", + }, + "name": "Tool1", + "source": "module1", + }, + { + "description": "Description 2", + "id": "tool-2", + "inputSchema": {}, + "name": "Tool2", + "source": "module2", + }, + ], + }, + ], +] +`; + +exports[`requestManifestGet should send manifest:result message, with single tool descriptor 1`] = ` +[ + [ + { + "id": "test-id", + "t": "manifest:result", + "tools": [ + { + "description": "Description 1", + "id": "tool-1", + "inputSchema": {}, + "name": "Tool1", + "source": "module1", + }, + ], + }, + ], +] +`; + +exports[`requestShutdown should send shutdown:ack and exit, with different id 1`] = ` +[ + [ + { + "id": "test-id-2", + "t": "shutdown:ack", + }, + ], +] +`; + +exports[`requestShutdown should send shutdown:ack and exit, with valid request 1`] = ` +[ + [ + { + "id": "test-id-1", + "t": "shutdown:ack", + }, + ], +] +`; + +exports[`setHandlers should set up message handlers and attempt handle requests, hello 1`] = ` +[ + [ + { + "id": "test-id", + "t": "hello:ack", + }, + ], +] +`; + +exports[`setHandlers should set up message handlers and attempt handle requests, invoke 1`] = ` +[ + [ + { + "error": { + "message": "Unknown toolId", + }, + "id": "test-id", + "ok": false, + "t": "invoke:result", + }, + ], +] +`; + +exports[`setHandlers should set up message handlers and attempt handle requests, load 1`] = ` +[ + [ + { + "errors": [], + "id": "test-id", + "t": "load:ack", + "warnings": [], + }, + ], +] +`; + +exports[`setHandlers should set up message handlers and attempt handle requests, manifest:get 1`] = ` +[ + [ + { + "id": "test-id", + "t": "manifest:result", + "tools": [], + }, + ], +] +`; diff --git a/src/__tests__/__snapshots__/server.toolsHostCreator.test.ts.snap b/src/__tests__/__snapshots__/server.toolsHostCreator.test.ts.snap new file mode 100644 index 0000000..1ba3804 --- /dev/null +++ b/src/__tests__/__snapshots__/server.toolsHostCreator.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`resolveExternalCreators should return a normalized module output with expected properties: normalized 1`] = ` +[ + "Tool1", + { + "description": "Tool 1", + "inputSchema": "[object Object] isZod = true", + }, + [MockFunction], +] +`; diff --git a/src/__tests__/options.tools.test.ts b/src/__tests__/options.tools.test.ts new file mode 100644 index 0000000..c44a8b6 --- /dev/null +++ b/src/__tests__/options.tools.test.ts @@ -0,0 +1,27 @@ +import { setToolOptions } from '../options.tools'; + +describe('setToolOptions', () => { + it.each([ + { + description: 'default', + options: { + name: 'lorem ipsum', + version: '1.2.3', + nodeVersion: '22', + repoName: 'dolor-sit-amet', + extra: 'consectetur adipiscing elit' + } + }, + { + description: 'random keys', + options: { + lorem: 'lorem ipsum', + ipsum: '1.2.3', + dolor: '22', + sit: 'dolor-sit-amet' + } + } + ])('should set a subset of options for tools, $description', ({ options }) => { + expect(setToolOptions(options as any)).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/server.toolsHost.test.ts b/src/__tests__/server.toolsHost.test.ts new file mode 100644 index 0000000..e2bfc9b --- /dev/null +++ b/src/__tests__/server.toolsHost.test.ts @@ -0,0 +1,626 @@ +import { z } from 'zod'; +import { + normalizeCreatorSchema, + requestHello, + requestLoad, + requestManifestGet, + requestInvoke, + requestShutdown, + requestFallback, + setHandlers +} from '../server.toolsHost'; +import { isZodSchema } from '../server.schema'; + +describe('normalizeCreatorSchema', () => { + it.each([ + { + description: 'with undefined name, schema', + creator: () => [ + undefined, + undefined, + () => null + ] + }, + { + description: 'with undefined name', + creator: () => [ + undefined, + { + description: 'lorem ipsum', + inputSchema: { type: 'object', additionalProperties: true } + }, + () => null + ] + }, + { + description: 'with undefined schema', + creator: () => [ + 'lorem ipsum', + undefined, + () => null + ] + }, + { + description: 'with partial', + creator: () => [ + 'lorem ipsum', + { + description: 'lorem ipsum', + inputSchema: undefined + }, + () => null + ] + }, + { + description: 'with JSON inputSchema', + creator: () => [ + 'lorem ipsum', + { + description: 'lorem ipsum', + inputSchema: { type: 'object', additionalProperties: true } + }, + () => null + ] + }, + { + description: 'with invalid JSON inputSchema', + creator: () => [ + 'lorem ipsum', + { + description: 'lorem ipsum', + inputSchema: { type: 'object', additionalProperties: 'busted' } + }, + () => null + ] + }, + { + description: 'with valid zod inputSchema', + creator: () => [ + 'lorem ipsum', + { + description: 'lorem ipsum', + inputSchema: z.any() + }, + () => null + ] + } + ])('should attempt to normalize a schema, $description', ({ creator }) => { + const { normalizedSchema, tool, ...rest } = normalizeCreatorSchema(creator); + + expect({ + normalizedSchema: `${normalizedSchema}, isZod=${isZodSchema(normalizedSchema)}`, + tool: [ + tool[0], + { + description: tool[1]?.description, + inputSchema: `${tool[1]?.inputSchema}, isZod=${isZodSchema(tool[1]?.inputSchema)}` + }, + tool[2] + ], + ...rest + }).toMatchSnapshot(); + }); +}); + +describe('requestHello', () => { + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + process.send = mockSend; + }); + + afterEach(() => { + delete (process as any).send; + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with valid request', + request: { t: 'hello', id: 'test-id-1' } + }, + { + description: 'with different id', + request: { t: 'hello', id: 'test-id-2' } + } + ])('should send hello:ack message, $description', ({ request }) => { + requestHello(request as any); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls).toMatchSnapshot(); + }); + + it('should not throw when process.send is undefined', () => { + delete (process as any).send; + + expect(() => { + requestHello({ t: 'hello', id: 'test-id' }); + }).not.toThrow(); + }); +}); + +describe('requestInvoke', () => { + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + process.send = mockSend; + }); + + afterEach(() => { + delete (process as any).send; + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'successful handler', + handlerResult: { data: 'result' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler returning promise', + handlerResult: Promise.resolve({ data: 'async-result' }), + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler throwing error', + handlerResult: Promise.reject(new Error('Handler error')), + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler returning error', + handlerResult: new Error('Handler error'), + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'mismatched state and request tool IDs', + handlerResult: { data: 'result' }, + stateToolId: 'tool-1', + requestToolId: 'tool-2' + }, + { + description: 'handler returning AggregateError', + handlerResult: new AggregateError(['Handler error']), + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler attempting to return an error-like object, with message', + handlerResult: { message: 'Handler error' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler attempting to return an error-like object, with single line stack', + handlerResult: { message: 'Handler error', stack: 'Stack trace' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler attempting to return an error-like object, with name and single line stack', + handlerResult: { name: 'Mock ERROR', message: 'Handler error', stack: 'Stack trace' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler attempting to return an error-like object, with name and multiline line stack', + handlerResult: { name: 'Mock', message: 'Handler error', stack: 'Stack trace\nSecond line' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler attempting to return a DOMException-like object, with name, message and multiline line stack', + handlerResult: { name: 'DOMException', message: 'Handler error', stack: 'DOMException: message\n at line x' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler attempting to return a browser-like ErrorEvent-like object, with name, message and multiline line stack', + handlerResult: { name: 'ErrorEvent', message: 'Handler error', stack: 'ErrorEvent: message\n at line x' }, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler returning undefined', + handlerResult: undefined, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + }, + { + description: 'handler returning null', + handlerResult: null, + stateToolId: 'tool-1', + requestToolId: 'tool-1' + } + ])('should attempt tool invocation, $description', async ({ handlerResult, stateToolId, requestToolId }) => { + const mockState = { + toolMap: new Map(), + descriptors: [ + { + id: stateToolId, + name: 'ToolName', + description: 'Tool description 1', + inputSchema: {}, + source: 'module1' + } + ], + invokeTimeoutMs: 1000 + }; + + mockState.toolMap.set( + stateToolId, + [ + 'ToolName', + { description: 'Tool description 1', inputSchema: {} }, + jest.fn().mockImplementation(async () => handlerResult) + ] + ); + + const promise = requestInvoke(mockState as any, { t: 'invoke', id: 'request-id', toolId: requestToolId, args: { param: 'value' } }); + + await promise; + + expect(mockSend.mock.calls.length).toBe(1); + + const { error, ...rest } = mockSend.mock.calls[0][0]; + + expect({ + ...((error?.message && { error: error?.message }) || undefined), + ...rest + }).toMatchSnapshot(); + }); + + it('should timeout when handler takes too long', async () => { + jest.useFakeTimers(); + const stateToolId = 'tool-1'; + const requestToolId = 'tool-1'; + const mockState = { + toolMap: new Map(), + descriptors: [ + { + id: stateToolId, + name: 'ToolName', + description: 'Tool description 1', + inputSchema: {}, + source: 'module1' + } + ], + invokeTimeoutMs: 100 + }; + + // Create a handler that resolves after timeout would fire + const handler = jest.fn(() => new Promise(resolve => { + setTimeout(resolve, 101); + })); + + mockState.toolMap.set( + stateToolId, + [ + 'ToolName', + { description: 'Tool description 1', inputSchema: {} }, + handler + ] + ); + + const invokePromise = requestInvoke(mockState, { t: 'invoke', id: 'request-id', toolId: requestToolId, args: {} }); + + // Wait for handler to be called, timeout to be set up + await Promise.resolve(); + + // Advance timers past timeout + jest.advanceTimersByTime(102); + + // Wait for the timeout message to be sent + await Promise.resolve(); + + // Verify timeout message was sent + expect(mockSend.mock.calls).toMatchSnapshot(); + + // Wait for the function to complete + await invokePromise; + + expect(mockSend).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); +}); + +describe('requestLoad', () => { + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + process.send = mockSend; + }); + + afterEach(() => { + delete (process as any).send; + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with warnings and errors', + request: { t: 'load', id: 'test-id', specs: [] }, + warnings: ['warning1', 'warning2'], + errors: ['error1'] + }, + { + description: 'with empty warnings and errors', + request: { t: 'load', id: 'test-id', specs: [] }, + warnings: [], + errors: [] + }, + { + description: 'with only warnings', + request: { t: 'load', id: 'test-id', specs: [] }, + warnings: ['warning1'], + errors: [] + }, + { + description: 'with only errors', + request: { t: 'load', id: 'test-id', specs: [] }, + warnings: [], + errors: ['error1'] + }, + { + description: 'with undefined warnings and errors', + request: { t: 'load', id: 'test-id', specs: [] }, + warnings: undefined, + errors: undefined + } + ])('should send load:ack message, $description', ({ request, warnings, errors }) => { + const options: { warnings?: string[]; errors?: string[] } = {}; + + if (warnings !== undefined) { + options.warnings = warnings; + } + if (errors !== undefined) { + options.errors = errors; + } + requestLoad(request as any, options); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls).toMatchSnapshot(); + }); + + it('should not throw when process.send is undefined', () => { + delete (process as any).send; + + expect(() => { + requestLoad({ t: 'load', id: 'test-id', specs: [] }, {}); + }).not.toThrow(); + }); +}); + +describe('requestManifestGet', () => { + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + process.send = mockSend; + }); + + afterEach(() => { + delete (process as any).send; + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with empty descriptors', + state: { + toolMap: new Map(), + descriptors: [], + invokeTimeoutMs: 1000 + }, + request: { t: 'manifest:get', id: 'test-id' } + }, + { + description: 'with single tool descriptor', + state: { + toolMap: new Map(), + descriptors: [ + { + id: 'tool-1', + name: 'Tool1', + description: 'Description 1', + inputSchema: {}, + source: 'module1' + } + ], + invokeTimeoutMs: 1000 + }, + request: { t: 'manifest:get', id: 'test-id' } + }, + { + description: 'with multiple tool descriptors', + state: { + toolMap: new Map(), + descriptors: [ + { + id: 'tool-1', + name: 'Tool1', + description: 'Description 1', + inputSchema: { type: 'object' }, + source: 'module1' + }, + { + id: 'tool-2', + name: 'Tool2', + description: 'Description 2', + inputSchema: {}, + source: 'module2' + } + ], + invokeTimeoutMs: 1000 + }, + request: { t: 'manifest:get', id: 'test-id' } + } + ])('should send manifest:result message, $description', ({ state, request }) => { + requestManifestGet(state, request as any); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls).toMatchSnapshot(); + }); + + it('should not throw when process.send is undefined', () => { + const mockHostState = { + toolMap: new Map(), + descriptors: [], + invokeTimeoutMs: 1000 + }; + + delete (process as any).send; + + expect(() => { + requestManifestGet(mockHostState, { t: 'manifest:get', id: 'test-id' }); + }).not.toThrow(); + }); +}); + +describe('requestShutdown', () => { + let mockSend: jest.Mock; + let mockExit: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + mockExit = jest.fn(); + process.send = mockSend; + process.exit = mockExit as any; + }); + + afterEach(() => { + delete (process as any).send; + delete (process as any).exit; + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with valid request', + request: { t: 'shutdown', id: 'test-id-1' } + }, + { + description: 'with different id', + request: { t: 'shutdown', id: 'test-id-2' } + } + ])('should send shutdown:ack and exit, $description', ({ request }) => { + requestShutdown(request as any); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls).toMatchSnapshot(); + expect(mockExit).toHaveBeenCalledTimes(1); + expect(mockExit).toHaveBeenCalledWith(0); + }); +}); + +describe('requestFallback', () => { + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + process.send = mockSend; + }); + + afterEach(() => { + delete (process as any).send; + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'with request id', + request: { t: 'hello', id: 'test-id' }, + error: new Error('Test error') + }, + { + description: 'without request id', + request: { t: 'load', id: '', specs: [] }, + error: new Error('Test error') + }, + { + description: 'with string error', + request: { t: 'invoke', id: 'test-id', toolId: 'tool', args: {} }, + error: 'String error' + } + ])('should send error response, $description', ({ request, error }) => { + requestFallback(request as any, error as Error); + + expect(mockSend).toHaveBeenCalledTimes(1); + + const { error: err, ...rest } = mockSend.mock.calls[0][0]; + + expect({ + ...rest, + error: err?.message + }).toMatchSnapshot(); + }); + + it('should not throw when process.send is undefined', () => { + delete (process as any).send; + + expect(() => { + requestFallback({ t: 'hello', id: 'test-id' }, new Error('Test')); + }).not.toThrow(); + }); + + it('should not throw when send throws', () => { + mockSend.mockImplementation(() => { + throw new Error('Send failed'); + }); + + expect(() => { + requestFallback({ t: 'hello', id: 'test-id' }, new Error('Test')); + }).not.toThrow(); + }); +}); + +describe('setHandlers', () => { + let mockOn: jest.Mock; + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + mockOn = jest.fn(); + + process.on = mockOn; + process.send = mockSend; + }); + + afterEach(() => { + jest.clearAllMocks(); + delete (process as any).send; + }); + + it.each([ + { + description: 'hello', + request: { t: 'hello', id: 'test-id' } + }, + { + description: 'load', + request: { t: 'load', id: 'test-id' } + }, + { + description: 'manifest:get', + request: { t: 'manifest:get', id: 'test-id' } + }, + { + description: 'invoke', + request: { t: 'invoke', id: 'test-id' } + } + ])('should set up message handlers and attempt handle requests, $description', async ({ request }) => { + const handler = setHandlers(); + + await handler(request as any); + + expect(mockOn).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockSend.mock.calls).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/server.toolsHostCreator.test.ts b/src/__tests__/server.toolsHostCreator.test.ts new file mode 100644 index 0000000..f4c6012 --- /dev/null +++ b/src/__tests__/server.toolsHostCreator.test.ts @@ -0,0 +1,155 @@ +import { z } from 'zod'; +import { resolveExternalCreators } from '../server.toolsHostCreator'; +import { isZodSchema } from '../server.schema'; + +describe('resolveExternalCreators', () => { + it('should return a normalized module output with expected properties', () => { + const moduleExport = { + default: () => ['Tool1', { description: 'Tool 1', inputSchema: z.any() }, jest.fn()] + }; + + const [result] = resolveExternalCreators(moduleExport); + const [name, schema = {}, handler]: any[] = result?.() || []; + + expect([ + name, + { + description: schema.description, + inputSchema: `${schema} isZod = ${isZodSchema(schema.inputSchema)}` + }, + handler + ]).toMatchSnapshot('normalized'); + }); + + it.each([ + { + description: 'valid format, default export with function, tuple', + moduleExports: { + default: () => ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()] + }, + isValid: true + }, + { + description: 'valid format, default export with function, array of functions with tuple return', + moduleExports: { + default: () => [ + () => ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()], + () => ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()] + ] + }, + isValid: true + }, + { + description: 'valid format, default export with array of functions with tuple return', + moduleExports: { + default: [ + () => ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()], + () => ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()] + ] + }, + isValid: true + }, + { + description: 'invalid format, default export with function, array of tuples', + moduleExports: { + default: () => [ + ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()], + ['Tool2', { description: 'Tool 2', inputSchema: {} }, jest.fn()] + ] + }, + isValid: false + }, + { + description: 'invalid format, default export with tuple', + moduleExports: { + default: ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()] + }, + isValid: false + }, + { + description: 'invalid format, default export with array of tuples', + moduleExports: { + default: [ + ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()], + ['Tool2', { description: 'Tool 2', inputSchema: {} }, jest.fn()] + ] + }, + isValid: false + }, + { + description: 'invalid format, default export function that returns empty', + moduleExports: { + default: () => {} + }, + isValid: false + }, + { + description: 'invalid format, empty module', + moduleExports: {}, + isValid: false + }, + { + description: 'invalid format, default export function that returns null', + moduleExports: { + default: () => null + }, + isValid: false + }, + { + description: 'invalid format, null', + moduleExports: null, + isValid: false + }, + { + description: 'invalid format, default export function that returns undefined', + moduleExports: { + default: () => undefined + }, + isValid: false + }, + { + description: 'invalid format, undefined', + moduleExports: undefined, + isValid: false + }, + { + description: 'invalid format, default export function that throws', + moduleExports: { + default: () => { + throw new Error('Function error'); + } + }, + isValid: false + }, + { + description: 'invalid format, function that throws', + moduleExports: () => { + throw new Error('Function error'); + }, + isValid: false + }, + { + description: 'invalid format, function returning unsupported shape', + moduleExports: () => 'not a tool or creators[]', + isValid: false + }, + { + description: 'invalid format, array with non-function elements', + moduleExports: ['not a function', 123, {}], + isValid: false + }, + { + description: 'invalid format, named exports only', + moduleExports: { + named1: () => ['Tool1', { description: 'Tool 1', inputSchema: {} }, jest.fn()], + named2: () => ['Tool2', { description: 'Tool 2', inputSchema: {} }, jest.fn()] + }, + isValid: false + } + ])('should normalize module exports with specific formats, $description', ({ moduleExports, isValid }) => { + const result = resolveExternalCreators(moduleExports); + + expect(Array.isArray(result)).toBe(true); + expect(result.length > 0).toBe(isValid); + }); +}); diff --git a/src/__tests__/server.toolsIpc.test.ts b/src/__tests__/server.toolsIpc.test.ts new file mode 100644 index 0000000..bdcabec --- /dev/null +++ b/src/__tests__/server.toolsIpc.test.ts @@ -0,0 +1,510 @@ +import { type ChildProcess } from 'node:child_process'; +import { + send, + awaitIpc, + isHelloAck, + isLoadAck, + isManifestResult, + isInvokeResult, + type IpcRequest, + type IpcResponse +} from '../server.toolsIpc'; + +describe('send', () => { + let mockProcess: NodeJS.Process; + let mockChildProcess: ChildProcess; + + beforeEach(() => { + mockProcess = { + send: jest.fn().mockReturnValue(true) + } as any; + + mockChildProcess = { + send: jest.fn().mockReturnValue(true) + } as any; + }); + + it.each([ + { + description: 'hello request', + request: { t: 'hello', id: 'test-id' } + }, + { + description: 'load request', + request: { t: 'load', id: 'test-id', specs: ['module1', 'module2'] } + }, + { + description: 'load request with invokeTimeoutMs', + request: { t: 'load', id: 'test-id', specs: ['module1'], invokeTimeoutMs: 5000 } + }, + { + description: 'manifest:get request', + request: { t: 'manifest:get', id: 'test-id' } + }, + { + description: 'invoke request', + request: { t: 'invoke', id: 'test-id', toolId: 'tool1', args: { param: 'value' } } + }, + { + description: 'shutdown request', + request: { t: 'shutdown', id: 'test-id' } + } + ])('should send IPC message, $description', ({ request }) => { + const result = send(mockProcess, request as IpcRequest); + + expect(result).toBe(true); + expect(mockProcess.send).toHaveBeenCalledTimes(1); + expect(mockProcess.send).toHaveBeenCalledWith(request); + + const childResult = send(mockChildProcess, request as IpcRequest); + + expect(childResult).toBe(true); + expect(mockChildProcess.send).toHaveBeenCalledTimes(1); + expect(mockChildProcess.send).toHaveBeenCalledWith(request); + }); + + it.each([ + { + description: 'process without send', + process: {} + }, + { + description: 'process with send returning false', + process: { + send: jest.fn().mockReturnValue(false) + } + } + ])('should return false, $description', ({ process }) => { + const result = send(process as any, { t: 'hello', id: 'test-id' }); + + expect(result).toBe(false); + }); +}); + +describe('isHelloAck', () => { + it.each([ + { + description: 'valid hello:ack message', + message: { t: 'hello:ack', id: 'test-id' }, + expected: true + }, + { + description: 'invalid type', + message: { t: 'hello', id: 'test-id' }, + expected: false + }, + { + description: 'missing type', + message: { id: 'test-id' }, + expected: false + }, + { + description: 'missing id', + message: { t: 'hello:ack' }, + expected: false + }, + { + description: 'non-string id', + message: { t: 'hello:ack', id: 123 }, + expected: false + }, + { + description: 'null message', + message: null, + expected: false + }, + { + description: 'undefined message', + message: undefined, + expected: false + }, + { + description: 'empty object', + message: {}, + expected: false + } + ])('should check if message is hello:ack, $description', ({ message, expected }) => { + expect(isHelloAck(message)).toBe(expected); + }); +}); + +describe('isLoadAck', () => { + it.each([ + { + description: 'valid load:ack message with matching id', + message: { t: 'load:ack', id: 'test-id', warnings: [], errors: [] }, + expectedId: 'test-id', + expected: true + }, + { + description: 'valid load:ack with warnings and errors', + message: { t: 'load:ack', id: 'test-id', warnings: ['warning1'], errors: ['error1'] }, + expectedId: 'test-id', + expected: true + }, + { + description: 'mismatched id', + message: { t: 'load:ack', id: 'other-id', warnings: [], errors: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'invalid type', + message: { t: 'load', id: 'test-id', warnings: [], errors: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'missing warnings', + message: { t: 'load:ack', id: 'test-id', errors: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'missing errors', + message: { t: 'load:ack', id: 'test-id', warnings: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'non-array warnings', + message: { t: 'load:ack', id: 'test-id', warnings: 'not-array', errors: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'non-array errors', + message: { t: 'load:ack', id: 'test-id', warnings: [], errors: 'not-array' }, + expectedId: 'test-id', + expected: false + }, + { + description: 'null message', + message: null, + expectedId: 'test-id', + expected: false + }, + { + description: 'undefined message', + message: undefined, + expectedId: 'test-id', + expected: false + } + ])('should check if message is load:ack, $description', ({ message, expectedId, expected }) => { + const matcher = isLoadAck(expectedId); + + expect(matcher(message)).toBe(expected); + }); +}); + +describe('isManifestResult', () => { + it.each([ + { + description: 'valid manifest:result with matching id', + message: { t: 'manifest:result', id: 'test-id', tools: [] }, + expectedId: 'test-id', + expected: true + }, + { + description: 'valid manifest:result with tools', + message: { + t: 'manifest:result', + id: 'test-id', + tools: [ + { id: 'tool1', name: 'Tool1', description: 'Description', inputSchema: {} } + ] + }, + expectedId: 'test-id', + expected: true + }, + { + description: 'mismatched id', + message: { t: 'manifest:result', id: 'other-id', tools: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'invalid type', + message: { t: 'manifest', id: 'test-id', tools: [] }, + expectedId: 'test-id', + expected: false + }, + { + description: 'missing tools', + message: { t: 'manifest:result', id: 'test-id' }, + expectedId: 'test-id', + expected: false + }, + { + description: 'non-array tools', + message: { t: 'manifest:result', id: 'test-id', tools: 'not-array' }, + expectedId: 'test-id', + expected: false + }, + { + description: 'null message', + message: null, + expectedId: 'test-id', + expected: false + }, + { + description: 'undefined message', + message: undefined, + expectedId: 'test-id', + expected: false + } + ])('should check if message is manifest:result, $description', ({ message, expectedId, expected }) => { + const matcher = isManifestResult(expectedId); + + expect(matcher(message)).toBe(expected); + }); +}); + +describe('isInvokeResult', () => { + it.each([ + { + description: 'valid invoke:result with ok:true and matching id', + message: { t: 'invoke:result', id: 'test-id', ok: true, result: { data: 'value' } }, + expectedId: 'test-id', + expected: true + }, + { + description: 'valid invoke:result with ok:false and error', + message: { + t: 'invoke:result', + id: 'test-id', + ok: false, + error: { message: 'Error message', stack: 'stack trace', code: 'ERROR_CODE' } + }, + expectedId: 'test-id', + expected: true + }, + { + description: 'mismatched id', + message: { t: 'invoke:result', id: 'other-id', ok: true, result: {} }, + expectedId: 'test-id', + expected: false + }, + { + description: 'invalid type', + message: { t: 'invoke', id: 'test-id', ok: true, result: {} }, + expectedId: 'test-id', + expected: false + }, + { + description: 'missing id', + message: { t: 'invoke:result', ok: true, result: {} }, + expectedId: 'test-id', + expected: false + }, + { + description: 'null message', + message: null, + expectedId: 'test-id', + expected: false + }, + { + description: 'undefined message', + message: undefined, + expectedId: 'test-id', + expected: false + } + ])('should check if message is invoke:result, $description', ({ message, expectedId, expected }) => { + const matcher = isInvokeResult(expectedId); + + expect(matcher(message)).toBe(expected); + }); +}); + +describe('awaitIpc', () => { + let mockProcess: NodeJS.Process; + let messageHandlers: Array<(message: any) => void>; + let exitHandlers: Array<(code?: number, signal?: string) => void>; + let disconnectHandlers: Array<() => void>; + + beforeEach(() => { + messageHandlers = []; + exitHandlers = []; + disconnectHandlers = []; + + mockProcess = { + on: jest.fn((event: string, handler: any) => { + switch (event) { + case 'message': + messageHandlers.push(handler); + break; + case 'exit': + exitHandlers.push(handler); + break; + case 'disconnect': + disconnectHandlers.push(handler); + break; + } + + return mockProcess; + }), + off: jest.fn((event: string, handler: any) => { + switch (event) { + case 'message': { + const index = messageHandlers.indexOf(handler); + + if (index > -1) { + messageHandlers.splice(index, 1); + } + break; + } + case 'exit': { + const index = exitHandlers.indexOf(handler); + + if (index > -1) { + exitHandlers.splice(index, 1); + } + break; + } + case 'disconnect': { + const index = disconnectHandlers.indexOf(handler); + + if (index > -1) { + disconnectHandlers.splice(index, 1); + } + break; + } + } + + return mockProcess; + }) + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'hello:ack message', + response: { t: 'hello:ack', id: 'test-id' } + }, + { + description: 'load:ack message', + response: { t: 'load:ack', id: 'test-id', warnings: [], errors: [] } + }, + { + description: 'manifest:result message', + response: { t: 'manifest:result', id: 'test-id', tools: [] } + }, + { + description: 'invoke:result with ok:true', + response: { t: 'invoke:result', id: 'test-id', ok: true, result: { data: 'value' } } + }, + { + description: 'invoke:result with ok:false', + response: { t: 'invoke:result', id: 'test-id', ok: false, error: { message: 'Error' } } + } + ])('should await and resolve IPC response, $description', async ({ response }) => { + let promise: Promise; + + switch (response.t) { + case 'hello:ack': + promise = awaitIpc(mockProcess, isHelloAck, 1000); + break; + case 'load:ack': + promise = awaitIpc(mockProcess, isLoadAck(response.id), 1000); + break; + case 'manifest:result': + promise = awaitIpc(mockProcess, isManifestResult(response.id), 1000); + break; + default: + promise = awaitIpc(mockProcess, isInvokeResult(response.id), 1000); + break; + } + + // Simulate message arrival, wait for handlers to be registered + await Promise.resolve(); + messageHandlers.forEach(handler => handler(response)); + + const result = await promise; + + expect(result).toEqual(response); + expect(mockProcess.on).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockProcess.on).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(mockProcess.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + + it('should ignore non-matching messages and only resolve once', async () => { + const responseOne = { t: 'hello:ack', id: 'test-id-1' }; + const responseTwo = { t: 'other:type', id: 'test-id-2' }; + const responseThree = { t: 'hello:ack', id: 'test-id-2' }; + + const promise = awaitIpc(mockProcess, isHelloAck, 1000); + + // Simulate message arrival, wait for handlers to be registered + await Promise.resolve(); + messageHandlers.forEach(handler => handler(responseTwo)); + + await Promise.resolve(); + messageHandlers.forEach(handler => handler(responseOne)); + + await Promise.resolve(); + messageHandlers.forEach(handler => handler(responseThree)); + + const result = await promise; + + expect(result).toEqual(responseOne); + }); + + it('should reject when process exits', async () => { + const exit = { event: 'exit', code: 1, signal: 'SIGTERM' }; + + const promise = awaitIpc(mockProcess, isHelloAck, 1000); + + // Simulate message arrival, wait for handlers to be registered + await Promise.resolve(); + exitHandlers.forEach(handler => handler(exit.code, exit.signal)); + + await expect(promise).rejects.toThrow('Tools Host exited before response'); + }); + + it('should reject on timeout', async () => { + jest.useFakeTimers(); + const promise = awaitIpc(mockProcess, isHelloAck, 1000); + + jest.advanceTimersByTime(1001); + + await expect(promise).rejects.toThrow('Timed out waiting for IPC response'); + jest.useRealTimers(); + }); + + it('should cleanup event listeners on resolve', async () => { + const response = { t: 'hello:ack', id: 'test-id' }; + const promise = awaitIpc(mockProcess, isHelloAck, 1000); + + // Simulate message arrival, wait for handlers to be registered + await Promise.resolve(); + messageHandlers.forEach(handler => handler(response)); + + await promise; + + expect(mockProcess.off).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockProcess.off).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(mockProcess.off).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + + it('should cleanup event listeners on reject', async () => { + jest.useFakeTimers(); + const promise = awaitIpc(mockProcess, isHelloAck, 1000); + + jest.advanceTimersByTime(1001); + + try { + await promise; + } catch { + // Expected to reject + } + jest.useRealTimers(); + + expect(mockProcess.off).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockProcess.off).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(mockProcess.off).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); +}); diff --git a/src/options.defaults.ts b/src/options.defaults.ts index e35aa7a..640389b 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -18,6 +18,7 @@ import packageJson from '../package.json'; * @property {LoggingOptions} logging - Logging options. * @property name - Name of the package. * @property nodeVersion - Node.js major version. + * @property {PluginHostOptions} pluginHost - Plugin host options. * @property repoName - Name of the repository. * @property pfExternal - PatternFly external docs URL. * @property pfExternalDesignComponents - PatternFly design guidelines' components' URL. @@ -45,6 +46,7 @@ interface DefaultOptions { logging: TLogOptions; name: string; nodeVersion: number; + pluginHost: PluginHostOptions; pfExternal: string; pfExternalDesignComponents: string; pfExternalExamplesComponents: string; @@ -114,6 +116,19 @@ interface HttpOptions { allowedHosts: string[]; } +/** + * 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. * @@ -146,6 +161,15 @@ const HTTP_OPTIONS: HttpOptions = { allowedHosts: [] }; +/** + * Default plugin host options. + */ +const PLUGIN_HOST_OPTIONS: PluginHostOptions = { + loadTimeoutMs: 5000, + invokeTimeoutMs: 10000, + gracePeriodMs: 2000 +}; + /** * Default separator for joining multiple document contents */ @@ -291,6 +315,7 @@ const DEFAULT_OPTIONS: DefaultOptions = { logging: LOGGING_OPTIONS, name: packageJson.name, nodeVersion: (process.env.NODE_ENV === 'local' && 22) || getNodeMajorVersion(), + pluginHost: PLUGIN_HOST_OPTIONS, pfExternal: PF_EXTERNAL, pfExternalDesignComponents: PF_EXTERNAL_DESIGN_COMPONENTS, pfExternalExamplesComponents: PF_EXTERNAL_EXAMPLES_REACT_CORE, @@ -328,5 +353,6 @@ export { type DefaultOptionsOverrides, type HttpOptions, type LoggingOptions, - type LoggingSession + type LoggingSession, + type PluginHostOptions }; diff --git a/src/options.tools.ts b/src/options.tools.ts new file mode 100644 index 0000000..b0716a0 --- /dev/null +++ b/src/options.tools.ts @@ -0,0 +1,31 @@ +import { type GlobalOptions } from './options'; + +/** + * Options for tools. + * + * @property serverName - Name of the server instance. + * @property serverVersion - Version of the server instance. + * @property nodeMajor - Major version of the Node.js runtime. + * @property repoName - Name of the repository containing the server instance. + */ +type ToolOptions = { + serverName: string; + serverVersion: string; + nodeMajor: number; + repoName: string; +}; + +/** + * Return a refined set of options from global options for tools. + * + * @param {GlobalOptions} options - Minimal set of options required for tools. + * @returns {ToolOptions} + */ +const setToolOptions = (options: GlobalOptions): ToolOptions => ({ + serverName: options.name, + serverVersion: options.version, + nodeMajor: options.nodeVersion, + repoName: options.repoName as string +}); + +export { setToolOptions, type ToolOptions }; diff --git a/src/server.schema.ts b/src/server.schema.ts index 16073a1..9e20ae3 100644 --- a/src/server.schema.ts +++ b/src/server.schema.ts @@ -18,7 +18,7 @@ const isZodSchema = (value: unknown): boolean => { const obj = value as Record; // Guard for property presence - const has = (key: string) => Object.prototype.hasOwnProperty.call(obj, key); + const has = (key: string) => Object.hasOwn(obj, key); const isFunc = (func: unknown) => typeof func === 'function'; // Zod v4 detection: branded internals at `_zod`. In v4, `_zod` is an object @@ -72,7 +72,8 @@ const isZodRawShape = (value: unknown): boolean => { * @param jsonSchema - Plain JSON Schema object * @param settings - Optional settings * @param settings.failFast - Fail fast on unsupported types, or be nice and attempt to convert. Defaults to true. - * @returns Zod schema equivalent + * @returns A Zod schema when convertible, returns undefined when failFast and input are unsupported, or falls back + * to z.any() when failFast `false` */ const jsonSchemaToZod = ( jsonSchema: unknown, diff --git a/src/server.toolsHost.ts b/src/server.toolsHost.ts new file mode 100644 index 0000000..36342f5 --- /dev/null +++ b/src/server.toolsHost.ts @@ -0,0 +1,557 @@ +import { + type IpcRequest, + type ToolDescriptor, + type SerializedError, + makeId +} from './server.toolsIpc'; +import { resolveExternalCreators } from './server.toolsHostCreator'; +import { DEFAULT_OPTIONS } from './options.defaults'; +import { type ToolOptions } from './options.tools'; +import { type McpTool, type McpToolCreator } from './server'; +import { + isZodRawShape, + isZodSchema, + normalizeInputSchema, + zodToJsonSchema +} from './server.schema'; +import { isPlainObject } from './server.helpers'; + +/** + * SubType of IpcRequest for "hello" requests. + */ +type HelloRequest = Extract; + +/** + * SubType of IpcRequest for "load" requests. + */ +type LoadRequest = Extract; + +/** + * SubType of IpcRequest for "manifest:get" requests. + */ +type ManifestGetRequest = Extract; + +/** + * SubType of IpcRequest for "invoke" requests. + */ +type InvokeRequest = Extract; + +/** + * SubType of IpcRequest for "shutdown" requests. + */ +type ShutdownRequest = Extract; + +/** + * State object for the tools host. + */ +type HostState = { + toolMap: Map; + descriptors: ToolDescriptor[]; + invokeTimeoutMs: number; +}; + +/** + * Create a new host state object. + * + * @param invokeTimeoutMs + * @returns {HostState} + */ +const createHostState = (invokeTimeoutMs = DEFAULT_OPTIONS.pluginHost.invokeTimeoutMs): HostState => ({ + toolMap: new Map(), + descriptors: [], + invokeTimeoutMs +}); + +/** + * Serialize an error value into a structured object. + * + * @param errorValue - Error-like value to serialize. + * @returns {SerializedError} - Serialized error object. + */ +const serializeError = (errorValue: unknown) => { + const err = errorValue as SerializedError | undefined; + + return { + message: err?.message || String(errorValue), + stack: err?.stack, + code: err?.code, + details: err?.details, + cause: err?.cause + }; +}; + +/** + * Result of `normalizeCreatorSchema`. + * + * @property tool - The realized tool tuple returned by the creator function. + * @property normalizedSchema - Normalized input schema. + * @property manifestSchema - JSON Schema representation of the normalized input schema. + * @property warnings - List of warnings generated during normalization. + */ +type NormalizeCreatorSchemaResult = { + tool: McpTool; + normalizedSchema: unknown; + manifestSchema: unknown; + warnings: string[]; +}; + +/** + * Check if a value is an error or an error-like object. + * + * Handles cross‑realm Error detection via tag checks for `[object Error]`, `[object AggregateError]`, + * and `[object DOMException]`. Does not treat `[object ErrorEvent]` as error‑like in the + * Node context; add if your runtime can emit `ErrorEvent`. + * + * @param value + * @returns True if the value is an error-like object, false otherwise. + */ +const isErrorLike = (value: unknown) => { + if (!value || (typeof value !== 'object' && typeof value !== 'function')) { + return false; + } + + if (value instanceof Error || value instanceof AggregateError) { + return true; + } + + const tag = Object.prototype.toString.call(value); + + if (tag === '[object Error]' || tag === '[object AggregateError]' || tag === '[object DOMException]') { + return true; + } + + const val = value as Record; + const has = (key: string) => + Object.hasOwn(val, key) && typeof val[key] === 'string' && val[key].length > 0; + + if (!has('message')) { + return false; + } + + const isNameLike = has('name') && (val.name as string).toLowerCase().endsWith('error'); + const isStackLike = has('stack') && (val.stack as string).includes('\n'); + + return isNameLike || isStackLike; +}; + +/** + * Normalize a tool creator function and its input schema. + * + * @param creator + * @param toolOptions + * @returns Object containing the normalized tool and its input schema. + */ +const normalizeCreatorSchema = (creator: unknown, toolOptions?: ToolOptions): NormalizeCreatorSchemaResult => { + const create = creator as (opts?: unknown) => McpTool; + + // Apply tool options to the creator function + const tool = create(toolOptions); + const toolName = tool[0] || create.name; + + // Normalize input schema in the child (Tools Host) + const cfg = (tool[1] ?? {}) as Record; + const normalizedSchema = normalizeInputSchema(cfg.inputSchema); + + // Overwrite tuple's schema so call-time validation matches manifest + tool[1] = { ...(tool[1] || {}), inputSchema: normalizedSchema } as any; + + // If the original was plain JSON Schema, prefer to send that as-is + if ( + normalizedSchema !== undefined && + isPlainObject(cfg.inputSchema) && + !isZodRawShape(cfg.inputSchema) && + !isZodSchema(cfg.inputSchema) + ) { + return { + tool, + normalizedSchema, + manifestSchema: cfg.inputSchema, + warnings: [] + }; + } + + // Zod schema, convert to JSON Schema. If conversion fails, send permissive fallback + const jsonSchemaForManifest = zodToJsonSchema(normalizedSchema); + const warnings: string[] = []; + + if (!jsonSchemaForManifest) { + const updatedToolName = toolName || 'the tool'; + + warnings.push( + `Using permissive JSON Schema fallback. Failed to convert Zod to JSON Schema for ${updatedToolName}.` + ); + warnings.push( + `Permissive JSON schemas may have unintended side-effects. Review ${updatedToolName}'s inputSchema and ensure it's a valid JSON or Zod schema.` + ); + } + + return { + tool, + normalizedSchema, + manifestSchema: jsonSchemaForManifest || { type: 'object', additionalProperties: true }, + warnings + }; +}; + +/** + * Load external tool creators, realize them, and normalize `inputSchema` in the child. + * + * Stores the real Zod schema in memory for runtime validation and sends a JSON-safe schema in descriptors. + * + * @param {LoadRequest} request - Load request object. + * @returns New state object with updated tools/descriptors and warnings/errors. + */ +const performLoad = async (request: LoadRequest): Promise => { + const nextInvokeTimeout = typeof request?.invokeTimeoutMs === 'number' && Number.isFinite(request.invokeTimeoutMs) && request.invokeTimeoutMs > 0 + ? request.invokeTimeoutMs + : DEFAULT_OPTIONS.pluginHost.invokeTimeoutMs; + + const state = createHostState(nextInvokeTimeout); + const warnings: string[] = []; + const errors: string[] = []; + const toolOptions: ToolOptions | undefined = request.toolOptions; + let module: unknown; + + for (const spec of request.specs || []) { + // Import the module. On fail, move to the next module. + try { + const dynamicImport = new Function('spec', 'return import(spec)') as (spec: string) => Promise; + + module = await dynamicImport(spec); + } catch (error) { + errors.push(`Failed import: ${spec}: ${String((error as Error)?.message || error)}`); + continue; + } + + // Does the module export a creator function? On fail, move to the next module. + let creators: McpToolCreator[] = []; + + try { + creators = resolveExternalCreators(module, request.toolOptions, { throwOnEmpty: true }); + } catch (error) { + warnings.push(`No usable creators in module ${spec}: ${String((error as Error)?.message || error)}`); + continue; + } + + // Finally, normalize module schema, convert to JSON for manifest, store, push descriptor + for (const creator of creators) { + try { + const { tool, manifestSchema, warnings: creatorWarnings } = normalizeCreatorSchema(creator, toolOptions); + + warnings.push(...creatorWarnings); + + const toolId = makeId(); + + state.toolMap.set(toolId, tool as McpTool); + state.descriptors.push({ + id: toolId, + name: tool[0], + description: tool[1]?.description || '', + inputSchema: manifestSchema, + source: spec + }); + } catch (error) { + warnings.push(`Tool creator threw while realizing: ${spec}: ${String((error as Error)?.message || error)}`); + } + } + } + + return { ...state, warnings, errors }; +}; + +/** + * Acknowledge a hello request. + * + * @param request + */ +const requestHello = (request: HelloRequest) => { + process.send?.({ t: 'hello:ack', id: request.id }); +}; + +/** + * Load tools from the provided list of module specifiers. + * + * @param {LoadRequest} request - Load request object. + * @param warningsErrors + * @param warningsErrors.warnings - List of warnings generated during tool loading. + * @param warningsErrors.errors - List of errors generated during tool loading. + */ +const requestLoad = ( + request: LoadRequest, + { warnings = [], errors = [] }: { warnings?: string[]; errors?: string[] } = {} +) => { + process.send?.({ t: 'load:ack', id: request.id, warnings, errors }); +}; + +/** + * Respond to a manifest request with a list of available tools. + * + * @param {HostState} state + * @param {ManifestGetRequest} request + */ +const requestManifestGet = (state: HostState, request: ManifestGetRequest) => { + process.send?.({ t: 'manifest:result', id: request.id, tools: state.descriptors }); +}; + +/** + * Invoke a realized tool by id. Validates arguments against the in-memory Zod schema. + * + * @example + * // On validation failure, returns + * { ok: false, error: { code: 'INVALID_ARGS', details } } + * + * @param {HostState} state + * @param {InvokeRequest} request + */ +const requestInvoke = async (state: HostState, request: InvokeRequest) => { + const tool = state.toolMap.get(request.toolId); + + if (!tool) { + process.send?.({ + t: 'invoke:result', + id: request.id, + ok: false, + error: { message: 'Unknown toolId' } + }); + + return; + } + + let settled = false; + + const timer = setTimeout(() => { + if (settled) { + return; + } + + settled = true; + + process.send?.({ + t: 'invoke:result', + id: request.id, + ok: false, + error: { message: 'Invoke timeout' } + }); + }, state.invokeTimeoutMs); + + timer?.unref?.(); + + const handler = tool[2]; + const cfg = (tool[1] || {}) as Record; + const schema = cfg.inputSchema; + + try { + // Child-side validation using in-memory Zod schema + let updatedRequestArgs = request.args; + // const zodSchema: any = tool?.[1]?.inputSchema; + + if (isZodSchema(schema)) { + const parsed = await (schema as any).safeParseAsync(updatedRequestArgs); + + if (!parsed.success) { + const details = parsed.error?.flatten?.() ?? String(parsed.error); + + const err: SerializedError = new Error('Invalid arguments', { cause: { details } }); + + err.code = 'INVALID_ARGS'; + + throw err; + } + + updatedRequestArgs = parsed.data; + } + + // Invoke the tool + const result = await Promise.resolve(handler(updatedRequestArgs)); + + // Some handlers may mistakenly return an Error instance instead of throwing. Normalize it to a failure. + if (isErrorLike(result)) { + const err: SerializedError = new Error('Internal error', { cause: { details: result } }); + + err.code = 'INTERNAL_ERROR'; + + throw err; + } + + if (!settled) { + settled = true; + clearTimeout(timer); + process.send?.({ t: 'invoke:result', id: request.id, ok: true, result }); + } + } catch (error) { + if (!settled) { + settled = true; + clearTimeout(timer); + process.send?.({ + t: 'invoke:result', + id: request.id, + ok: false, + error: serializeError(error as Error) + }); + } + } +}; + +/** + * Handle shutdown requests. + * + * @param request + */ +const requestShutdown = (request: ShutdownRequest) => { + process.send?.({ t: 'shutdown:ack', id: request.id }); + process.exit(0); +}; + +/** + * Fallback handler for unhandled errors. + * + * @param {IpcRequest} request - Original IPC request object. + * @param {Error} error - Failed request error object + * + * Attempt to send a structured message back to the IPC channel. The message includes: + * - Type of response ('invoke:result'). + * - Request identifier, or 'n/a' if the request ID is unavailable. + * - Operation status (`ok: false`). + * - Serialized error object. + * + * Any issues during this process (e.g., if `process.send` is unavailable) fail silently. + */ +const requestFallback = (request: IpcRequest, error: Error) => { + try { + process.send?.({ + t: 'invoke:result', + id: request?.id || 'n/a', + ok: false, + error: serializeError(error) + }); + } catch {} +}; + +/** + * Initializes and sets up handlers for incoming IPC (Inter-Process Communication) messages. + * + * @returns Function to remove IPC message listeners. + */ +const setHandlers = () => { + let state: HostState = createHostState(); + + /** + * Load tools from the provided list of module specifiers. Splits out warnings/errors + * before updating state. + * + * @param {LoadRequest} request + */ + const onRequestLoad = async (request: LoadRequest) => { + const loaded = await performLoad(request); + + state = { + toolMap: loaded.toolMap, + descriptors: loaded.descriptors, + invokeTimeoutMs: loaded.invokeTimeoutMs + }; + + requestLoad(request, { warnings: loaded.warnings, errors: loaded.errors }); + }; + + /** + * Handle incoming IPC (Inter-Process Communication) messages. + * + * Process the request and execute the corresponding handler function for each type. A fallback handler + * is triggered on error. + * + * @param {IpcRequest} request - The IPC request object containing the type of request and associated data. + * @throws {Error} - Any error, pass the request through the fallback handler. + * + * @remarks + * Supported request types: + * - 'hello': Trigger the `requestHello` handler. + * - 'load': Trigger the `requestLoad` handler. + * - 'manifest:get': Trigger the `requestManifestGet` handler. + * - 'invoke': Trigger the asynchronous `requestInvoke` handler. + * - 'shutdown': Trigger the `requestShutdown` handler. + */ + const handlerMessage = async (request: IpcRequest) => { + try { + switch (request.t) { + case 'hello': + requestHello(request); + break; + + case 'load': + await onRequestLoad(request); + break; + + case 'manifest:get': + requestManifestGet(state, request); + break; + + case 'invoke': { + await requestInvoke(state, request); + break; + } + case 'shutdown': { + requestShutdown(request); + break; + } + } + } catch (error) { + requestFallback(request, error as Error); + } + }; + + /** + * Listen for incoming IPC messages. + */ + process.on('message', handlerMessage); + + /** + * Handle process disconnects. + */ + const handlerDisconnect = () => { + process.exit(0); + }; + + /** + * Handle process disconnects. + */ + process.on('disconnect', handlerDisconnect); + + // Expose the router for bootstrapping. + return handlerMessage; +}; + +/** + * Lazy initialize for IPC (Inter-Process Communication) handlers. + * + * This is a one-shot process: the first message received will remove itself then + * trigger the real handler setup. + * + * @param {IpcRequest} first + */ +const bootstrapMessage = (first: IpcRequest) => { + // Detach bootstrap to avoid duplicate delivery + process.off('message', bootstrapMessage); + + // Install real handlers and get a reference to the router + const route = setHandlers(); + + // Route the very first message through the same code path the real handler uses + // Use void to fire-and-forget async operations to avoid blocking + void route(first); +}; + +if (process.send) { + process.on('message', bootstrapMessage); +} + +export { + normalizeCreatorSchema, + performLoad, + requestHello, + requestLoad, + requestManifestGet, + requestInvoke, + requestShutdown, + requestFallback, + setHandlers +}; diff --git a/src/server.toolsHostCreator.ts b/src/server.toolsHostCreator.ts new file mode 100644 index 0000000..68d41e1 --- /dev/null +++ b/src/server.toolsHostCreator.ts @@ -0,0 +1,137 @@ +import { type McpTool, type McpToolCreator } from './server'; + +/** + * Guard for an array of creators. File-scoped helper. + * + * @param value + * @returns `true` if value is an array of functions. + */ +const isCreatorsArray = (value: unknown): value is McpToolCreator[] => + Array.isArray(value) && value.length > 0 && value.every(fn => typeof fn === 'function'); + +/** + * Guard for tool tuple. File-scoped helper. + * + * @param value + * @returns `true` if value is a tool tuple. + */ +const isRealizedToolTuple = (value: unknown): value is McpTool => + Array.isArray(value) && + value.length === 3 && + typeof value[0] === 'string' && + typeof (value as unknown[])[2] === 'function'; + +/** + * Wrap a realized tool tuple in a creator function that returns the tuple itself. + * File-scoped helper. + * + * @param cached + * @returns A normalized creator function that returns the cached tool tuple. + */ +const wrapCachedTuple = (cached: McpTool): McpToolCreator & { toolName: string } => { + const wrapped: McpToolCreator = () => cached; + + (wrapped as any).toolName = cached[0]; + + return wrapped as McpToolCreator & { toolName: string }; +}; + +/** + * Options for resolveExternalCreators. + */ +type ResolveOptions = { + throwOnEmpty?: boolean; +}; + +/** + * Minimally filter, resolve, then cache tool creators from external module export during the child process. + * + * - Probes function exports at most once with toolOptions and never re-probes without options. + * - Supported export shapes: + * - A `default export` that is a tool creator (function returning a tool tuple) -> wraps and caches as a creator (with .toolName) + * - A `default export` that is a function returning an array of tool creators (functions returning tool tuples) -> no unwrapping, returns them directly + * - A `default export` that is an array of tool creators (functions returning tool tuples) -> no unwrapping, returns them directly + * + * @example + * // A default export creator (function returning a tool tuple) + * export default () => ['toolName', { description: 'recommended', inputSchema: { ... } }, handler]; + * + * @example + * // A default export function returning an array of tool creators (functions returning tool tuples) + * const dolorSit = () => ['toolName1', { description: 'recommended', inputSchema: { ... } }, handler]; + * const ametConsectetur = () => ['toolName2', { description: 'recommended', inputSchema: { ... } }, handler]; + * + * export default () => [dolorSit, ametConsectetur]; + * + * @example + * // A default export array of tool creators (functions returning tool tuples) + * export default [ + * () => ['toolName1', { description: 'recommended', inputSchema: { ... } }, handler], + * () => ['toolName2', { description: 'recommended', inputSchema: { ... } }, handler] + * ]; + * + * @param moduleExports - The module exports object from the child process. + * @param toolOptions - Tool options to pass to tool creators. + * @param settings - Optional settings. + * @param settings.throwOnEmpty - Throw an error if no tool creators are found. Defaults to false. + */ +const resolveExternalCreators = ( + moduleExports: unknown, + toolOptions?: Record | undefined, + { throwOnEmpty = false }: ResolveOptions = {} +): McpToolCreator[] => { + const mod = moduleExports as any; + const candidates: unknown[] = [mod?.default, mod].filter(Boolean); + + const observed: string[] = []; + + for (const candidate of candidates) { + if (typeof candidate === 'function') { + observed.push('function'); + try { + const result = (candidate as (o?: unknown) => unknown)(toolOptions); + + if (isRealizedToolTuple(result)) { + return [wrapCachedTuple(result)]; + } + + if (isCreatorsArray(result)) { + observed.push('creators[]'); + + return result; + } + + observed.push(Array.isArray(result) ? 'array' : typeof result); + } catch { + // Move to next candidate + } + + continue; + } + + if (isCreatorsArray(candidate)) { + observed.push('creators[]'); + + return candidate as McpToolCreator[]; + } + + // Note shape for diagnostics if we end up throwing on empty + observed.push(Array.isArray(candidate) ? 'array' : typeof candidate); + } + + if (throwOnEmpty) { + const shapes = observed.length ? ` Observed candidate shapes: ${observed.join(', ')}` : ''; + + throw new Error([ + `No usable tool creators found from module. ${shapes}`, + 'Expected one of:', + '- default export: a tool creator (function that returns [name, { inputSchema, description? }, handler])', + '- default export: a function that returns an array of tool creators', + '- default export: an array of tool creators' + ].join('\n')); + } + + return []; +}; + +export { resolveExternalCreators, type ResolveOptions }; diff --git a/src/server.toolsIpc.ts b/src/server.toolsIpc.ts new file mode 100644 index 0000000..a337614 --- /dev/null +++ b/src/server.toolsIpc.ts @@ -0,0 +1,247 @@ +import { type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { type ToolOptions } from './options.tools'; + +/** + * IPC (Inter-Process Communication) request messages. + * + * - `hello` - Sent by the host to the process to acknowledge receipt. + * - `load` - Sent by the host to the process to load tools. + * - `manifest:get` - Sent by the host to the process to request a list of available tools. + * - `invoke` - Sent by the host to the process to invoke a tool. + * - `shutdown` - Sent by the host to the process to shutdown. + * + * @property t - Message type. + * @property id - Message identifier. + * @property specs - List of tool module specifiers to load. + * @property invokeTimeoutMs - Timeout for tool invocations. + * @property {ToolOptions} toolOptions - Options to pass to tool creators. + */ +type IpcRequest = + | { t: 'hello'; id: string } | + { t: 'load'; id: string; specs: string[]; invokeTimeoutMs?: number; toolOptions?: ToolOptions } | + { t: 'manifest:get'; id: string } | + { t: 'invoke'; id: string; toolId: string; args: unknown } | + { t: 'shutdown'; id: string }; + +/** + * Serialized error object for IPC. + * + * @property message - Error message. + * @property stack - Error stack trace. + * @property code - Error code. + * @property cause - Error cause. + * @property details - Additional details. + */ +type SerializedError = { message: string; stack?: string; code?: string; cause?: unknown; details?: unknown }; + +/** + * Tool descriptor object for IPC. + * + * @property id - Tool identifier. + * @property name - Tool name. + * @property description - Tool description. + * @property inputSchema - Tool input schema. + * @property source - Tool module specifier. + */ +type ToolDescriptor = { + id: string; + name: string; + description: string; + inputSchema: any; + source?: string; +}; + +/** + * Inter-Process Communication (IPC) responses. + * + * Types: + * - 'hello:ack': Acknowledgment message for a "hello" operation, including an identifier. + * - 'load:ack': Acknowledgment message for a "load" operation, including an identifier, + * and arrays of warnings and errors. + * - 'manifest:result': Message containing the result of a "manifest" operation, including an + * identifier and a list of tool descriptors. + * - 'invoke:result' (success case): Message containing the result of a successful "invoke" + * operation, including an identifier, a success flag, and the result. + * - 'invoke:result' (failure case): Message containing the result of a failed "invoke" + * operation, including an identifier, a failure flag, and an error descriptor. + * - 'shutdown:ack': Acknowledgment message for a "shutdown" operation, including an identifier. + * + * @property t - Message type. + * @property id - Message identifier. + * @property warnings - List of warnings generated during tool loading. + * @property errors - List of errors generated during tool loading. + * @property {ToolDescriptor[]} tools - List of available tools. + * @property ok - Success flag. + * @property result - Result of the operation. + * @property {SerializedError} error - Error descriptor. + */ +type IpcResponse = + | { t: 'hello:ack'; id: string } | + { t: 'load:ack'; id: string; warnings: string[]; errors: string[] } | + { t: 'manifest:result'; id: string; tools: ToolDescriptor[] } | + { t: 'invoke:result'; id: string; ok: true; result: unknown } | + { t: 'invoke:result'; id: string; ok: false; error: SerializedError } | + { t: 'shutdown:ack'; id: string }; + +/** + * Generate a unique ID for IPC messages. + */ +const makeId = () => randomUUID(); + +/** + * Send an IPC message to the provided process. + * + * @param processRef + * @param {IpcRequest} request + */ +const send = ( + processRef: NodeJS.Process | ChildProcess, + request: IpcRequest +): boolean => Boolean(processRef.send?.(request)); + +/** + * Await an IPC response from the provided process. + * + * @param processRef + * @param matcher + * @param timeoutMs + */ +const awaitIpc = ( + processRef: NodeJS.Process | ChildProcess, + matcher: (message: any) => message is T, + timeoutMs: number +): Promise => new Promise((resolve, reject) => { + let settled = false; + + // Cleanup listeners and timers on exit or timeout + const cleanup = () => { + processRef.off('message', onMessage); + processRef.off('exit', onExit); + processRef.off('disconnect', onExit); + clearTimeout(timerId); + }; + + // Listen for messages and resolve on match or timeout + const onMessage = (message: any) => { + if (settled) { + return; + } + + if (matcher(message)) { + settled = true; + cleanup(); + resolve(message); + } + }; + + // Reject on exit or timeout + const onExit = (code?: number, signal?: string) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(new Error(`Tools Host exited before response (code=${code}, signal=${signal || 'none'})`)); + }; + + // Set a timeout to reject if the process doesn't respond' + const timerId = setTimeout(() => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(new Error('Timed out waiting for IPC response')); + }, timeoutMs); + + timerId?.unref?.(); + + // Attach listeners to the process + processRef.on('message', onMessage); + processRef.on('exit', onExit); + processRef.on('disconnect', onExit); +}); + +/** + * Check if a message is a "hello" response. IPC message type guards. + * + * @param message + */ +const isHelloAck = (message: any): message is { t: 'hello:ack'; id: string } => { + if (!message || message.t !== 'hello:ack') { + return false; + } + + return typeof message.id === 'string'; +}; + +/** + * Check if a message is a "load" response. IPC message type guards. + * + * Checks + * - If a given message is a valid load acknowledgment (`load:ack`) with expected id + * - That the message contains the proper structure, including the required fields and + * correct types for `warnings` and `errors`. + * + * @param expectedId - Expected identifier to match against the message `id` field. + * @returns Function that takes a message and determines if it conforms to the expected structure and values. + */ +const isLoadAck = (expectedId: string) => (message: any): message is { + t: 'load:ack'; id: string; warnings: string[]; errors: string[] +} => { + if (!message || message.t !== 'load:ack' || message.id !== expectedId) { + return false; + } + + const hasWarnings = Array.isArray(message.warnings); + const hasErrors = Array.isArray(message.errors); + + return hasWarnings && hasErrors; +}; + +/** + * Check if a message is a "manifest" response. IPC message type guards. + * + * @param expectedId + */ +const isManifestResult = (expectedId: string) => (message: any): message is { + t: 'manifest:result'; id: string; tools: ToolDescriptor[] +} => { + if (!message || message.t !== 'manifest:result' || message.id !== expectedId) { + return false; + } + + return Array.isArray(message.tools); +}; + +/** + * Check if a message is an "invoke" response. IPC message type guards. + * + * @param expectedId + */ +const isInvokeResult = (expectedId: string) => (message: any): message is + { t: 'invoke:result'; id: string; ok: true; result: unknown } | + { t: 'invoke:result'; id: string; ok: false; error: SerializedError } => { + if (!message || message.t !== 'invoke:result') { + return false; + } + + return message.id === expectedId; +}; + +export { + send, + awaitIpc, + makeId, + isHelloAck, + isLoadAck, + isManifestResult, + isInvokeResult, + type IpcRequest, + type IpcResponse, + type ToolDescriptor, + type SerializedError +};