diff --git a/package-lock.json b/package-lock.json index 7b79c9f..4f78958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1764,8 +1764,7 @@ "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@stylistic/eslint-plugin": { "version": "2.11.0", @@ -1846,7 +1845,6 @@ "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1897,7 +1895,6 @@ "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", @@ -2405,7 +2402,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3865,7 +3861,6 @@ "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -6845,7 +6840,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6859,7 +6853,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8167,7 +8160,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8632,7 +8624,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/index.ts b/src/index.ts index 4aa9d5f..b163dc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' import fp from 'fastify-plugin' -import { Redis } from 'ioredis' +import { Redis, type RedisOptions } from 'ioredis' import type { SessionStore } from './stores/session-store.ts' import type { MessageBroker } from './brokers/message-broker.ts' import { MemorySessionStore } from './stores/memory-session-store.ts' @@ -34,6 +34,20 @@ import type { GetPromptResult } from './schema.ts' +function isIoRedis (value: unknown): value is Redis { + if (typeof value !== 'object' || value === null) return false + // can match if the same module is loaded + if (value instanceof Redis) return true + // otherwise treat as a duck type, which can be useful for mocking anyhow + const v = value as Partial + return ( + typeof v.connect === 'function' && + typeof v.quit === 'function' && + typeof v.hset === 'function' && + typeof v.get === 'function' + ) +} + const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOptions) { const serverInfo: Implementation = opts.serverInfo ?? { name: '@platformatic/mcp', @@ -55,10 +69,15 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption let sessionStore: SessionStore let messageBroker: MessageBroker let redis: Redis | null = null + let redisIsInternallyManaged = false if (opts.redis) { - // Redis implementations for horizontal scaling - redis = new Redis(opts.redis) + if (isIoRedis(opts.redis)) { + redis = opts.redis + } else { + redis = new Redis(opts.redis as RedisOptions) // or string, to overcome type narrowing + redisIsInternallyManaged = true + } sessionStore = new RedisSessionStore({ redis, maxMessages: 100 }) messageBroker = new RedisMessageBroker(redis) } else { @@ -144,7 +163,7 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption // Execute all unsubscribes in parallel await Promise.all(unsubscribePromises) - if (redis) { + if (redis && redisIsInternallyManaged) { await redis.quit() } await messageBroker.close() diff --git a/src/types.ts b/src/types.ts index 4ca6a9e..e96586c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ import type { RequestId } from './schema.ts' import type { Static, TSchema, TObject, TString } from '@sinclair/typebox' +import type { Redis, RedisOptions } from 'ioredis' import type { AuthorizationConfig, AuthorizationContext } from './types/auth-types.ts' // Context interface for all handler types @@ -138,12 +139,7 @@ export interface MCPPluginOptions { enableSSE?: boolean sessionStore?: 'memory' | 'redis' messageBroker?: 'memory' | 'redis' - redis?: { - host: string - port: number - password?: string - db?: number - } + redis?: Redis | RedisOptions | string authorization?: AuthorizationConfig } diff --git a/test/redis-integration.test.ts b/test/redis-integration.test.ts index 7390a0a..8e606a0 100644 --- a/test/redis-integration.test.ts +++ b/test/redis-integration.test.ts @@ -4,6 +4,7 @@ import fastify from 'fastify' import mcpPlugin from '../src/index.ts' import { testWithRedis } from './redis-test-utils.ts' import type { JSONRPCMessage } from '../src/schema.ts' +import { Redis } from 'ioredis' describe('Redis Integration Tests', () => { testWithRedis('should initialize plugin with Redis configuration', async (redis, t) => { @@ -27,6 +28,52 @@ describe('Redis Integration Tests', () => { assert.ok(app.mcpSendToSession) }) + testWithRedis('should initialize plugin with a Redis client', async (redis, t) => { + const app = fastify() + + const client = new Redis({ + host: redis.options.host!, + port: redis.options.port!, + db: redis.options.db! + }) + + t.after(async () => { + await app.close() + await client.quit() + }) + + await app.register(mcpPlugin, { + enableSSE: true, + redis: client + }) + + // Verify plugin is registered + assert.ok(app.mcpAddTool) + assert.ok(app.mcpAddResource) + assert.ok(app.mcpAddPrompt) + assert.ok(app.mcpBroadcastNotification) + assert.ok(app.mcpSendToSession) + }) + + testWithRedis('should initialize plugin with a Redis url', async (redis, t) => { + const app = fastify() + t.after(() => app.close()) + + const url = `redis://${redis.options.host}:${redis.options.port}/${redis.options.db}` + + await app.register(mcpPlugin, { + enableSSE: true, + redis: url + }) + + // Verify plugin is registered + assert.ok(app.mcpAddTool) + assert.ok(app.mcpAddResource) + assert.ok(app.mcpAddPrompt) + assert.ok(app.mcpBroadcastNotification) + assert.ok(app.mcpSendToSession) + }) + testWithRedis('should handle MCP requests with Redis backend', async (redis, t) => { const app = fastify() t.after(() => app.close())