Skip to main content
Glama
server-registry.ts4.74 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { MEDPLUM_VERSION } from '@medplum/core'; import { randomUUID } from 'node:crypto'; import { heartbeat } from './heartbeat'; import { getRedis } from './redis'; import { getServerVersion } from './util/version'; const SERVER_REGISTRY_KEY_PREFIX = 'medplum:server-registry:'; const SERVER_REGISTRY_TTL_SECONDS = 60; export type ServerRegistryInfo = { /* Unique identifier for a server instance */ id: string; /* Timestamp of the first heartbeat */ firstSeen: string; /* Timestamp of the last heartbeat */ lastSeen: string; /* Semver version of Medplum the server is running */ version: string; /* Full version (semver + build commit hash) of Medplum the server is running */ fullVersion: string; }; export async function setServerRegistryPayload(value: ServerRegistryInfo): Promise<void> { const redis = getRedis(); await redis.setex(SERVER_REGISTRY_KEY_PREFIX + value.id, SERVER_REGISTRY_TTL_SECONDS, JSON.stringify(value)); } let serverRegistryHeartbeatListener: (() => Promise<void>) | undefined; let registryPayload: ServerRegistryInfo | undefined; function getServerRegistryPayload(): ServerRegistryInfo { const now = new Date().toISOString(); registryPayload ??= { id: randomUUID(), firstSeen: now, lastSeen: now, version: getServerVersion(), fullVersion: MEDPLUM_VERSION, }; registryPayload.lastSeen = now; return registryPayload; } export function initServerRegistryHeartbeatListener(): void { if (serverRegistryHeartbeatListener) { return; } serverRegistryHeartbeatListener = async () => { const payload = getServerRegistryPayload(); await setServerRegistryPayload(payload); }; heartbeat.addEventListener('heartbeat', serverRegistryHeartbeatListener); } export function cleanupServerRegistryHeartbeatListener(): void { if (serverRegistryHeartbeatListener) { heartbeat.removeEventListener('heartbeat', serverRegistryHeartbeatListener); serverRegistryHeartbeatListener = undefined; } } export type ServerRegistryInfoWithComputed = ServerRegistryInfo & { firstSeenAgeMs: number; lastSeenAgeMs: number; }; export type ClusterStatus = { timestamp: string; totalServers: number; versions: Record<string, number>; oldestVersion: string | undefined; newestVersion: string | undefined; isHomogeneous: boolean; servers: ServerRegistryInfoWithComputed[]; }; /** * @param ensureSelf - If true, includes the current process in the list of registered servers, even * if it has not registered within the last minute. * @returns A list of registered servers. */ export async function getRegisteredServers(ensureSelf: boolean): Promise<ServerRegistryInfo[]> { const redis = getRedis(); const servers: ServerRegistryInfo[] = []; const keys = await redis.keys(SERVER_REGISTRY_KEY_PREFIX + '*'); // `redis.mget` throws an error if keys is empty if (keys.length > 0) { const payloads = await redis.mget(keys); for (const payload of payloads) { if (payload) { servers.push(JSON.parse(payload)); } } } if (ensureSelf) { const self = getServerRegistryPayload(); if (!servers.find((s) => s.id === self.id)) { servers.push(self); } } return servers; } function addComputedFields(now: number, server: ServerRegistryInfo): ServerRegistryInfoWithComputed { return { ...server, lastSeenAgeMs: now - new Date(server.lastSeen).getTime(), firstSeenAgeMs: now - new Date(server.firstSeen).getTime(), }; } function getServersByVersion(servers: ServerRegistryInfo[]): Record<string, ServerRegistryInfo[]> { const versionMap: Record<string, ServerRegistryInfo[]> = {}; for (const server of servers) { versionMap[server.fullVersion] ??= []; versionMap[server.fullVersion].push(server); } return versionMap; } export async function getClusterStatus(servers: ServerRegistryInfo[]): Promise<ClusterStatus> { servers.sort((a, b) => a.fullVersion.localeCompare(b.fullVersion)); const versionMap = getServersByVersion(servers); const versions = Object.keys(versionMap).sort((a, b) => a.localeCompare(b)); const versionCounts = versions.reduce((versionCounts: Record<string, number>, version) => { versionCounts[version] = versionMap[version].length; return versionCounts; }, {}); const now = Date.now(); return { timestamp: new Date().toISOString(), totalServers: servers.length, versions: versionCounts, oldestVersion: versions[0], newestVersion: versions.at(-1), isHomogeneous: versions.length === 1, servers: servers.map((server) => addComputedFields(now, server)), }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server