Skip to content

Commit e6615c3

Browse files
authored
chore: Reactive resource support for debugging connectivity MCP-80 (#413)
1 parent b24fe5e commit e6615c3

File tree

7 files changed

+274
-47
lines changed

7 files changed

+274
-47
lines changed

src/common/session.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ export interface SessionOptions {
1313
apiClientSecret?: string;
1414
}
1515

16-
export class Session extends EventEmitter<{
16+
export type SessionEvents = {
17+
connect: [];
1718
close: [];
1819
disconnect: [];
19-
}> {
20+
"connection-error": [string];
21+
};
22+
23+
export class Session extends EventEmitter<SessionEvents> {
2024
sessionId?: string;
2125
serviceProvider?: NodeDriverServiceProvider;
2226
apiClient: ApiClient;
@@ -102,19 +106,30 @@ export class Session extends EventEmitter<{
102106
connectionString,
103107
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
104108
});
105-
this.serviceProvider = await NodeDriverServiceProvider.connect(connectionString, {
106-
productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/",
107-
productName: "MongoDB MCP",
108-
readConcern: {
109-
level: connectOptions.readConcern,
110-
},
111-
readPreference: connectOptions.readPreference,
112-
writeConcern: {
113-
w: connectOptions.writeConcern,
114-
},
115-
timeoutMS: connectOptions.timeoutMS,
116-
proxy: { useEnvironmentVariableProxies: true },
117-
applyProxyToOIDC: true,
118-
});
109+
110+
try {
111+
this.serviceProvider = await NodeDriverServiceProvider.connect(connectionString, {
112+
productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/",
113+
productName: "MongoDB MCP",
114+
readConcern: {
115+
level: connectOptions.readConcern,
116+
},
117+
readPreference: connectOptions.readPreference,
118+
writeConcern: {
119+
w: connectOptions.writeConcern,
120+
},
121+
timeoutMS: connectOptions.timeoutMS,
122+
proxy: { useEnvironmentVariableProxies: true },
123+
applyProxyToOIDC: true,
124+
});
125+
126+
await this.serviceProvider?.runCommand?.("admin", { hello: 1 });
127+
} catch (error: unknown) {
128+
const message = error instanceof Error ? error.message : `${error as string}`;
129+
this.emit("connection-error", message);
130+
throw error;
131+
}
132+
133+
this.emit("connect");
119134
}
120135
}

src/resources/common/config.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ReactiveResource } from "../resource.js";
2+
import { config } from "../../common/config.js";
3+
import type { UserConfig } from "../../common/config.js";
4+
5+
export class ConfigResource extends ReactiveResource(
6+
{
7+
name: "config",
8+
uri: "config://config",
9+
config: {
10+
description:
11+
"Server configuration, supplied by the user either as environment variables or as startup arguments",
12+
},
13+
},
14+
{
15+
initial: { ...config },
16+
events: [],
17+
}
18+
) {
19+
reduce(eventName: undefined, event: undefined): UserConfig {
20+
void eventName;
21+
void event;
22+
23+
return this.current;
24+
}
25+
26+
toOutput(): string {
27+
const result = {
28+
telemetry: this.current.telemetry,
29+
logPath: this.current.logPath,
30+
connectionString: this.current.connectionString
31+
? "set; access to MongoDB tools are currently available to use"
32+
: "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'.",
33+
connectOptions: this.current.connectOptions,
34+
atlas:
35+
this.current.apiClientId && this.current.apiClientSecret
36+
? "set; MongoDB Atlas tools are currently available to use"
37+
: "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'.",
38+
};
39+
40+
return JSON.stringify(result);
41+
}
42+
}

src/resources/common/debug.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ReactiveResource } from "../resource.js";
2+
3+
type ConnectionStateDebuggingInformation = {
4+
readonly tag: "connected" | "connecting" | "disconnected" | "errored";
5+
readonly connectionStringAuthType?: "scram" | "ldap" | "kerberos" | "oidc-auth-flow" | "oidc-device-flow" | "x.509";
6+
readonly oidcLoginUrl?: string;
7+
readonly oidcUserCode?: string;
8+
readonly errorReason?: string;
9+
};
10+
11+
export class DebugResource extends ReactiveResource(
12+
{
13+
name: "debug-mongodb-connectivity",
14+
uri: "debug://mongodb-connectivity",
15+
config: {
16+
description: "Debugging information for connectivity issues.",
17+
},
18+
},
19+
{
20+
initial: { tag: "disconnected" } as ConnectionStateDebuggingInformation,
21+
events: ["connect", "disconnect", "close", "connection-error"],
22+
}
23+
) {
24+
reduce(
25+
eventName: "connect" | "disconnect" | "close" | "connection-error",
26+
event: string | undefined
27+
): ConnectionStateDebuggingInformation {
28+
void event;
29+
30+
switch (eventName) {
31+
case "connect":
32+
return { tag: "connected" };
33+
case "connection-error":
34+
return { tag: "errored", errorReason: event };
35+
case "disconnect":
36+
case "close":
37+
return { tag: "disconnected" };
38+
}
39+
}
40+
41+
toOutput(): string {
42+
let result = "";
43+
44+
switch (this.current.tag) {
45+
case "connected":
46+
result += "The user is connected to the MongoDB cluster.";
47+
break;
48+
case "errored":
49+
result += `The user is not connected to a MongoDB cluster because of an error.\n`;
50+
result += `<error>${this.current.errorReason}</error>`;
51+
break;
52+
case "connecting":
53+
case "disconnected":
54+
result += "The user is not connected to a MongoDB cluster.";
55+
break;
56+
}
57+
58+
return result;
59+
}
60+
}

src/resources/resource.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Server } from "../server.js";
2+
import { Session } from "../common/session.js";
3+
import { UserConfig } from "../common/config.js";
4+
import { Telemetry } from "../telemetry/telemetry.js";
5+
import type { SessionEvents } from "../common/session.js";
6+
import { ReadResourceCallback, ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp.js";
7+
import logger, { LogId } from "../common/logger.js";
8+
9+
type PayloadOf<K extends keyof SessionEvents> = SessionEvents[K][0];
10+
11+
type ResourceConfiguration = { name: string; uri: string; config: ResourceMetadata };
12+
13+
export function ReactiveResource<Value, RelevantEvents extends readonly (keyof SessionEvents)[]>(
14+
{ name, uri, config: resourceConfig }: ResourceConfiguration,
15+
{
16+
initial,
17+
events,
18+
}: {
19+
initial: Value;
20+
events: RelevantEvents;
21+
}
22+
) {
23+
type SomeEvent = RelevantEvents[number];
24+
25+
abstract class NewReactiveResource {
26+
protected readonly session: Session;
27+
protected readonly config: UserConfig;
28+
protected current: Value;
29+
30+
constructor(
31+
protected readonly server: Server,
32+
protected readonly telemetry: Telemetry,
33+
current?: Value
34+
) {
35+
this.current = current ?? initial;
36+
this.session = server.session;
37+
this.config = server.userConfig;
38+
39+
for (const event of events) {
40+
this.session.on(event, (...args: SessionEvents[typeof event]) => {
41+
this.reduceApply(event, (args as unknown[])[0] as PayloadOf<typeof event>);
42+
void this.triggerUpdate();
43+
});
44+
}
45+
}
46+
47+
public register(): void {
48+
this.server.mcpServer.registerResource(name, uri, resourceConfig, this.resourceCallback);
49+
}
50+
51+
private resourceCallback: ReadResourceCallback = (uri) => ({
52+
contents: [
53+
{
54+
text: this.toOutput(),
55+
mimeType: "application/json",
56+
uri: uri.href,
57+
},
58+
],
59+
});
60+
61+
private async triggerUpdate() {
62+
try {
63+
await this.server.mcpServer.server.sendResourceUpdated({ uri });
64+
this.server.mcpServer.sendResourceListChanged();
65+
} catch (error: unknown) {
66+
logger.warning(
67+
LogId.serverClosed,
68+
"Could not send the latest resources to the client.",
69+
error as string
70+
);
71+
}
72+
}
73+
74+
reduceApply(eventName: SomeEvent, ...event: PayloadOf<SomeEvent>[]): void {
75+
this.current = this.reduce(eventName, ...event);
76+
}
77+
78+
protected abstract reduce(eventName: SomeEvent, ...event: PayloadOf<SomeEvent>[]): Value;
79+
abstract toOutput(): string;
80+
}
81+
82+
return NewReactiveResource;
83+
}

src/resources/resources.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { ConfigResource } from "./common/config.js";
2+
import { DebugResource } from "./common/debug.js";
3+
4+
export const Resources = [ConfigResource, DebugResource] as const;

src/server.ts

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Session } from "./common/session.js";
33
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
44
import { AtlasTools } from "./tools/atlas/tools.js";
55
import { MongoDbTools } from "./tools/mongodb/tools.js";
6+
import { Resources } from "./resources/resources.js";
67
import logger, { LogId, LoggerBase, McpLogger, DiskLogger, ConsoleLogger } from "./common/logger.js";
78
import { ObjectId } from "mongodb";
89
import { Telemetry } from "./telemetry/telemetry.js";
@@ -155,37 +156,10 @@ export class Server {
155156
}
156157

157158
private registerResources() {
158-
this.mcpServer.resource(
159-
"config",
160-
"config://config",
161-
{
162-
description:
163-
"Server configuration, supplied by the user either as environment variables or as startup arguments",
164-
},
165-
(uri) => {
166-
const result = {
167-
telemetry: this.userConfig.telemetry,
168-
logPath: this.userConfig.logPath,
169-
connectionString: this.userConfig.connectionString
170-
? "set; access to MongoDB tools are currently available to use"
171-
: "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'.",
172-
connectOptions: this.userConfig.connectOptions,
173-
atlas:
174-
this.userConfig.apiClientId && this.userConfig.apiClientSecret
175-
? "set; MongoDB Atlas tools are currently available to use"
176-
: "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'.",
177-
};
178-
return {
179-
contents: [
180-
{
181-
text: JSON.stringify(result),
182-
mimeType: "application/json",
183-
uri: uri.href,
184-
},
185-
],
186-
};
187-
}
188-
);
159+
for (const resourceConstructor of Resources) {
160+
const resource = new resourceConstructor(this, this.telemetry);
161+
resource.register();
162+
}
189163
}
190164

191165
private async validateConfig(): Promise<void> {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import { DebugResource } from "../../../../src/resources/common/debug.js";
3+
import { Session } from "../../../../src/common/session.js";
4+
import { Server } from "../../../../src/server.js";
5+
import { Telemetry } from "../../../../src/telemetry/telemetry.js";
6+
import { config } from "../../../../src/common/config.js";
7+
8+
describe("debug resource", () => {
9+
// eslint-disable-next-line
10+
const session = new Session({} as any);
11+
// eslint-disable-next-line
12+
const server = new Server({ session } as any);
13+
const telemetry = Telemetry.create(session, { ...config, telemetry: "disabled" });
14+
15+
let debugResource: DebugResource = new DebugResource(server, telemetry);
16+
17+
beforeEach(() => {
18+
debugResource = new DebugResource(server, telemetry);
19+
});
20+
21+
it("should be connected when a connected event happens", () => {
22+
debugResource.reduceApply("connect", undefined);
23+
const output = debugResource.toOutput();
24+
25+
expect(output).toContain(`The user is connected to the MongoDB cluster.`);
26+
});
27+
28+
it("should be disconnected when a disconnect event happens", () => {
29+
debugResource.reduceApply("disconnect", undefined);
30+
const output = debugResource.toOutput();
31+
32+
expect(output).toContain(`The user is not connected to a MongoDB cluster.`);
33+
});
34+
35+
it("should be disconnected when a close event happens", () => {
36+
debugResource.reduceApply("close", undefined);
37+
const output = debugResource.toOutput();
38+
39+
expect(output).toContain(`The user is not connected to a MongoDB cluster.`);
40+
});
41+
42+
it("should be disconnected and contain an error when an error event occurred", () => {
43+
debugResource.reduceApply("connection-error", "Error message from the server");
44+
const output = debugResource.toOutput();
45+
46+
expect(output).toContain(`The user is not connected to a MongoDB cluster because of an error.`);
47+
expect(output).toContain(`<error>Error message from the server</error>`);
48+
});
49+
});

0 commit comments

Comments
 (0)