File size: 3,591 Bytes
f0743f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import type Keyv from 'keyv';
import { fromPairs } from 'lodash';
import { standardCache, keyvRedisClient } from '~/cache';
import { ParsedServerConfig } from '~/mcp/types';
import { BaseRegistryCache } from './BaseRegistryCache';

/**
 * Redis-backed implementation of MCP server configurations cache for distributed deployments.
 * Stores server configs in Redis with namespace isolation by owner (App, User, or specific user ID).
 * Enables data sharing across multiple server instances in a cluster environment.
 * Supports optional leader-only write operations to prevent race conditions during initialization.
 * Data persists across server restarts and is accessible from any instance in the cluster.
 */
export class ServerConfigsCacheRedis extends BaseRegistryCache {
  protected readonly cache: Keyv;
  private readonly owner: string;
  private readonly leaderOnly: boolean;

  constructor(owner: string, leaderOnly: boolean) {
    super();
    this.owner = owner;
    this.leaderOnly = leaderOnly;
    this.cache = standardCache(`${this.PREFIX}::Servers::${owner}`);
  }

  public async add(serverName: string, config: ParsedServerConfig): Promise<void> {
    if (this.leaderOnly) await this.leaderCheck(`add ${this.owner} MCP servers`);
    const exists = await this.cache.has(serverName);
    if (exists)
      throw new Error(
        `Server "${serverName}" already exists in cache. Use update() to modify existing configs.`,
      );
    const success = await this.cache.set(serverName, config);
    this.successCheck(`add ${this.owner} server "${serverName}"`, success);
  }

  public async update(serverName: string, config: ParsedServerConfig): Promise<void> {
    if (this.leaderOnly) await this.leaderCheck(`update ${this.owner} MCP servers`);
    const exists = await this.cache.has(serverName);
    if (!exists)
      throw new Error(
        `Server "${serverName}" does not exist in cache. Use add() to create new configs.`,
      );
    const success = await this.cache.set(serverName, config);
    this.successCheck(`update ${this.owner} server "${serverName}"`, success);
  }

  public async remove(serverName: string): Promise<void> {
    if (this.leaderOnly) await this.leaderCheck(`remove ${this.owner} MCP servers`);
    const success = await this.cache.delete(serverName);
    this.successCheck(`remove ${this.owner} server "${serverName}"`, success);
  }

  public async get(serverName: string): Promise<ParsedServerConfig | undefined> {
    return this.cache.get(serverName);
  }

  public async getAll(): Promise<Record<string, ParsedServerConfig>> {
    // Use Redis SCAN iterator directly (non-blocking, production-ready)
    // Note: Keyv uses a single colon ':' between namespace and key, even if GLOBAL_PREFIX_SEPARATOR is '::'
    const pattern = `*${this.cache.namespace}:*`;
    const entries: Array<[string, ParsedServerConfig]> = [];

    // Use scanIterator from Redis client
    if (keyvRedisClient && 'scanIterator' in keyvRedisClient) {
      for await (const key of keyvRedisClient.scanIterator({ MATCH: pattern })) {
        // Extract the actual key name (last part after final colon)
        // Full key format: "prefix::namespace:keyName"
        const lastColonIndex = key.lastIndexOf(':');
        const keyName = key.substring(lastColonIndex + 1);
        const value = await this.cache.get(keyName);
        if (value) {
          entries.push([keyName, value as ParsedServerConfig]);
        }
      }
    } else {
      throw new Error('Redis client with scanIterator not available.');
    }

    return fromPairs(entries);
  }
}