diff --git a/package-lock.json b/package-lock.json index 307ef728..8563857c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", + "@napi-rs/keyring": "^1.1.6", "@types/express": "^5.0.1", "bson": "^6.10.3", "mongodb": "^6.15.0", @@ -2511,6 +2512,221 @@ "node": ">=14.15.1" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.1.6.tgz", + "integrity": "sha512-e6xoYELSMyaxcXv4MmEHhf0oOGsMnfWMmeu84CD91ICMgMH1I1vrLSMFpiPEQz03xD+pNQgAkQ7DwwBDozCuvw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.1.6", + "@napi-rs/keyring-darwin-x64": "1.1.6", + "@napi-rs/keyring-freebsd-x64": "1.1.6", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.1.6", + "@napi-rs/keyring-linux-arm64-gnu": "1.1.6", + "@napi-rs/keyring-linux-arm64-musl": "1.1.6", + "@napi-rs/keyring-linux-riscv64-gnu": "1.1.6", + "@napi-rs/keyring-linux-x64-gnu": "1.1.6", + "@napi-rs/keyring-linux-x64-musl": "1.1.6", + "@napi-rs/keyring-win32-arm64-msvc": "1.1.6", + "@napi-rs/keyring-win32-ia32-msvc": "1.1.6", + "@napi-rs/keyring-win32-x64-msvc": "1.1.6" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.1.6.tgz", + "integrity": "sha512-8N+qvM+O6OSU59BTgDP/PvqYhoqfOcD2HGy1NgRFo1B0DRmkTp4U/DGZrV4Pk/nOP6Uf0PLqznfx3a/M8O5sjQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.1.6.tgz", + "integrity": "sha512-r3Jgc5/ubfaao6Lmk/USA13IwU/GEVLP8NDfg5gYXjPVllU6bWnAaEDHVg7q4vl51kViwj9ELo6XTmOeJFut6A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.1.6.tgz", + "integrity": "sha512-ayG396jZAt7j820gsEyW/LJKn+rf9KtgSPq1NKpvu84Y5GXopoFLyjMIP7wYZ1RLBL6SGKy27/f8S4f6YZ4DuA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.1.6.tgz", + "integrity": "sha512-8nXavgxcaUTUxyFHR+PEQF7eC8rITlYZNUmlf5amTb36y5bkNKrc3QLvCxjtbFSR/+KYzMi3vydoqNmFpF616w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.1.6.tgz", + "integrity": "sha512-qsI2NTAxGD3mBhZvdyYGL+N0n1D/NAjV0zCpTsFKKSzdpIrQJ0nM5Y0HxlLi6TsHm61dMyXHkdHb0ut8AzTcGA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.1.6.tgz", + "integrity": "sha512-SB/2A4LtL+SrS2aZXl3rWBtyCVB2aG2zAU56kOGFDGwRZM2tqaITuQoM1QLOAMwu0eksN/Xedy95Yn2rkRH0nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.1.6.tgz", + "integrity": "sha512-BcjXf33T2CoVgS87SvZ62Y6xxkbenNIeldy0r8O5nz6zFgN+wYB0scz5ulvowEYBQnhi4fmbxfneeqM/0HUOeA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.1.6.tgz", + "integrity": "sha512-eK0OxCBI6Wl8rFHYynrtEID6pxOwhPfnpIIpul7UPeqCCMJSyZpFN4lFP3oZ4vqX/6FnWjwMrR7IGbPgivdMjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.1.6.tgz", + "integrity": "sha512-Qb3NP98KFq4jXmk9PUQlcYrHjbzsBTtG+OOxX4YxUNKTGuUaIOGP79lB0w7jhns2oHdq8DwkW2ugzlmGSUaRSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.1.6.tgz", + "integrity": "sha512-e794gO2CLD0P7JN2DVPT5CC60k3WmNWTWU5BVoQM8Hj0NYebx7j6LyxMIpdb2cztOHHiv7iltEHekgutf0TMlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.1.6.tgz", + "integrity": "sha512-SUPafl6vKRMQBKZoSwIeBFZ+c7AGEKUy6mpAD9fVHDKHOBWP3VpHKda4YIlgGtQd3SxH0bjfqJ078Z5SYsDYZQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.1.6.tgz", + "integrity": "sha512-FkNhM1x5ijFzGSrRcshRxUxQSrrjxl4wCmvRcXnimWreOHyzNotT+/1EZtSfM/k8yhdK0HEkkVIMQl0UqfioRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 62f82c4a..0e6f6f4e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "dependencies": { "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", + "@napi-rs/keyring": "^1.1.6", "@types/express": "^5.0.1", "bson": "^6.10.3", "mongodb": "^6.15.0", diff --git a/src/common/atlas/auth.ts b/src/common/atlas/auth.ts index 31618000..ac0c876d 100644 --- a/src/common/atlas/auth.ts +++ b/src/common/atlas/auth.ts @@ -8,21 +8,21 @@ export async function ensureAuthenticated(state: State, apiClient: ApiClient): P } export async function isAuthenticated(state: State, apiClient: ApiClient): Promise { - switch (state.auth.status) { + switch (state.credentials.auth.status) { case "not_auth": return false; case "requested": try { - if (!state.auth.code) { + if (!state.credentials.auth.code) { return false; } - await apiClient.retrieveToken(state.auth.code.device_code); - return !!state.auth.token; + await apiClient.retrieveToken(state.credentials.auth.code.device_code); + return !!state.credentials.auth.token; } catch { return false; } case "issued": - if (!state.auth.token) { + if (!state.credentials.auth.token) { return false; } return await apiClient.validateToken(); diff --git a/src/server.ts b/src/server.ts index 6ccb92f5..0415f038 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ApiClient } from "./common/atlas/apiClient.js"; -import { State, saveState, loadState } from "./state.js"; +import defaultState, { State } from "./state.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { registerAtlasTools } from "./tools/atlas/tools.js"; import { registerMongoDBTools } from "./tools/mongodb/index.js"; @@ -9,7 +9,7 @@ import logger, { initializeLogger } from "./logger.js"; import { mongoLogId } from "mongodb-log-writer"; export class Server { - state: State | undefined = undefined; + state: State = defaultState; apiClient: ApiClient | undefined = undefined; initialized: boolean = false; @@ -17,18 +17,19 @@ export class Server { if (this.initialized) { return; } - this.state = await loadState(); + + await this.state.loadCredentials(); this.apiClient = new ApiClient({ - token: this.state?.auth.token, + token: this.state.credentials.auth.token, saveToken: async (token) => { if (!this.state) { throw new Error("State is not initialized"); } - this.state.auth.code = undefined; - this.state.auth.token = token; - this.state.auth.status = "issued"; - await saveState(this.state); + this.state.credentials.auth.code = undefined; + this.state.credentials.auth.token = token; + this.state.credentials.auth.status = "issued"; + await this.state.persistCredentials(); }, }); @@ -43,8 +44,8 @@ export class Server { server.server.registerCapabilities({ logging: {} }); - registerAtlasTools(server, this.state!, this.apiClient!); - registerMongoDBTools(server, this.state!); + registerAtlasTools(server, this.state, this.apiClient!); + registerMongoDBTools(server, this.state); return server; } diff --git a/src/state.ts b/src/state.ts index 349dd04c..9cc79626 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,8 +1,10 @@ -import fs from "fs/promises"; -import config from "./config.js"; import { OauthDeviceCode, OAuthToken } from "./common/atlas/apiClient.js"; +import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; +import { AsyncEntry } from "@napi-rs/keyring"; +import logger from "./logger.js"; +import { mongoLogId } from "mongodb-log-writer"; -export interface State { +interface Credentials { auth: { status: "not_auth" | "requested" | "issued"; code?: OauthDeviceCode; @@ -11,23 +13,33 @@ export interface State { connectionString?: string; } -export async function saveState(state: State): Promise { - await fs.writeFile(config.stateFile, JSON.stringify(state), { encoding: "utf-8" }); -} +export class State { + private entry = new AsyncEntry("mongodb-mcp", "credentials"); + credentials: Credentials = { + auth: { + status: "not_auth", + }, + }; + serviceProvider?: NodeDriverServiceProvider; -export async function loadState(): Promise { - try { - const data = await fs.readFile(config.stateFile, "utf-8"); - return JSON.parse(data) as State; - } catch (err: unknown) { - if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") { - return { - auth: { - status: "not_auth", - }, - }; - } + public async persistCredentials(): Promise { + await this.entry.setPassword(JSON.stringify(this.credentials)); + } - throw err; + public async loadCredentials(): Promise { + try { + const data = await this.entry.getPassword(); + if (data) { + this.credentials = JSON.parse(data); + } + + return true; + } catch (err: unknown) { + logger.error(mongoLogId(1_000_007), "state", `Failed to load state: ${err}`); + return false; + } } } + +const defaultState = new State(); +export default defaultState; diff --git a/src/tools/atlas/auth.ts b/src/tools/atlas/auth.ts index 84fe8527..aa33bd78 100644 --- a/src/tools/atlas/auth.ts +++ b/src/tools/atlas/auth.ts @@ -1,5 +1,4 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { saveState } from "../../state.js"; import { AtlasToolBase } from "./atlasTool.js"; import { isAuthenticated } from "../../common/atlas/auth.js"; import logger from "../../logger.js"; @@ -25,11 +24,11 @@ export class AuthTool extends AtlasToolBase { try { const code = await this.apiClient.authenticate(); - this.state.auth.status = "requested"; - this.state.auth.code = code; - this.state.auth.token = undefined; + this.state.credentials.auth.status = "requested"; + this.state.credentials.auth.code = code; + this.state.credentials.auth.token = undefined; - await saveState(this.state); + await this.state.persistCredentials(); return { content: [ diff --git a/src/tools/mongodb/connect.ts b/src/tools/mongodb/connect.ts index 397d4552..f95cde27 100644 --- a/src/tools/mongodb/connect.ts +++ b/src/tools/mongodb/connect.ts @@ -4,7 +4,6 @@ import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver import { DbOperationType, MongoDBToolBase } from "./mongodbTool.js"; import { ToolArgs } from "../tool.js"; import { ErrorCodes, MongoDBError } from "../../errors.js"; -import { saveState } from "../../state.js"; export class ConnectTool extends MongoDBToolBase { protected name = "connect"; @@ -21,7 +20,7 @@ export class ConnectTool extends MongoDBToolBase { protected async execute({ connectionStringOrClusterName, }: ToolArgs): Promise { - connectionStringOrClusterName ??= this.state.connectionString; + connectionStringOrClusterName ??= this.state.credentials.connectionString; if (!connectionStringOrClusterName) { return { content: [ @@ -71,8 +70,8 @@ export class ConnectTool extends MongoDBToolBase { productName: "MongoDB MCP", }); - this.mongodbState.serviceProvider = provider; - this.state.connectionString = connectionString; - await saveState(this.state); + this.state.serviceProvider = provider; + this.state.credentials.connectionString = connectionString; + await this.state.persistCredentials(); } } diff --git a/src/tools/mongodb/index.ts b/src/tools/mongodb/index.ts index c1c5703c..be30c494 100644 --- a/src/tools/mongodb/index.ts +++ b/src/tools/mongodb/index.ts @@ -4,7 +4,6 @@ import { ConnectTool } from "./connect.js"; import { ListCollectionsTool } from "./metadata/listCollections.js"; import { CollectionIndexesTool } from "./collectionIndexes.js"; import { ListDatabasesTool } from "./metadata/listDatabases.js"; -import { MongoDBToolState } from "./mongodbTool.js"; import { CreateIndexTool } from "./createIndex.js"; import { CollectionSchemaTool } from "./metadata/collectionSchema.js"; import { InsertOneTool } from "./create/insertOne.js"; @@ -23,8 +22,6 @@ import { DropDatabaseTool } from "./delete/dropDatabase.js"; import { DropCollectionTool } from "./delete/dropCollection.js"; export function registerMongoDBTools(server: McpServer, state: State) { - const mongodbToolState: MongoDBToolState = {}; - const tools = [ ConnectTool, ListCollectionsTool, @@ -49,7 +46,7 @@ export function registerMongoDBTools(server: McpServer, state: State) { ]; for (const tool of tools) { - const instance = new tool(state, mongodbToolState); + const instance = new tool(state); instance.register(server); } } diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index 75d1af27..2031fe29 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -5,8 +5,6 @@ import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ErrorCodes, MongoDBError } from "../../errors.js"; -export type MongoDBToolState = { serviceProvider?: NodeDriverServiceProvider }; - export const DbOperationArgs = { database: z.string().describe("Database name"), collection: z.string().describe("Collection name"), @@ -15,17 +13,14 @@ export const DbOperationArgs = { export type DbOperationType = "metadata" | "read" | "create" | "update" | "delete"; export abstract class MongoDBToolBase extends ToolBase { - constructor( - state: State, - protected mongodbState: MongoDBToolState - ) { + constructor(state: State) { super(state); } protected abstract operationType: DbOperationType; protected ensureConnected(): NodeDriverServiceProvider { - const provider = this.mongodbState.serviceProvider; + const provider = this.state.serviceProvider; if (!provider) { throw new MongoDBError(ErrorCodes.NotConnectedToMongoDB, "Not connected to MongoDB"); }