diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index eb4596d..0cd3b19 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -59,6 +59,12 @@ exports[`options defaults should return specific properties: defaults 1`] = ` --- ", + "stats": { + "reportIntervalMs": { + "health": 30000, + "transport": 10000, + }, + }, "toolMemoOptions": { "fetchDocs": { "cacheErrors": false, diff --git a/src/__tests__/__snapshots__/options.test.ts.snap b/src/__tests__/__snapshots__/options.test.ts.snap index 6e56c0e..6f80c14 100644 --- a/src/__tests__/__snapshots__/options.test.ts.snap +++ b/src/__tests__/__snapshots__/options.test.ts.snap @@ -102,7 +102,9 @@ exports[`parseCliOptions should attempt to parse args with --http and --port 1`] exports[`parseCliOptions should attempt to parse args with --http and invalid --port 1`] = ` { "docsHost": false, - "http": {}, + "http": { + "port": 0, + }, "isHttp": true, "logging": { "level": "info", diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index 4fc88d5..da84fa2 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -6,6 +6,9 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -38,6 +41,9 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -70,6 +76,9 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -107,6 +116,9 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -139,6 +151,9 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -176,6 +191,9 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -224,6 +242,9 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -282,6 +303,9 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -319,6 +343,9 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], @@ -369,6 +396,9 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Server logging enabled.", ], + [ + "Server stats enabled.", + ], [ "No external tools loaded.", ], diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index a630608..3bcaf16 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -55,6 +55,7 @@ describe('main', () => { const mockServerInstance = { stop: jest.fn().mockResolvedValue(undefined), isRunning: jest.fn().mockReturnValue(true), + getStats: jest.fn().mockReturnValue({}), onLog: jest.fn() }; diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index a232c8b..1720b08 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -1,4 +1,13 @@ -import { freezeObject, generateHash, hashCode, isPlainObject, isPromise, isReferenceLike, mergeObjects } from '../server.helpers'; +import { + freezeObject, + generateHash, + hashCode, + isPlainObject, + isPromise, + isReferenceLike, + mergeObjects, + portValid +} from '../server.helpers'; describe('freezeObject', () => { it.each([ @@ -598,3 +607,70 @@ describe('mergeObjects', () => { expect((Object.prototype as any).polluted).toBeUndefined(); }); }); + +describe('portValid', () => { + it.each([ + { + description: 'valid', + port: 8080, + expected: 8080 + }, + { + description: 'zero', + port: 0, + expected: 0 + }, + { + description: 'upper-range', + port: 65535, + expected: 65535 + }, + { + description: 'out-of-range', + port: 10_0000, + expected: undefined + }, + { + description: 'out-of-range negative', + port: -10_0000, + expected: undefined + }, + { + description: 'string', + port: '9000', + expected: 9000 + }, + { + description: 'empty string', + port: '', + expected: undefined + }, + { + description: 'NaN', + port: NaN, + expected: undefined + }, + { + description: 'float', + port: 1.088, + expected: undefined + }, + { + description: 'out-of-range float', + port: -1.088, + expected: undefined + }, + { + description: 'undefined', + port: undefined, + expected: undefined + }, + { + description: 'null', + port: null, + expected: undefined + } + ])('should validate a port, $description', ({ port, expected }) => { + expect(portValid(port)).toBe(expected); + }); +}); diff --git a/src/__tests__/server.stats.test.ts b/src/__tests__/server.stats.test.ts new file mode 100644 index 0000000..7470264 --- /dev/null +++ b/src/__tests__/server.stats.test.ts @@ -0,0 +1,94 @@ +import diagnostics_channel from 'node:diagnostics_channel'; +import { healthReport, statsReport, transportReport, createServerStats } from '../server.stats'; +import { getStatsOptions } from '../options.context'; + +describe('healthReport', () => { + const statsOptions = getStatsOptions(); + + it('should generate a health report', () => { + const type = 'health'; + const channelName = statsOptions.channels[type]; + const channel = diagnostics_channel.channel(channelName); + const handler = jest.fn(); + + channel.subscribe(handler); + + const report = healthReport(statsOptions); + + expect(Object.keys(handler.mock.calls[0][0])).toEqual(expect.arrayContaining(['timestamp', 'type', 'memory', 'uptime'])); + + clearTimeout(report); + }); +}); + +describe('statsReport', () => { + const statsOptions = getStatsOptions(); + + it.each([ + { description: 'stdio', httpPort: undefined }, + { description: 'http', httpPort: 3030 } + ])('should generate a stats report, $description', ({ httpPort }) => { + const report = statsReport({ httpPort }, statsOptions); + + expect(Object.keys(report)).toEqual(expect.arrayContaining(['timestamp', 'reports'])); + expect(Object.keys(report.reports.transport).includes('port')).toBe(httpPort !== undefined); + + expect(report.reports.transport.channelId).toBe(statsOptions.channels.transport); + expect(report.reports.health.channelId).toBe(statsOptions.channels.health); + expect(report.reports.traffic.channelId).toBe(statsOptions.channels.traffic); + }); +}); + +describe('transportReport', () => { + const statsOptions = getStatsOptions(); + + it('should generate a transport report', () => { + const type = 'transport'; + const channelName = statsOptions.channels[type]; + const channel = diagnostics_channel.channel(channelName); + const handler = jest.fn(); + + channel.subscribe(handler); + + const report = transportReport({ httpPort: 9999 }, statsOptions); + + expect(Object.keys(handler.mock.calls[0][0])).toEqual(expect.arrayContaining(['timestamp', 'type', 'method', 'port'])); + + clearTimeout(report); + }); +}); + +describe('createServerStats', () => { + const statsOptions = getStatsOptions(); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should resolve stats promise after setStats is called', async () => { + const tracker = createServerStats(statsOptions, { isHttp: true } as any); + const httpHandle = { port: 9999, close: jest.fn() }; + + tracker.setStats(httpHandle as any); + + const stats = await tracker.getStats(); + + expect(stats.reports.transport.port).toBe(9999); + expect(stats.reports.transport.method).toBe('http'); + + tracker.unsubscribe(); + }); + + it('should correctly clean up timers on unsubscribe', () => { + const tracker = createServerStats(); + const spy = jest.spyOn(global, 'clearTimeout'); + + tracker.unsubscribe(); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 0856812..1437f2f 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -47,6 +47,7 @@ describe('runServer', () => { // Mock HTTP transport mockClose = jest.fn().mockResolvedValue(undefined); mockHttpHandle = { + port: 0, close: mockClose }; diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts new file mode 100644 index 0000000..0ab410b --- /dev/null +++ b/src/__tests__/stats.test.ts @@ -0,0 +1,126 @@ +import diagnostics_channel from 'node:diagnostics_channel'; +import { getStatsOptions } from '../options.context'; +import { publish, stat, timedReport, type StatReportType } from '../stats'; + +describe('publish', () => { + const statsOptions = getStatsOptions(); + + it.each([ + { + description: 'health channel', + type: 'health', + data: { memory: 1024 } + }, + { + description: 'traffic channel', + type: 'traffic', + data: { tool: 'test-tool', duration: 100 } + }, + { + description: 'transport channel', + type: 'transport', + data: { method: 'http', port: 8080 } + } + ])('should publish to the correct channel when subscribers exist: $description', ({ type, data }) => { + const channelName = statsOptions.channels[type as StatReportType]; + const channel = diagnostics_channel.channel(channelName); + const handler = jest.fn(); + + channel.subscribe(handler); + publish(type as any, data); + + expect(handler.mock.calls[0][0]).toEqual(expect.objectContaining(data)); + + channel.unsubscribe(handler); + }); + + it('should not throw if no subscribers exist', () => { + expect(() => publish('health', { foo: 'bar' })).not.toThrow(); + }); +}); + +describe('timedReport', () => { + const statsOptions = getStatsOptions(); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should calculate duration correctly', async () => { + const duration = 11; + const type = 'traffic'; + const channelName = statsOptions.channels[type]; + const channel = diagnostics_channel.channel(channelName); + const handler = jest.fn(); + + channel.subscribe(handler); + + const tracker = timedReport(type); + + tracker.start(); + + jest.advanceTimersByTime(duration); + + tracker.report({ tool: 'delayed-tool' }); + + expect(handler.mock.calls[0][0]).toEqual(expect.objectContaining({ + tool: 'delayed-tool', + duration + })); + + channel.unsubscribe(handler); + }); + + it('should allow overriding start time via data', () => { + const duration = 11; + const type = 'traffic'; + const channelName = statsOptions.channels[type]; + const channel = diagnostics_channel.channel(channelName); + const handler = jest.fn(); + + channel.subscribe(handler); + + const tracker = timedReport(type); + + tracker.start(); + + jest.advanceTimersByTime(duration); + + tracker.report({ tool: 'delayed-tool', start: Date.now() }); + + expect(handler.mock.calls[0][0]).toEqual(expect.objectContaining({ + tool: 'delayed-tool', + duration: 0 + })); + + channel.unsubscribe(handler); + }); +}); + +describe('stat', () => { + const statsOptions = getStatsOptions(); + + it('should provide a console-like method for traffic', () => { + const channelName = statsOptions.channels.traffic; + const channel = diagnostics_channel.channel(channelName); + const handler = jest.fn(); + + channel.subscribe(handler); + + const report = stat.traffic(); + + report({ tool: 'console-tool' }); + + expect(handler.mock.calls[0][0]).toEqual(expect.objectContaining({ + type: 'traffic', + tool: 'console-tool', + duration: 0 + })); + + channel.unsubscribe(handler); + }); +}); diff --git a/src/index.ts b/src/index.ts index 377711e..4f907b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,9 @@ import { type ServerSettings, type ServerOnLog, type ServerOnLogHandler, - type ServerLogEvent + type ServerLogEvent, + type ServerStats, + type ServerGetStats } from './server'; import { createMcpTool, @@ -70,6 +72,20 @@ type PfMcpOnLogHandler = ServerOnLogHandler; */ type PfMcpLogEvent = ServerLogEvent; +/** + * Get statistics about the server. + * + * @alias ServerGetStats + */ +type PfMcpGetStats = ServerGetStats; + +/** + * Statistics about the server. + * + * @alias ServerStats + */ +type PfMcpStats = ServerStats; + /** * Main function - Programmatic and CLI entry point with optional overrides * @@ -98,6 +114,21 @@ type PfMcpLogEvent = ServerLogEvent; * stop(); * } * + * @example Programmatic: Listening for server stats + * import { subscribe, unsubscribe } from 'node:diagnostics_channel'; + * import { start, createMcpTool } from '@patternfly/patternfly-mcp'; + * + * const { stop, isRunning, getStats } = await start(); + * const stats = await getStats(); + * const statsChannel = subscribe(stats.health.channelId, (healthStats: PfMcpHealthStats) => { + * stderr.write(`Health uptime: ${healthStats.uptime}\n`); + * }) + * + * if (isRunning()) { + * unsubscribe(stats.health.channelId); + * stop(); + * } + * * @example Programmatic: A MCP server with inline tool configuration. * import { start, createMcpTool } from '@patternfly/patternfly-mcp'; * @@ -156,6 +187,8 @@ export { type PfMcpLogEvent, type PfMcpOnLog, type PfMcpOnLogHandler, + type PfMcpStats, + type PfMcpGetStats, type ToolCreator, type ToolModule, type ToolConfig, diff --git a/src/options.context.ts b/src/options.context.ts index 1a64cf9..8fe633c 100644 --- a/src/options.context.ts +++ b/src/options.context.ts @@ -1,8 +1,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; import { type AppSession, type GlobalOptions, type DefaultOptionsOverrides } from './options'; -import { DEFAULT_OPTIONS, LOG_BASENAME, type LoggingSession } from './options.defaults'; -import { mergeObjects, freezeObject, isPlainObject } from './server.helpers'; +import { DEFAULT_OPTIONS, LOG_BASENAME, type LoggingSession, type StatsSession } from './options.defaults'; +import { mergeObjects, freezeObject, isPlainObject, hashCode } from './server.helpers'; /** * AsyncLocalStorage instance for a per-instance session state. @@ -12,6 +12,14 @@ import { mergeObjects, freezeObject, isPlainObject } from './server.helpers'; */ const sessionContext = new AsyncLocalStorage(); +/** + * Generates a consistent, one-way hash of the sessionId for public exposure. + * + * @param sessionId + */ +const getPublicSessionHash = (sessionId: string): string => + hashCode(sessionId, { algorithm: 'sha256', encoding: 'hex' }).substring(0, 12); + /** * Initialize and return session data. * @@ -20,8 +28,9 @@ const sessionContext = new AsyncLocalStorage(); const initializeSession = (): AppSession => { const sessionId = (process.env.NODE_ENV === 'local' && '1234d567-1ce9-123d-1413-a1234e56c789') || randomUUID(); const channelName = `${LOG_BASENAME}:${sessionId}`; + const publicSessionId = getPublicSessionHash(sessionId); - return freezeObject({ sessionId, channelName }); + return freezeObject({ sessionId, channelName, publicSessionId }); }; /** @@ -121,6 +130,24 @@ const getLoggerOptions = (session = getSessionOptions()): LoggingSession => { return { ...base, channelName: session.channelName }; }; +/** + * Get stat channel options from the current context. + * + * @param {AppSession} [options] - Session options to use in context. + * @returns {StatsSession} Stats options from context. + */ +const getStatsOptions = (options = getSessionOptions()): StatsSession => { + const base = getOptions().stats; + const publicSessionId = options.publicSessionId; + const health = `pf-mcp:stats:health:${publicSessionId}`; + const session = `pf-mcp:stats:session:${publicSessionId}`; + const transport = `pf-mcp:stats:transport:${publicSessionId}`; + const traffic = `pf-mcp:stats:traffic:${publicSessionId}`; + const channels = { health, transport, traffic, session }; + + return { ...base, publicSessionId, channels }; +}; + /** * Run a function with specific options context. Useful for testing or programmatic usage. * @@ -145,7 +172,9 @@ const runWithOptions = async ( export { getLoggerOptions, getOptions, + getPublicSessionHash, getSessionOptions, + getStatsOptions, initializeSession, optionsContext, runWithOptions, diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 43d0c5f..fb84e90 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -32,10 +32,11 @@ import { type ToolModule } from './server.toolsUser'; * @property pfExternalDesignLayouts - PatternFly design guidelines' layouts' URL. * @property pfExternalAccessibility - PatternFly accessibility URL. * @property {typeof RESOURCE_MEMO_OPTIONS} resourceMemoOptions - Resource-level memoization options. + * @property separator - Default string delimiter. + * @property {StatsOptions} stats - Stats options. * @property {typeof TOOL_MEMO_OPTIONS} toolMemoOptions - Tool-specific memoization options. * @property {ToolModule|ToolModule[]} toolModules - Array of external tool modules (ESM specs or paths) to be loaded and * registered with the server. - * @property separator - Default string delimiter. * @property urlRegex - Regular expression pattern for URL matching. * @property version - Version of the package. */ @@ -64,6 +65,7 @@ interface DefaultOptions { repoName: string | undefined; resourceMemoOptions: Partial; separator: string; + stats: StatsOptions; toolMemoOptions: Partial; toolModules: ToolModule | ToolModule[]; urlRegex: RegExp; @@ -148,6 +150,32 @@ interface LoggingSession extends LoggingOptions { readonly channelName: string; } +type StatsOptions = { + reportIntervalMs: { + health: number; + transport: number; + } +}; + +type StatsChannels = { + readonly health: string; + readonly session: string; + readonly transport: string; + readonly traffic: string; +}; + +/** + * Stats session options, non-configurable by the user. + * + * @interface StatsSession + * @property publicSessionId Unique identifier for the stats session. + * @property channels Channel names for stats. + */ +interface StatsSession extends StatsOptions { + readonly publicSessionId: string; + channels: StatsChannels +} + /** * Base logging options. */ @@ -218,6 +246,16 @@ const TOOL_MEMO_OPTIONS = { } }; +/** + * Stats options. + */ +const STATS_OPTIONS: StatsOptions = { + reportIntervalMs: { + health: 30_000, + transport: 10_000 + } +}; + /** * Base logging channel name. Fixed to avoid user override. */ @@ -336,6 +374,7 @@ const DEFAULT_OPTIONS: DefaultOptions = { pfExternalAccessibility: PF_EXTERNAL_ACCESSIBILITY, resourceMemoOptions: RESOURCE_MEMO_OPTIONS, repoName: basename(process.cwd() || '').trim(), + stats: STATS_OPTIONS, toolMemoOptions: TOOL_MEMO_OPTIONS, toolModules: [], separator: DEFAULT_SEPARATOR, @@ -364,5 +403,6 @@ export { type HttpOptions, type LoggingOptions, type LoggingSession, - type PluginHostOptions + type PluginHostOptions, + type StatsSession }; diff --git a/src/options.ts b/src/options.ts index eb5b4e6..d8ecc64 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,11 +1,13 @@ import { DEFAULT_OPTIONS, type DefaultOptions, type DefaultOptionsOverrides, type LoggingOptions, type HttpOptions } from './options.defaults'; import { type LogLevel, logSeverity } from './logger'; +import { portValid } from './server.helpers'; /** * Session defaults, not user-configurable */ type AppSession = { readonly sessionId: string; + readonly publicSessionId: string; readonly channelName: string }; @@ -110,7 +112,7 @@ const parseCliOptions = (argv: string[] = process.argv): CliOptions => { if (isHttp) { const rawPort = getArgValue('--port', { argv }); - const parsedPort = Number.parseInt(String(rawPort || ''), 10); + const parsedPort = portValid(rawPort); const host = getArgValue('--host', { argv }); const allowedOrigins = (getArgValue('--allowed-origins', { argv }) as string) @@ -123,8 +125,7 @@ const parseCliOptions = (argv: string[] = process.argv): CliOptions => { ?.map((host: string) => host.trim()) ?.filter(Boolean); - const isPortValid = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort < 65536; - const port = isPortValid ? parsedPort : undefined; + const port = parsedPort; if (port !== undefined) { http.port = port; diff --git a/src/server.helpers.ts b/src/server.helpers.ts index fb46114..640c79a 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -1,5 +1,23 @@ import { createHash, type BinaryToTextEncoding } from 'node:crypto'; +/** + * Check if a value is a valid port number. + * + * @param port - Port number to check. + * @returns Valid port number, or `undefined` if invalid. + */ +const portValid = (port: unknown) => { + const toStr = String(port); + const isFloatLike = toStr.includes('.'); + const parsedPort = Number.parseInt(toStr, 10); + + if (!isFloatLike && Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort < 65536) { + return parsedPort; + } + + return undefined; +}; + /** * Check if an object is an object * @@ -285,5 +303,6 @@ export { isPlainObject, isPromise, isReferenceLike, - mergeObjects + mergeObjects, + portValid }; diff --git a/src/server.http.ts b/src/server.http.ts index 33e3608..771d417 100644 --- a/src/server.http.ts +++ b/src/server.http.ts @@ -8,6 +8,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { portToPid } from 'pid-port'; import { getOptions } from './options.context'; import { log } from './logger'; +import { portValid } from './server.helpers'; /** * Fixed base path for MCP transport endpoints. @@ -28,7 +29,7 @@ const MCP_HOST = 'http://mcp.local'; * @returns Process info or undefined if port is free */ const getProcessOnPort = async (port: number) => { - if (!port) { + if (typeof portValid(port) !== 'number') { return undefined; } @@ -143,6 +144,7 @@ const handleStreamableHttpRequest = async ( */ type HttpServerHandle = { close: () => Promise; + port: number; }; /** @@ -155,7 +157,7 @@ type HttpServerHandle = { const startHttpTransport = async (mcpServer: McpServer, options = getOptions()): Promise => { const { name, http } = options; - if (!http?.port || !http?.host) { + if (typeof portValid(http?.port) !== 'number' || !http?.host) { throw new Error('Port and host options are required for HTTP transport'); } @@ -203,10 +205,16 @@ const startHttpTransport = async (mcpServer: McpServer, options = getOptions()): void handleStreamableHttpRequest(req, res, transport); }); + const getPort = () => { + const addr = server?.address?.(); + + return (typeof addr !== 'string' && addr?.port) || http.port; + }; + // Start the server. Port conflicts will be handled in the error handler below await new Promise((resolve, reject) => { server.listen(http.port, http.host, () => { - log.info(`${name} server running on http://${http.host}:${http.port}`); + log.info(`${name} server running on http://${http.host}:${getPort()}`); resolve(); }); @@ -230,6 +238,7 @@ const startHttpTransport = async (mcpServer: McpServer, options = getOptions()): }); return { + port: getPort(), close: async () => { // 1) Stop accepting new connections and finish requests quickly // If the transport exposes a close/shutdown, call it here (pseudo): diff --git a/src/server.stats.ts b/src/server.stats.ts new file mode 100644 index 0000000..c25bd9b --- /dev/null +++ b/src/server.stats.ts @@ -0,0 +1,160 @@ +import { + getOptions, + getStatsOptions +} from './options.context'; +import { type HttpServerHandle } from './server.http'; +import { publish, type StatReport } from './stats'; +import { type StatsSession } from './options.defaults'; + +/** + * Transport-specific telemetry report. + * + * @interface TransportReport + */ +interface TransportReport extends StatReport { + type: 'transport'; + method: 'stdio' | 'http'; + port?: number; +} + +/** + * Server stats. + * + * @interface Stats + * @property {string} timestamp - Timestamp of the server stats. + * @property reports - Object containing various server telemetry reports. + * @property {TransportReport} reports.transport - Transport-specific telemetry report. + * @property reports.health - Server health metrics (e.g., memory usage and uptime). + * @property reports.traffic - Event-driven traffic metric (e.g., tool/resource execution). + */ +interface Stats { + timestamp: string; + reports: { + transport: TransportReport & { channelId: string }; + health: { channelId: string }; + traffic: { channelId: string }; + }; +} + +/** + * Reports server health metrics (e.g., memory usage and uptime). + * + * @param statsOptions - Session-specific stats options. + * @returns {NodeJS.Timeout} Timer handle for the recurring health report. + */ +const healthReport = (statsOptions: StatsSession) => { + publish('health', { + memory: process.memoryUsage(), + uptime: process.uptime() + }); + + return setTimeout(() => { + healthReport(statsOptions); + }, statsOptions?.reportIntervalMs.health).unref(); +}; + +/** + * Creates a server stats report object. + * + * @param params - Report parameters. + * @param params.httpPort - HTTP server port if available. + * @param statsOptions - Session-specific stats options. + * @returns {Stats} - Server stats and channel IDs. + */ +const statsReport = ({ httpPort }: { httpPort?: number | undefined } = {}, statsOptions: StatsSession): Stats => ({ + timestamp: new Date().toISOString(), + reports: { + transport: { + type: 'transport', + timestamp: new Date().toISOString(), + method: httpPort ? 'http' : 'stdio', + ...(httpPort ? { port: httpPort } : {}), + channelId: statsOptions.channels.transport + }, + health: { channelId: statsOptions.channels.health }, + traffic: { channelId: statsOptions.channels.traffic } + } +}); + +/** + * Reports server transport metrics (e.g., HTTP server port). + * + * @param params - Report parameters. + * @param params.httpPort - HTTP server port if available. + * @param statsOptions - Session-specific stats options. + * @returns {NodeJS.Timeout} Timer handle for the recurring transport report. + */ +const transportReport = ({ httpPort }: { httpPort?: number | undefined } = {}, statsOptions: StatsSession) => { + publish('transport', { + method: httpPort ? 'http' : 'stdio', + port: httpPort + }); + + return setTimeout(() => { + transportReport({ httpPort }, statsOptions); + }, statsOptions?.reportIntervalMs.transport).unref(); +}; + +/** + * Creates a telemetry tracker for a server instance. + * + * - Starts the health report timer. + * + * @param {StatsSession} [statsOptions] - Session-specific stats options. + * @param {GlobalOptions} [options] - Global server options. + * @returns - An object with methods to manage server telemetry: + * - `getStats`: Resolve server stats and channel IDs. + * - `setStats`: Uses the HTTP server handle and starts the transport report timer. + * - `unsubscribe`: Cleans up timers and resources. + */ +const createServerStats = (statsOptions = getStatsOptions(), options = getOptions()) => { + // Start the health report + const healthTimer = healthReport(statsOptions); + let transportTimer: NodeJS.Timeout | undefined; + let resolveStatsPromise: (value: Stats) => void; + + const statsPromise: Promise = new Promise(resolve => { + resolveStatsPromise = resolve; + }); + + return { + + /** + * Returns the server stats and channel IDs. + * + * @returns {Promise} - Server stats and channel IDs. + */ + getStats: (): Promise => statsPromise, + + /** + * Uses the HTTP server handle and starts the transport report timer. + * + * @param {HttpServerHandle} [httpHandle] - Handle for the HTTP server if available. + */ + setStats: (httpHandle?: HttpServerHandle | null) => { + if (transportTimer) { + clearTimeout(transportTimer); + } + + const httpPort = options.isHttp ? httpHandle?.port : undefined; + const stats = statsReport({ httpPort }, statsOptions); + + transportTimer = transportReport({ httpPort }, statsOptions); + + resolveStatsPromise(stats); + }, + + /** + * Cleans up timers and resources. + */ + unsubscribe: () => { + if (transportTimer) { + clearTimeout(transportTimer); + } + + clearTimeout(healthTimer); + } + }; +}; + +export { createServerStats, healthReport, statsReport, transportReport, type Stats }; diff --git a/src/server.ts b/src/server.ts index ff5b05d..0b874e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,8 @@ import { import { DEFAULT_OPTIONS } from './options.defaults'; import { isZodRawShape, isZodSchema } from './server.schema'; import { isPlainObject } from './server.helpers'; +import { createServerStats, type Stats } from './server.stats'; +import { stat } from './stats'; /** * A tool registered with the MCP server. @@ -61,6 +63,18 @@ interface ServerSettings { allowProcessExit?: boolean; } +/** + * Server stats. + * + * @alias Stats + */ +type ServerStats = Stats; + +/** + * A callback to Promise return server stats. + */ +type ServerGetStats = () => Promise; + /** * Server log event. */ @@ -86,11 +100,13 @@ type ServerOnLog = (handler: ServerOnLogHandler) => () => void; * * @property stop - Stops the server, gracefully. * @property isRunning - Indicates whether the server is running. + * @property {ServerGetStats} getStats - Resolves server stats. * @property {ServerOnLog} onLog - Subscribes to server logs. Automatically unsubscribed on server shutdown. */ interface ServerInstance { stop(): Promise; isRunning(): boolean; + getStats: ServerGetStats; onLog: ServerOnLog; } @@ -116,7 +132,7 @@ const builtinTools: McpToolCreator[] = [ * @param [settings.tools] - Built-in tools to register. * @param [settings.enableSigint] - Indicates whether SIGINT signal handling is enabled. * @param [settings.allowProcessExit] - Determines if the process is allowed to exit explicitly, useful for testing. - * @returns Server instance with `stop()`, `isRunning()`, and `onLog()` subscription. + * @returns Server instance with `stop()`, `getStats()` `isRunning()`, and `onLog()` subscription. */ const runServer = async (options: ServerOptions = getOptions(), { tools = builtinTools, @@ -129,9 +145,11 @@ const runServer = async (options: ServerOptions = getOptions(), { let transport: StdioServerTransport | null = null; let httpHandle: HttpServerHandle | null = null; let unsubscribeServerLogger: (() => void) | null = null; + let unsubscribeServerStats: (() => void) | null = null; let sigintHandler: (() => void) | null = null; let running = false; let onLogSetup: ServerOnLog = () => () => {}; + let getStatsSetup: ServerGetStats = () => Promise.resolve({} as ServerStats); const stopServer = async () => { log.debug(`${options.name} attempting shutdown.`); @@ -158,6 +176,7 @@ const runServer = async (options: ServerOptions = getOptions(), { log.info(`${options.name} closed!\n`); unsubscribeServerLogger?.(); + unsubscribeServerStats?.(); if (allowProcessExit) { process.exit(0); @@ -182,7 +201,7 @@ const runServer = async (options: ServerOptions = getOptions(), { ); // Setup server logging. - const subUnsub = createServerLogger.memo(server); + const loggerSubUnsub = createServerLogger.memo(server); log.info(`Server logging enabled.`); @@ -194,11 +213,15 @@ const runServer = async (options: ServerOptions = getOptions(), { ); } + const statsTracker = createServerStats(); + + log.info(`Server stats enabled.`); + // Combine built-in tools with custom ones after logging is set up. const updatedTools = await composeTools(tools); - if (subUnsub) { - const { subscribe, unsubscribe } = subUnsub; + if (loggerSubUnsub) { + const { subscribe, unsubscribe } = loggerSubUnsub; // Track active logging subscriptions to clean up on stop() unsubscribeServerLogger = unsubscribe; @@ -207,6 +230,14 @@ const runServer = async (options: ServerOptions = getOptions(), { onLogSetup = (handler: ServerOnLogHandler) => subscribe(handler); } + if (statsTracker) { + // Track active stat subscriptions to clean up on stop() + unsubscribeServerStats = statsTracker.unsubscribe; + + // Setup server stats for external handlers + getStatsSetup = () => statsTracker.getStats(); + } + updatedTools.forEach(toolCreator => { const [name, schema, callback] = toolCreator(options); // Do NOT normalize schemas here. This is by design and is a fallback check for malformed schemas. @@ -236,6 +267,8 @@ const runServer = async (options: ServerOptions = getOptions(), { `isArgs = ${args !== undefined}`, `isRemainingArgs = ${_args?.length > 0}` ); + + const timedReport = stat.traffic(); const isContextLikeArgs = isContextLike(args); // Log potential Zod validation errors triggered by context fail. @@ -248,7 +281,11 @@ const runServer = async (options: ServerOptions = getOptions(), { ); } - return await callback(args); + const toolResult = await callback(args); + + timedReport({ tool: name }); + + return toolResult; }))); }); @@ -272,6 +309,7 @@ const runServer = async (options: ServerOptions = getOptions(), { log.info(`${options.name} server running on ${options.isHttp ? 'HTTP' : 'stdio'} transport`); running = true; + statsTracker.setStats(httpHandle); } catch (error) { log.error(`Error creating ${options.name} server:`, error); throw error; @@ -286,6 +324,10 @@ const runServer = async (options: ServerOptions = getOptions(), { return running; }, + async getStats(): Promise { + return await getStatsSetup(); + }, + onLog(handler: ServerOnLogHandler): () => void { // Simple one-off log event to notify the handler of the server startup. handler({ level: 'info', msg: `${options.name} running!`, transport: options.logging?.transport } as LogEvent); @@ -342,5 +384,7 @@ export { type ServerOnLog, type ServerOnLogHandler, type ServerOptions, - type ServerSettings + type ServerSettings, + type ServerStats, + type ServerGetStats }; diff --git a/src/stats.ts b/src/stats.ts new file mode 100644 index 0000000..c3e5014 --- /dev/null +++ b/src/stats.ts @@ -0,0 +1,93 @@ +import { channel } from 'node:diagnostics_channel'; +import { getStatsOptions } from './options.context'; +import { type StatsSession } from './options.defaults'; + +/** + * Valid report types for server statistics. + */ +type StatReportType = 'transport' | 'health' | 'traffic' | 'session'; + +/** + * Base interface for all telemetry reports. + */ +interface StatReport { + type: StatReportType; + timestamp: string; + [key: string]: unknown; +} + +/** + * Publishes a structured report to a faceted diagnostics channel if there is an active subscriber. + * + * @param type - The facet/type of the report (e.g., 'health'). + * @param data - Telemetry payload. + * @param {StatsSession} [options] - Session options. + */ +const publish = (type: StatReportType, data: Record, options: StatsSession = getStatsOptions()) => { + const channelName = options.channels[type]; + const setChannel = channel(channelName); + + if (setChannel.hasSubscribers) { + setChannel.publish({ + type, + timestamp: new Date().toISOString(), + ...data + }); + } +}; + +/** + * Creates a timed report that tracks the duration of an event without needing + * to manually track the start time. + * + * - You can override the start time by passing a `start` property in the report data. + * + * @param type - The facet/type of the timed report (e.g., 'traffic'). + * @param {StatsSession} [options] - Session options. + */ +const timedReport = (type: StatReportType, options: StatsSession = getStatsOptions()) => { + let start: number = 0; + + return { + start: () => start = Date.now(), + report: (data: Record) => { + const updatedStart = typeof data.start === 'number' ? data.start : start; + const duration = Date.now() - updatedStart; + const updatedData = { ...data, duration: duration > 0 ? duration : 0 }; + + publish(type, updatedData, options); + } + }; +}; + +/** + * Console-like API for publishing structured stats to the diagnostics channel. + * + * @property traffic Records an event-driven traffic metric (e.g., tool/resource execution). + */ +const stat = { + + /** + * Call the function to `start` a traffic report. + * + * - Call `traffic` to `start` the timed report. + * - Close the returned `report` by calling the returned callback with the traffic metrics. + * + * @returns Callback function to report traffic metrics. + */ + traffic: () => { + const { start, report: statReport } = timedReport('traffic'); + + start(); + + return statReport; + } +}; + +export { + publish, + stat, + timedReport, + type StatReport, + type StatReportType +}; diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index a79ce01..482ce48 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -271,6 +271,8 @@ exports[`Logging should allow setting logging options, default 1`] = `[]`; exports[`Logging should allow setting logging options, stderr 1`] = ` [ "[INFO]: Server logging enabled. +", + "[INFO]: Server stats enabled. ", "[INFO]: No external tools loaded. ", diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts index 2c58f19..4f14869 100644 --- a/tests/httpTransport.test.ts +++ b/tests/httpTransport.test.ts @@ -99,7 +99,7 @@ describe('PatternFly MCP, HTTP Transport', () => { }); it('should concatenate headers and separator with two remote files', async () => { - const CLIENT = await startServer({ http: { port: 5002 } }); + const CLIENT = await startServer({ http: { port: 0 } }); const req = { jsonrpc: '2.0', id: 1, @@ -136,7 +136,6 @@ describe('Inline tools over HTTP', () => { it.each([ { description: 'inline tool module', - port: 5011, toolName: 'inline_module', tool: createMcpTool({ name: 'inline_module', @@ -147,7 +146,6 @@ describe('Inline tools over HTTP', () => { }, { description: 'inline tool creator', - port: 5012, toolName: 'inline_creator', tool: (() => { const inlineCreator = (_options: any) => [ @@ -166,7 +164,6 @@ describe('Inline tools over HTTP', () => { }, { description: 'inline object', - port: 5013, toolName: 'inline_obj', tool: { name: 'inline_obj', @@ -177,7 +174,6 @@ describe('Inline tools over HTTP', () => { }, { description: 'inline tuple', - port: 5014, toolName: 'inline_tuple', tool: [ 'inline_tuple', @@ -188,10 +184,10 @@ describe('Inline tools over HTTP', () => { (args: any) => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }) ] } - ])('should register and invoke an inline tool module, $description', async ({ port, tool, toolName }) => { + ])('should register and invoke an inline tool module, $description', async ({ tool, toolName }) => { CLIENT = await startServer( { - http: { port }, + http: { port: 0 }, isHttp: true, logging: { level: 'info', protocol: true }, toolModules: [tool as any] diff --git a/tests/utils/fetchMock.ts b/tests/utils/fetchMock.ts index 5b87669..1e777d0 100644 --- a/tests/utils/fetchMock.ts +++ b/tests/utils/fetchMock.ts @@ -39,7 +39,7 @@ interface StartHttpFixtureOptions { const startHttpFixture = ( { routes = {}, address = '127.0.0.1', port = 0 }: StartHttpFixtureOptions = {}, regexRoutes: FetchRoute[] = [] -): Promise<{ baseUrl: string; close: () => Promise; addRoute?: (path: string, route: Route) => void }> => +): Promise<{ port: number; baseUrl: string; close: () => Promise; addRoute?: (path: string, route: Route) => void }> => new Promise((resolve, reject) => { const dynamicRoutes: Record = { ...routes }; @@ -117,6 +117,7 @@ const startHttpFixture = ( const baseUrl = `http://${host}:${addr.port}`; resolve({ + port: addr.port, baseUrl, close: () => new Promise(res => server.close(() => res())), addRoute: (path: string, route: Route) => { @@ -126,6 +127,7 @@ const startHttpFixture = ( } else { // Fallback if the address isn't available as AddressInfo resolve({ + port, baseUrl: `http://${address}`, close: () => new Promise(res => server.close(() => res())), addRoute: (path: string, route: Route) => { diff --git a/tests/utils/httpTransportClient.ts b/tests/utils/httpTransportClient.ts index ca688c7..9e29c18 100644 --- a/tests/utils/httpTransportClient.ts +++ b/tests/utils/httpTransportClient.ts @@ -67,7 +67,7 @@ export const startServer = async ( docsHost: false, ...options, http: { - port: 5000, + port: 8000, host: '127.0.0.1', allowedOrigins: [], allowedHosts: [], @@ -100,11 +100,14 @@ export const startServer = async ( throw new Error(`Server failed to start on port ${port}`); } + // const httpClientPort = server.port ?? updatedOptions?.http?.port; + const stats = await server.getStats(); + const httpClientPort = stats.reports.transport.port; let httpClientUrl: URL; try { - // Construct base URL from options - const baseUrl = `http://${host}:${port}/mcp`; + // Construct base URL from options, apply port from server stats + const baseUrl = `http://${host}:${httpClientPort}/mcp`; httpClientUrl = new URL(baseUrl); } catch (error) {