Skip to content

feat: core telemetry functionality #87

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 52 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
9cf0447
wip
blva Apr 16, 2025
1b8ea02
WIP
blva Apr 16, 2025
af714b4
wip
blva Apr 16, 2025
40aaef0
wip: simple approach
blva Apr 16, 2025
8adc20d
add cache and client logic
blva Apr 17, 2025
cff01e0
Merge remote-tracking branch 'origin/main' into telemetry
blva Apr 22, 2025
5336e4a
update deps
blva Apr 22, 2025
76e2a6f
update
blva Apr 22, 2025
1850676
update logs
blva Apr 22, 2025
3a5d4c1
reformat
blva Apr 22, 2025
23fff05
address comment: server main
blva Apr 22, 2025
754ce8d
address comment: make dynamic config part of session
blva Apr 22, 2025
8301e43
address comment: remove redundant method
blva Apr 22, 2025
994b698
address comment: move tool emission to tool.ts
blva Apr 22, 2025
3bfc9f2
address comment: update agentClient to agentRunner
blva Apr 22, 2025
6d92021
address comment: update agentClient to agentRunner
blva Apr 22, 2025
7997c54
address comment: isolate machine data
blva Apr 22, 2025
f18b916
address comment: inject config to constructor
blva Apr 22, 2025
a9cd835
feat: use machineid library
blva Apr 22, 2025
6813518
chore: reformat
blva Apr 22, 2025
65fad1d
chore: reformat
blva Apr 22, 2025
7e98c33
address comment: fix misleading comment
blva Apr 22, 2025
5209073
address npm check
blva Apr 22, 2025
2e33584
address comment: support do_not_track
blva Apr 22, 2025
d92adf1
Merge branch 'main' into telemetry
blva Apr 22, 2025
8ec7d9c
address comment: use in-memory cache
blva Apr 23, 2025
6ad2a19
address comments: use const
blva Apr 23, 2025
807109d
Merge branch 'main' into telemetry
blva Apr 23, 2025
f9a46f9
chore: fix lint and tests
blva Apr 23, 2025
599201d
address comment: remove unused const
blva Apr 23, 2025
7a889b4
address comment: do not use object freeze
blva Apr 23, 2025
eb360e2
address comment: as const
blva Apr 23, 2025
710b131
Update src/common/atlas/apiClient.ts
blva Apr 23, 2025
a15eeb6
address comment: make common props readonly
blva Apr 23, 2025
90caa25
Merge remote-tracking branch 'origin/main' into telemetry
blva Apr 23, 2025
89113a5
address comment: do not use enum
blva Apr 23, 2025
143f898
fix
blva Apr 23, 2025
f751a30
address comment: inject and use eventcache
blva Apr 23, 2025
0927d28
minor fixeS
blva Apr 23, 2025
fb0b8af
chore: gen sessionId
blva Apr 23, 2025
9e625aa
add lru cache
blva Apr 23, 2025
a61d9b4
chore: disable telemetry in tests
blva Apr 23, 2025
2bd00cf
Merge remote-tracking branch 'origin/main' into telemetry
blva Apr 23, 2025
3636fde
chore: reformat
blva Apr 23, 2025
0df870c
clean up
blva Apr 23, 2025
4b7563d
fix check
blva Apr 23, 2025
6dd1f79
address comment for category and update emit tool to get tool result …
blva Apr 23, 2025
807b2ca
address comment: fix telemetry init
blva Apr 23, 2025
139c3ee
Merge remote-tracking branch 'origin/main' into telemetry
blva Apr 23, 2025
3a05d31
format
blva Apr 23, 2025
8505d91
fix check
blva Apr 23, 2025
024a3d1
address comment
blva Apr 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"jest-environment-node": "^29.7.0",
"jest-extended": "^4.0.2",
"mongodb-runner": "^5.8.2",
"native-machine-id": "^0.0.8",
"openapi-types": "^12.1.3",
"openapi-typescript": "^7.6.1",
"prettier": "^3.5.3",
Expand Down
45 changes: 42 additions & 3 deletions src/common/atlas/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import config from "../../config.js";
import createClient, { Client, FetchOptions, Middleware } from "openapi-fetch";
import createClient, { Client, Middleware } from "openapi-fetch";
import type { FetchOptions } from "openapi-fetch";
import { AccessToken, ClientCredentials } from "simple-oauth2";
import { ApiClientError } from "./apiClientError.js";
import { paths, operations } from "./openapi.js";
import { BaseEvent } from "../../telemetry/types.js";
import { mongoLogId } from "mongodb-log-writer";
import logger from "../../logger.js";

const ATLAS_API_VERSION = "2025-03-12";

Expand Down Expand Up @@ -62,10 +66,10 @@ export class ApiClient {
constructor(options?: ApiClientOptions) {
this.options = {
...options,
baseUrl: options?.baseUrl || "https://cloud.mongodb.com/",
baseUrl: options?.baseUrl || "https://cloud-dev.mongodb.com/",
userAgent:
options?.userAgent ||
`AtlasMCP/${config.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
`${config.mcpServerName}/${config.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
};

this.client = createClient<paths>({
Expand All @@ -91,6 +95,15 @@ export class ApiClient {
this.client.use(this.errorMiddleware);
}

public hasCredentials(): boolean {
logger.info(
mongoLogId(1_000_000),
"api-client",
`Checking if API client has credentials: ${!!(this.oauth2Client && this.accessToken)}`
);
return !!(this.oauth2Client && this.accessToken);
}

public async getIpInfo(): Promise<{
currentIpv4Address: string;
}> {
Expand All @@ -116,6 +129,32 @@ export class ApiClient {
}>;
}

async sendEvents(events: BaseEvent[]): Promise<void> {
let endpoint = "api/private/unauth/telemetry/events";
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": this.options.userAgent,
};

const accessToken = await this.getAccessToken();
if (accessToken) {
endpoint = "api/private/v1.0/telemetry/events";
headers["Authorization"] = `Bearer ${accessToken}`;
}

const url = new URL(endpoint, this.options.baseUrl);
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(events),
});

if (!response.ok) {
throw await ApiClientError.fromResponse(response);
}
}

// DO NOT EDIT. This is auto-generated code.
async listClustersForAllProjects(options?: FetchOptions<operations["listClustersForAllProjects"]>) {
const { data } = await this.client.GET("/api/atlas/v2/clusters", options);
Expand Down
7 changes: 6 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import argv from "yargs-parser";
import packageJson from "../package.json" with { type: "json" };
import { ReadConcernLevel, ReadPreferenceMode, W } from "mongodb";

export const SERVER_NAME = "MdbMcpServer";
export const SERVER_VERSION = packageJson.version;

// If we decide to support non-string config options, we'll need to extend the mechanism for parsing
// env variables.
interface UserConfig {
apiBaseUrl?: string;
apiClientId?: string;
apiClientSecret?: string;
telemetry?: "enabled" | "disabled";
logPath: string;
connectionString?: string;
connectOptions: {
Expand Down Expand Up @@ -41,7 +45,8 @@ const mergedUserConfig = {

const config = {
...mergedUserConfig,
version: packageJson.version,
version: SERVER_VERSION,
mcpServerName: SERVER_NAME,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move this to MACHINE_METADATA and maybe get rid of individual global constants

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use it in the client though, so wanted to keep it here where is common

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a bit confusing for me as it is inside a configuration, which to me means it is configurable

};

export default config;
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,5 @@ try {
await server.connect(transport);
} catch (error: unknown) {
logger.emergency(mongoLogId(1_000_004), "server", `Fatal error running server: ${error as string}`);

process.exit(1);
}
10 changes: 8 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,21 @@ export class Server {

async connect(transport: Transport) {
this.mcpServer.server.registerCapabilities({ logging: {} });

this.registerTools();
this.registerResources();

await initializeLogger(this.mcpServer);

await this.mcpServer.connect(transport);

logger.info(mongoLogId(1_000_004), "server", `Server started with transport ${transport.constructor.name}`);
this.mcpServer.server.oninitialized = () => {
this.session.setAgentRunner(this.mcpServer.server.getClientVersion());
logger.info(
mongoLogId(1_000_004),
"server",
`Server started with transport ${transport.constructor.name} and agent runner ${this.session.agentRunner?.name}`
);
};
}

async close(): Promise<void> {
Expand Down
66 changes: 57 additions & 9 deletions src/session.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,73 @@
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { ApiClient } from "./common/atlas/apiClient.js";
import config from "./config.js";
import defaultConfig from "./config.js";
import { Implementation } from "@modelcontextprotocol/sdk/types.js";

// Define the type for configuration used by Session
interface SessionConfig {
apiBaseUrl?: string;
apiClientId?: string;
apiClientSecret?: string;
[key: string]: unknown;
}

export class Session {
sessionId?: string;
serviceProvider?: NodeDriverServiceProvider;
apiClient?: ApiClient;
apiClient: ApiClient;
agentRunner?: {
name: string;
version: string;
};
private credentials?: { clientId: string; clientSecret: string };
private baseUrl: string;
private readonly config: SessionConfig;

constructor(config: SessionConfig = defaultConfig as SessionConfig) {
this.config = config;
this.baseUrl = this.config.apiBaseUrl ?? "https://cloud.mongodb.com/";

// Store credentials if available
if (this.config.apiClientId && this.config.apiClientSecret) {
this.credentials = {
clientId: this.config.apiClientId,
clientSecret: this.config.apiClientSecret,
};

// Initialize API client with credentials
this.apiClient = new ApiClient({
baseUrl: this.baseUrl,
credentials: this.credentials,
});
return;
}

// Initialize API client without credentials
this.apiClient = new ApiClient({ baseUrl: this.baseUrl });
}

setAgentRunner(agentClient: Implementation | undefined) {
if (agentClient?.name && agentClient?.version) {
this.agentRunner = {
name: agentClient.name,
version: agentClient.version,
};
}
}

ensureAuthenticated(): asserts this is { apiClient: ApiClient } {
if (!this.apiClient) {
if (!config.apiClientId || !config.apiClientSecret) {
if (!this.apiClient.hasCredentials()) {
if (!this.credentials) {
throw new Error(
"Not authenticated make sure to configure MCP server with MDB_MCP_API_CLIENT_ID and MDB_MCP_API_CLIENT_SECRET environment variables."
);
}

// Reinitialize API client with the stored credentials
// This can happen if the server was configured without credentials but the env variables are later set
this.apiClient = new ApiClient({
baseUrl: config.apiBaseUrl,
credentials: {
clientId: config.apiClientId,
clientSecret: config.apiClientSecret,
},
baseUrl: this.baseUrl,
credentials: this.credentials,
});
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/telemetry/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import pkg from "../../package.json" with { type: "json" };
import config from "../config.js";
import { getMachineIdSync } from "native-machine-id";

/**
* Machine-specific metadata formatted for telemetry
*/
export const MACHINE_METADATA = {
device_id: getMachineIdSync(),
mcp_server_version: pkg.version,
mcp_server_name: config.mcpServerName,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems constant, do we need this? maybe we add useragent instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need it for telemetry right? we could give user agent, but i figure that having the data split is better.. no strong opinion as we hope not to change server_name, but we might change after public preview

platform: process.platform,
arch: process.arch,
os_type: process.platform,
os_version: process.version,
} as const;
46 changes: 46 additions & 0 deletions src/telemetry/eventCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { BaseEvent } from "./types.js";

/**
* Singleton class for in-memory telemetry event caching
* Provides a central storage for telemetry events that couldn't be sent
*/
export class EventCache {
private static instance: EventCache;
private events: BaseEvent[] = [];

private constructor() {}

/**
* Gets the singleton instance of EventCache
* @returns The EventCache instance
*/
public static getInstance(): EventCache {
if (!EventCache.instance) {
EventCache.instance = new EventCache();
}
return EventCache.instance;
}

/**
* Gets a copy of the currently cached events
* @returns Array of cached BaseEvent objects
*/
public getEvents(): BaseEvent[] {
return [...this.events];
}

/**
* Sets the cached events, replacing any existing events
* @param events - The events to cache
*/
public setEvents(events: BaseEvent[]): void {
this.events = [...events];
}

/**
* Clears all cached events
*/
public clearEvents(): void {
this.events = [];
}
}
Loading
Loading