From dea16fe5cbe830453ae467f0ed9532e0c9c4e1c8 Mon Sep 17 00:00:00 2001 From: David Wood Date: Tue, 28 Oct 2025 16:49:23 +0000 Subject: [PATCH 1/4] feat: support providing redis client or string --- package-lock.json | 11 +-------- src/index.ts | 17 +++++++++++--- src/types.ts | 8 ++----- test/redis-integration.test.ts | 43 ++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11713f7..4810d01 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-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "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" }, @@ -3866,7 +3862,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", @@ -6836,7 +6831,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6850,7 +6844,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8158,7 +8151,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8623,7 +8615,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..d3fa2b4 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,14 @@ 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 + return 'connect' in value && typeof (value as any).connect === 'function' +} + const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOptions) { const serverInfo: Implementation = opts.serverInfo ?? { name: '@platformatic/mcp', @@ -57,8 +65,11 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption let redis: Redis | null = null 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 + } sessionStore = new RedisSessionStore({ redis, maxMessages: 100 }) messageBroker = new RedisMessageBroker(redis) } else { 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..546827a 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,48 @@ describe('Redis Integration Tests', () => { assert.ok(app.mcpSendToSession) }) + testWithRedis('should initialize plugin with a Redis client', async (redis, t) => { + const app = fastify() + t.after(() => app.close()) + + const client = new Redis({ + host: redis.options.host!, + port: redis.options.port!, + db: redis.options.db! + }) + + 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()) From 2d5a155019050ea0ac400da45bb5ec342f8b4182 Mon Sep 17 00:00:00 2001 From: David Wood Date: Tue, 28 Oct 2025 16:57:34 +0000 Subject: [PATCH 2/4] refactor: use Partial rather than any --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d3fa2b4..b3c7543 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ function isIoRedis (value: unknown): value is Redis { // 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 - return 'connect' in value && typeof (value as any).connect === 'function' + return typeof (value as Partial).connect === 'function' } const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOptions) { From a218d382cbcc4a5f2082053538b3083ef8f578e3 Mon Sep 17 00:00:00 2001 From: David Wood Date: Tue, 28 Oct 2025 17:13:23 +0000 Subject: [PATCH 3/4] fix: only quit if redis is internally managed --- src/index.ts | 4 +++- test/redis-integration.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index b3c7543..20c75c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,12 +63,14 @@ 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) { 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) @@ -155,7 +157,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/test/redis-integration.test.ts b/test/redis-integration.test.ts index 546827a..405a15d 100644 --- a/test/redis-integration.test.ts +++ b/test/redis-integration.test.ts @@ -30,7 +30,6 @@ describe('Redis Integration Tests', () => { testWithRedis('should initialize plugin with a Redis client', async (redis, t) => { const app = fastify() - t.after(() => app.close()) const client = new Redis({ host: redis.options.host!, @@ -38,6 +37,11 @@ describe('Redis Integration Tests', () => { db: redis.options.db! }) + t.after(() => { + app.close() + client.quit() + }) + await app.register(mcpPlugin, { enableSSE: true, redis: client From b9918db492e2ce4b0a84a298e93a96e5a62cea76 Mon Sep 17 00:00:00 2001 From: David Wood Date: Wed, 29 Oct 2025 08:51:04 +0000 Subject: [PATCH 4/4] refactor: check more on redis client, and correctly await test teardown; --- src/index.ts | 8 +++++++- test/redis-integration.test.ts | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 20c75c6..b163dc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,13 @@ function isIoRedis (value: unknown): value is Redis { // 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 - return typeof (value as Partial).connect === 'function' + 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) { diff --git a/test/redis-integration.test.ts b/test/redis-integration.test.ts index 405a15d..8e606a0 100644 --- a/test/redis-integration.test.ts +++ b/test/redis-integration.test.ts @@ -37,9 +37,9 @@ describe('Redis Integration Tests', () => { db: redis.options.db! }) - t.after(() => { - app.close() - client.quit() + t.after(async () => { + await app.close() + await client.quit() }) await app.register(mcpPlugin, {