diff --git a/src/common/session.ts b/src/common/session.ts index 689a25d8..2a75af33 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -13,10 +13,14 @@ export interface SessionOptions { apiClientSecret?: string; } -export class Session extends EventEmitter<{ +export type SessionEvents = { + connect: []; close: []; disconnect: []; -}> { + "connection-error": [string]; +}; + +export class Session extends EventEmitter { sessionId?: string; serviceProvider?: NodeDriverServiceProvider; apiClient: ApiClient; @@ -102,19 +106,30 @@ export class Session extends EventEmitter<{ connectionString, defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`, }); - this.serviceProvider = await NodeDriverServiceProvider.connect(connectionString, { - productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/", - productName: "MongoDB MCP", - readConcern: { - level: connectOptions.readConcern, - }, - readPreference: connectOptions.readPreference, - writeConcern: { - w: connectOptions.writeConcern, - }, - timeoutMS: connectOptions.timeoutMS, - proxy: { useEnvironmentVariableProxies: true }, - applyProxyToOIDC: true, - }); + + try { + this.serviceProvider = await NodeDriverServiceProvider.connect(connectionString, { + productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/", + productName: "MongoDB MCP", + readConcern: { + level: connectOptions.readConcern, + }, + readPreference: connectOptions.readPreference, + writeConcern: { + w: connectOptions.writeConcern, + }, + timeoutMS: connectOptions.timeoutMS, + proxy: { useEnvironmentVariableProxies: true }, + applyProxyToOIDC: true, + }); + + await this.serviceProvider?.runCommand?.("admin", { hello: 1 }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : `${error as string}`; + this.emit("connection-error", message); + throw error; + } + + this.emit("connect"); } } diff --git a/src/resources/common/config.ts b/src/resources/common/config.ts new file mode 100644 index 00000000..8d2e8089 --- /dev/null +++ b/src/resources/common/config.ts @@ -0,0 +1,42 @@ +import { ReactiveResource } from "../resource.js"; +import { config } from "../../common/config.js"; +import type { UserConfig } from "../../common/config.js"; + +export class ConfigResource extends ReactiveResource( + { + name: "config", + uri: "config://config", + config: { + description: + "Server configuration, supplied by the user either as environment variables or as startup arguments", + }, + }, + { + initial: { ...config }, + events: [], + } +) { + reduce(eventName: undefined, event: undefined): UserConfig { + void eventName; + void event; + + return this.current; + } + + toOutput(): string { + const result = { + telemetry: this.current.telemetry, + logPath: this.current.logPath, + connectionString: this.current.connectionString + ? "set; access to MongoDB tools are currently available to use" + : "not set; before using any MongoDB tool, you need to configure a connection string, alternatively you can setup MongoDB Atlas access, more info at 'https://github.com/mongodb-js/mongodb-mcp-server'.", + connectOptions: this.current.connectOptions, + atlas: + this.current.apiClientId && this.current.apiClientSecret + ? "set; MongoDB Atlas tools are currently available to use" + : "not set; MongoDB Atlas tools are currently unavailable, to have access to MongoDB Atlas tools like creating clusters or connecting to clusters make sure to setup credentials, more info at 'https://github.com/mongodb-js/mongodb-mcp-server'.", + }; + + return JSON.stringify(result); + } +} diff --git a/src/resources/common/debug.ts b/src/resources/common/debug.ts new file mode 100644 index 00000000..c8de2dd0 --- /dev/null +++ b/src/resources/common/debug.ts @@ -0,0 +1,60 @@ +import { ReactiveResource } from "../resource.js"; + +type ConnectionStateDebuggingInformation = { + readonly tag: "connected" | "connecting" | "disconnected" | "errored"; + readonly connectionStringAuthType?: "scram" | "ldap" | "kerberos" | "oidc-auth-flow" | "oidc-device-flow" | "x.509"; + readonly oidcLoginUrl?: string; + readonly oidcUserCode?: string; + readonly errorReason?: string; +}; + +export class DebugResource extends ReactiveResource( + { + name: "debug-mongodb-connectivity", + uri: "debug://mongodb-connectivity", + config: { + description: "Debugging information for connectivity issues.", + }, + }, + { + initial: { tag: "disconnected" } as ConnectionStateDebuggingInformation, + events: ["connect", "disconnect", "close", "connection-error"], + } +) { + reduce( + eventName: "connect" | "disconnect" | "close" | "connection-error", + event: string | undefined + ): ConnectionStateDebuggingInformation { + void event; + + switch (eventName) { + case "connect": + return { tag: "connected" }; + case "connection-error": + return { tag: "errored", errorReason: event }; + case "disconnect": + case "close": + return { tag: "disconnected" }; + } + } + + toOutput(): string { + let result = ""; + + switch (this.current.tag) { + case "connected": + result += "The user is connected to the MongoDB cluster."; + break; + case "errored": + result += `The user is not connected to a MongoDB cluster because of an error.\n`; + result += `${this.current.errorReason}`; + break; + case "connecting": + case "disconnected": + result += "The user is not connected to a MongoDB cluster."; + break; + } + + return result; + } +} diff --git a/src/resources/resource.ts b/src/resources/resource.ts new file mode 100644 index 00000000..271d3d3e --- /dev/null +++ b/src/resources/resource.ts @@ -0,0 +1,83 @@ +import { Server } from "../server.js"; +import { Session } from "../common/session.js"; +import { UserConfig } from "../common/config.js"; +import { Telemetry } from "../telemetry/telemetry.js"; +import type { SessionEvents } from "../common/session.js"; +import { ReadResourceCallback, ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp.js"; +import logger, { LogId } from "../common/logger.js"; + +type PayloadOf = SessionEvents[K][0]; + +type ResourceConfiguration = { name: string; uri: string; config: ResourceMetadata }; + +export function ReactiveResource( + { name, uri, config: resourceConfig }: ResourceConfiguration, + { + initial, + events, + }: { + initial: Value; + events: RelevantEvents; + } +) { + type SomeEvent = RelevantEvents[number]; + + abstract class NewReactiveResource { + protected readonly session: Session; + protected readonly config: UserConfig; + protected current: Value; + + constructor( + protected readonly server: Server, + protected readonly telemetry: Telemetry, + current?: Value + ) { + this.current = current ?? initial; + this.session = server.session; + this.config = server.userConfig; + + for (const event of events) { + this.session.on(event, (...args: SessionEvents[typeof event]) => { + this.reduceApply(event, (args as unknown[])[0] as PayloadOf); + void this.triggerUpdate(); + }); + } + } + + public register(): void { + this.server.mcpServer.registerResource(name, uri, resourceConfig, this.resourceCallback); + } + + private resourceCallback: ReadResourceCallback = (uri) => ({ + contents: [ + { + text: this.toOutput(), + mimeType: "application/json", + uri: uri.href, + }, + ], + }); + + private async triggerUpdate() { + try { + await this.server.mcpServer.server.sendResourceUpdated({ uri }); + this.server.mcpServer.sendResourceListChanged(); + } catch (error: unknown) { + logger.warning( + LogId.serverClosed, + "Could not send the latest resources to the client.", + error as string + ); + } + } + + reduceApply(eventName: SomeEvent, ...event: PayloadOf[]): void { + this.current = this.reduce(eventName, ...event); + } + + protected abstract reduce(eventName: SomeEvent, ...event: PayloadOf[]): Value; + abstract toOutput(): string; + } + + return NewReactiveResource; +} diff --git a/src/resources/resources.ts b/src/resources/resources.ts new file mode 100644 index 00000000..40a17702 --- /dev/null +++ b/src/resources/resources.ts @@ -0,0 +1,4 @@ +import { ConfigResource } from "./common/config.js"; +import { DebugResource } from "./common/debug.js"; + +export const Resources = [ConfigResource, DebugResource] as const; diff --git a/src/server.ts b/src/server.ts index d58cca52..1eccbdcd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import { Session } from "./common/session.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { AtlasTools } from "./tools/atlas/tools.js"; import { MongoDbTools } from "./tools/mongodb/tools.js"; +import { Resources } from "./resources/resources.js"; import logger, { LogId, LoggerBase, McpLogger, DiskLogger, ConsoleLogger } from "./common/logger.js"; import { ObjectId } from "mongodb"; import { Telemetry } from "./telemetry/telemetry.js"; @@ -155,37 +156,10 @@ export class Server { } private registerResources() { - this.mcpServer.resource( - "config", - "config://config", - { - description: - "Server configuration, supplied by the user either as environment variables or as startup arguments", - }, - (uri) => { - const result = { - telemetry: this.userConfig.telemetry, - logPath: this.userConfig.logPath, - connectionString: this.userConfig.connectionString - ? "set; access to MongoDB tools are currently available to use" - : "not set; before using any MongoDB tool, you need to configure a connection string, alternatively you can setup MongoDB Atlas access, more info at 'https://github.com/mongodb-js/mongodb-mcp-server'.", - connectOptions: this.userConfig.connectOptions, - atlas: - this.userConfig.apiClientId && this.userConfig.apiClientSecret - ? "set; MongoDB Atlas tools are currently available to use" - : "not set; MongoDB Atlas tools are currently unavailable, to have access to MongoDB Atlas tools like creating clusters or connecting to clusters make sure to setup credentials, more info at 'https://github.com/mongodb-js/mongodb-mcp-server'.", - }; - return { - contents: [ - { - text: JSON.stringify(result), - mimeType: "application/json", - uri: uri.href, - }, - ], - }; - } - ); + for (const resourceConstructor of Resources) { + const resource = new resourceConstructor(this, this.telemetry); + resource.register(); + } } private async validateConfig(): Promise { diff --git a/tests/unit/resources/common/debug.test.ts b/tests/unit/resources/common/debug.test.ts new file mode 100644 index 00000000..4a2f704b --- /dev/null +++ b/tests/unit/resources/common/debug.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DebugResource } from "../../../../src/resources/common/debug.js"; +import { Session } from "../../../../src/common/session.js"; +import { Server } from "../../../../src/server.js"; +import { Telemetry } from "../../../../src/telemetry/telemetry.js"; +import { config } from "../../../../src/common/config.js"; + +describe("debug resource", () => { + // eslint-disable-next-line + const session = new Session({} as any); + // eslint-disable-next-line + const server = new Server({ session } as any); + const telemetry = Telemetry.create(session, { ...config, telemetry: "disabled" }); + + let debugResource: DebugResource = new DebugResource(server, telemetry); + + beforeEach(() => { + debugResource = new DebugResource(server, telemetry); + }); + + it("should be connected when a connected event happens", () => { + debugResource.reduceApply("connect", undefined); + const output = debugResource.toOutput(); + + expect(output).toContain(`The user is connected to the MongoDB cluster.`); + }); + + it("should be disconnected when a disconnect event happens", () => { + debugResource.reduceApply("disconnect", undefined); + const output = debugResource.toOutput(); + + expect(output).toContain(`The user is not connected to a MongoDB cluster.`); + }); + + it("should be disconnected when a close event happens", () => { + debugResource.reduceApply("close", undefined); + const output = debugResource.toOutput(); + + expect(output).toContain(`The user is not connected to a MongoDB cluster.`); + }); + + it("should be disconnected and contain an error when an error event occurred", () => { + debugResource.reduceApply("connection-error", "Error message from the server"); + const output = debugResource.toOutput(); + + expect(output).toContain(`The user is not connected to a MongoDB cluster because of an error.`); + expect(output).toContain(`Error message from the server`); + }); +});