diff --git a/package-lock.json b/package-lock.json index 3766a848e..1559a39f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,19 @@ { - "name": "mongodb-mcp-server", + "name": "@mongodb-js/mongodb-mcp-server", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mongodb-mcp-server", + "name": "@mongodb-js/mongodb-mcp-server", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { "@types/express": "^5.0.1" }, + "bin": { + "mongodb-mcp-server": "dist/index.js" + }, "devDependencies": { "@eslint/js": "^9.24.0", "@modelcontextprotocol/inspector": "^0.8.2", diff --git a/package.json b/package.json index 184a2abaf..148cbc7f7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "description": "MongoDB Model Context Protocol Server", "version": "0.0.0", "main": "dist/index.js", + "type": "module", "author": "MongoDB ", "homepage": "https://github.com/mongodb-js/mongodb-mcp-server", "repository": { diff --git a/src/common/atlas/auth.ts b/src/common/atlas/auth.ts new file mode 100644 index 000000000..64c594378 --- /dev/null +++ b/src/common/atlas/auth.ts @@ -0,0 +1,32 @@ +import { ApiClient } from "../../client.js"; +import { State } from "../../state.js"; + +export async function ensureAuthenticated(state: State, apiClient: ApiClient): Promise { + if (!(await isAuthenticated(state, apiClient))) { + throw new Error("Not authenticated"); + } +} + +export async function isAuthenticated(state: State, apiClient: ApiClient): Promise { + switch (state.auth.status) { + case "not_auth": + return false; + case "requested": + try { + if (!state.auth.code) { + return false; + } + await apiClient.retrieveToken(state.auth.code.device_code); + return !!state.auth.token; + } catch { + return false; + } + case "issued": + if (!state.auth.token) { + return false; + } + return await apiClient.validateToken(); + default: + throw new Error("Unknown authentication status"); + } +} diff --git a/src/config.ts b/src/config.ts index 29b5eeb37..00208768c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,20 @@ import path from "path"; import fs from "fs"; -const packageMetadata = fs.readFileSync(path.resolve("./package.json"), "utf8"); +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const dir = path.resolve(path.join(dirname(__filename), "..")); + +const packageMetadata = fs.readFileSync(path.join(dir, "package.json"), "utf8"); const packageJson = JSON.parse(packageMetadata); export const config = { version: packageJson.version, apiBaseURL: process.env.API_BASE_URL || "https://cloud.mongodb.com/", clientID: process.env.CLIENT_ID || "0oabtxactgS3gHIR0297", - stateFile: process.env.STATE_FILE || path.resolve("./state.json"), + stateFile: process.env.STATE_FILE || path.join(dir, "state.json"), projectID: process.env.PROJECT_ID, userAgent: `AtlasMCP/${packageJson.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`, }; diff --git a/src/resources/atlas/clusters.ts b/src/resources/atlas/clusters.ts new file mode 100644 index 000000000..4b23a0578 --- /dev/null +++ b/src/resources/atlas/clusters.ts @@ -0,0 +1,33 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";; +import { ResourceTemplateBase } from "../base.js"; +import { ensureAuthenticated } from "../../common/atlas/auth.js"; + +export class ClustersResource extends ResourceTemplateBase { + name = "clusters"; + metadata = { + description: "MongoDB Atlas clusters" + }; + template = new ResourceTemplate("atlas://clusters", { list: undefined }); + + async execute(uri: URL, { projectId }: { projectId: string }) { + await ensureAuthenticated(this.state, this.apiClient); + + const clusters = await this.apiClient.listProjectClusters(projectId); + + if (!clusters || clusters.results.length === 0) { + return { + contents: [], + }; + } + + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(clusters.results), + }, + ], + }; + } +}; diff --git a/src/resources/atlas/projects.ts b/src/resources/atlas/projects.ts new file mode 100644 index 000000000..f0ad5a6df --- /dev/null +++ b/src/resources/atlas/projects.ts @@ -0,0 +1,37 @@ +import { ensureAuthenticated } from "../../common/atlas/auth.js"; +import { ResourceUriBase } from "../base.js"; + +export class ProjectsResource extends ResourceUriBase { + name = "projects"; + metadata = { + description: "MongoDB Atlas projects" + }; + uri = "atlas://projects"; + + async execute(uri: URL) { + await ensureAuthenticated(this.state, this.apiClient); + + const projects = await this.apiClient.listProjects(); + + if (!projects) { + return { + contents: [], + }; + } + + const projectList = projects.results.map((project) => ({ + id: project.id, + name: project.name, + })); + + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(projectList), + }, + ], + }; + } +}; diff --git a/src/resources/base.ts b/src/resources/base.ts new file mode 100644 index 000000000..0ead41691 --- /dev/null +++ b/src/resources/base.ts @@ -0,0 +1,49 @@ +import { McpServer, ResourceMetadata, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";; +import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";; +import { State } from "../state.js"; +import { ApiClient } from "../client.js"; +import { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";; +import { Variables } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";; + +abstract class ResourceCommonBase { + protected abstract name: string; + protected abstract metadata?: ResourceMetadata; + + constructor(protected state: State, protected apiClient: ApiClient) { } +} + +export abstract class ResourceUriBase extends ResourceCommonBase { + protected abstract uri: string; + + abstract execute(uri: URL, extra: RequestHandlerExtra): ReadResourceResult | Promise; + + register(server: McpServer) { + server.resource( + this.name, + this.uri, + this.metadata || {}, + (uri: URL, extra: RequestHandlerExtra) => { + return this.execute(uri, extra); + } + ); + } +} + +export abstract class ResourceTemplateBase extends ResourceCommonBase { + protected abstract template: ResourceTemplate; + + abstract execute(uri: URL, variables: Variables, extra: RequestHandlerExtra): ReadResourceResult | Promise; + + register(server: McpServer) { + server.resource( + this.name, + this.template, + this.metadata || {}, + (uri: URL, variables: Variables, extra: RequestHandlerExtra) => { + return this.execute(uri, variables, extra); + } + ); + } +} + +export type ResourceBase = ResourceTemplateBase | ResourceUriBase; diff --git a/src/resources/register.ts b/src/resources/register.ts new file mode 100644 index 000000000..271f39880 --- /dev/null +++ b/src/resources/register.ts @@ -0,0 +1,14 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ApiClient } from '../client'; +import { State } from '../state'; +import { ProjectsResource } from './atlas/projects.js'; +import { ClustersResource } from './atlas/clusters.js'; + +export function registerResources(server: McpServer, state: State, apiClient: ApiClient) { + const projectsResource = new ProjectsResource(state, apiClient); + const clustersResource = new ClustersResource(state, apiClient); + + projectsResource.register(server); + clustersResource.register(server); +} + diff --git a/src/server.ts b/src/server.ts index 052be94b5..91781962b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,10 +1,10 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";; import { ApiClient } from "./client.js"; import { State, saveState, loadState } 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"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";; import { config } from "./config.js"; +import { registerResources } from "./resources/register.js"; +import { registerTools } from "./tools/register.js"; export class Server { state: State | undefined = undefined; @@ -39,9 +39,10 @@ export class Server { version: config.version, }); - registerAtlasTools(server, this.state!, this.apiClient!); - registerMongoDBTools(server, this.state!); + registerResources(server, this.state!, this.apiClient!); + registerTools(server, this.state!, this.apiClient!); + return server; } diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts deleted file mode 100644 index c504fdf33..000000000 --- a/src/tools/atlas/atlasTool.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ZodRawShape } from "zod"; -import { ToolBase } from "../tool.js"; -import { ApiClient } from "../../client.js"; -import { State } from "../../state.js"; - -export abstract class AtlasToolBase extends ToolBase { - constructor( - state: State, - protected apiClient: ApiClient - ) { - super(state); - } -} diff --git a/src/tools/atlas/auth.ts b/src/tools/atlas/auth.ts index d8db34f93..ae994d570 100644 --- a/src/tools/atlas/auth.ts +++ b/src/tools/atlas/auth.ts @@ -1,45 +1,21 @@ -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { ApiClient } from "../../client.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";; import { log } from "../../logger.js"; import { saveState } from "../../state.js"; +import { isAuthenticated } from "../../common/atlas/auth.js"; +import { ToolBase } from "../base.js"; +import { ZodRawShape } from "zod"; +import { ApiClient } from "../../client.js"; import { State } from "../../state.js"; -import { AtlasToolBase } from "./atlasTool.js"; - -export async function ensureAuthenticated(state: State, apiClient: ApiClient): Promise { - if (!(await isAuthenticated(state, apiClient))) { - throw new Error("Not authenticated"); - } -} - -export async function isAuthenticated(state: State, apiClient: ApiClient): Promise { - switch (state.auth.status) { - case "not_auth": - return false; - case "requested": - try { - if (!state.auth.code) { - return false; - } - await apiClient.retrieveToken(state.auth.code.device_code); - return !!state.auth.token; - } catch { - return false; - } - case "issued": - if (!state.auth.token) { - return false; - } - return await apiClient.validateToken(); - default: - throw new Error("Unknown authentication status"); - } -} -export class AuthTool extends AtlasToolBase { +export class AuthTool extends ToolBase { protected name = "auth"; protected description = "Authenticate to MongoDB Atlas"; protected argsShape = {}; + constructor(state: State, private apiClient: ApiClient) { + super(state); + } + private async isAuthenticated(): Promise { return isAuthenticated(this.state!, this.apiClient); } diff --git a/src/tools/atlas/listClusters.ts b/src/tools/atlas/listClusters.ts deleted file mode 100644 index b54c5744c..000000000 --- a/src/tools/atlas/listClusters.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { z, ZodOptional, ZodString } from "zod"; -import { ApiClient, AtlasCluster } from "../../client.js"; -import { config } from "../../config.js"; -import { ensureAuthenticated } from "./auth.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { AtlasToolBase } from "./atlasTool.js"; -import { State } from "../../state.js"; - -export class ListClustersTool extends AtlasToolBase<{ - projectId: ZodString | ZodOptional; -}> { - protected name = "listClusters"; - protected description = "List MongoDB Atlas clusters"; - protected argsShape; - - constructor(state: State, apiClient: ApiClient) { - super(state, apiClient); - - let projectIdFilter: ZodString | ZodOptional = z - .string() - .describe("Optional Atlas project ID to filter clusters"); - - if (config.projectID) { - projectIdFilter = projectIdFilter.optional(); - } - - this.argsShape = { - projectId: projectIdFilter, - }; - } - - protected async execute({ projectId }: { projectId: string }): Promise { - await ensureAuthenticated(this.state, this.apiClient); - - let clusters: AtlasCluster[] | undefined = undefined; - let introText = "Here are your MongoDB Atlas clusters:"; - - const selectedProjectId = projectId || config.projectID; - if (!selectedProjectId) { - return { - content: [{ type: "text", text: "No project ID provided. Please specify a project ID." }], - }; - } - - const project = await this.apiClient.getProject(selectedProjectId); - - const data = await this.apiClient.listProjectClusters(project.id); - clusters = data.results || []; - - introText = `Here are the clusters in project "${project.name}" (${project.id}):`; - - if (clusters.length === 0) { - return { - content: [ - { - type: "text", - text: "No clusters found. You may need to create a cluster in your MongoDB Atlas account.", - }, - ], - }; - } - - const formattedClusters = this.formatClustersTable(clusters); - - return { - content: [ - { type: "text", text: introText }, - { type: "text", text: formattedClusters }, - ], - }; - } - - private formatClustersTable(clusters: AtlasCluster[]): string { - if (clusters.length === 0) { - return "No clusters found."; - } - const header = `Cluster Name | State | MongoDB Version | Region | Connection String - ----------------|----------------|----------------|----------------|----------------|----------------`; - const rows = clusters - .map((cluster) => { - const region = cluster.providerSettings?.regionName || "N/A"; - const connectionString = cluster.connectionStrings?.standard || "N/A"; - const mongoDBVersion = cluster.mongoDBVersion || "N/A"; - return `${cluster.name} | ${cluster.stateName} | ${mongoDBVersion} | ${region} | ${connectionString}`; - }) - .join("\n"); - return `${header}\n${rows}`; - } -} diff --git a/src/tools/atlas/listProjects.ts b/src/tools/atlas/listProjects.ts deleted file mode 100644 index e3f4af04c..000000000 --- a/src/tools/atlas/listProjects.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ensureAuthenticated } from "./auth.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { AtlasToolBase } from "./atlasTool.js"; - -export class ListProjectsTool extends AtlasToolBase { - protected name = "listProjects"; - protected description = "List MongoDB Atlas projects"; - protected argsShape = {}; - - protected async execute(): Promise { - await ensureAuthenticated(this.state, this.apiClient); - - const projectsData = await this.apiClient!.listProjects(); - const projects = projectsData.results || []; - - if (projects.length === 0) { - return { - content: [{ type: "text", text: "No projects found in your MongoDB Atlas account." }], - }; - } - - // Format projects as a table - const header = `Project Name | Project ID | Created At -----------------|----------------|----------------`; - const rows = projects - .map((project) => { - const createdAt = project.created ? new Date(project.created.$date).toLocaleString() : "N/A"; - return `${project.name} | ${project.id} | ${createdAt}`; - }) - .join("\n"); - const formattedProjects = `${header}\n${rows}`; - return { - content: [ - { type: "text", text: "Here are your MongoDB Atlas projects:" }, - { type: "text", text: formattedProjects }, - ], - }; - } -} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts deleted file mode 100644 index c5de4a53c..000000000 --- a/src/tools/atlas/tools.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ZodRawShape } from "zod"; -import { ToolBase } from "../tool.js"; -import { ApiClient } from "../../client.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { State } from "../../state.js"; -import { AuthTool } from "./auth.js"; -import { ListClustersTool } from "./listClusters.js"; -import { ListProjectsTool } from "./listProjects.js"; - -export function registerAtlasTools(server: McpServer, state: State, apiClient: ApiClient) { - const tools: ToolBase[] = [ - new AuthTool(state, apiClient), - new ListClustersTool(state, apiClient), - new ListProjectsTool(state, apiClient), - ]; - - for (const tool of tools) { - tool.register(server); - } -} diff --git a/src/tools/tool.ts b/src/tools/base.ts similarity index 99% rename from src/tools/tool.ts rename to src/tools/base.ts index 7227fa97f..e39319634 100644 --- a/src/tools/tool.ts +++ b/src/tools/base.ts @@ -1,7 +1,7 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";; import { z, ZodRawShape, ZodTypeAny } from "zod"; import { log } from "../logger.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";; import { State } from "../state.js"; export abstract class ToolBase { diff --git a/src/tools/mongodb/index.ts b/src/tools/mongodb/index.ts deleted file mode 100644 index 5b6d13608..000000000 --- a/src/tools/mongodb/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { State } from "../../state.js"; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function registerMongoDBTools(server: McpServer, state: State) {} diff --git a/src/tools/register.ts b/src/tools/register.ts new file mode 100644 index 000000000..95f28ff15 --- /dev/null +++ b/src/tools/register.ts @@ -0,0 +1,14 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ApiClient } from '../client'; +import { State } from '../state'; +import { AuthTool } from '../tools/atlas/auth.js'; + +export function registerTools(server: McpServer, state: State, apiClient: ApiClient) { + // atlas + const authTool = new AuthTool(state, apiClient); + authTool.register(server); + + // mongodb + // TODO HERE +} +