diff --git a/README.md b/README.md index c52e53e..d009c11 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ An [MCP](https://modelcontextprotocol.io/) server implementation of Couchbase th - Get the status of the MCP server - Check the cluster credentials by connecting to the cluster + ## Prerequisites - Python 3.10 or higher. @@ -71,7 +72,7 @@ git clone https://github.com/Couchbase-Ecosystem/mcp-server-couchbase.git #### Server Configuration using Source for MCP Clients This is the common configuration for the MCP clients such as Claude Desktop, Cursor, Windsurf Editor. - +Using Basic Auth: ```json { "mcpServers": { @@ -87,6 +88,31 @@ This is the common configuration for the MCP clients such as Claude Desktop, Cur "CB_CONNECTION_STRING": "couchbases://connection-string", "CB_USERNAME": "username", "CB_PASSWORD": "password", + "CB_BUCKET_NAME": "bucket_name", + "CA_CERT_PATH" : "path/to/ca.crt" + } + } + } +} +``` + +Using mTLS: + +```json +{ + "mcpServers": { + "couchbase": { + "command": "uv", + "args": [ + "--directory", + "path/to/cloned/repo/mcp-server-couchbase/", + "run", + "src/mcp_server.py" + ], + "env": { + "CB_CONNECTION_STRING": "couchbases://connection-string", + "CLIENT_CERT_PATH": "path/to/client/cert_and_key/", + "CA_CERT_PATH" : "path/to/ca.crt", "CB_BUCKET_NAME": "bucket_name" } } @@ -105,14 +131,19 @@ The server can be configured using environment variables or command line argumen | Environment Variable | CLI Argument | Description | Default | | ----------------------------- | ------------------------ | ------------------------------------------ | ------------ | | `CB_CONNECTION_STRING` | `--connection-string` | Connection string to the Couchbase cluster | **Required** | -| `CB_USERNAME` | `--username` | Username with bucket access | **Required** | -| `CB_PASSWORD` | `--password` | Password for authentication | **Required** | +| `CB_USERNAME` | `--username` | Username with bucket access | **Required (or client cert)** | +| `CB_PASSWORD` | `--password` | Password for authentication | **Required(or client cert)** | +| `CLIENT_CERT_PATH` | `--client-cert-path` | Path to client.pem/client.key folder | **Required(or user/pass)** | +| `CA_CERT_PATH` | `--ca-cert-path` | Path to CA certificate for TLS connection | `None` | | `CB_BUCKET_NAME` | `--bucket-name` | Name of the bucket to access | **Required** | | `CB_MCP_READ_ONLY_QUERY_MODE` | `--read-only-query-mode` | Prevent data modification queries | `true` | | `CB_MCP_TRANSPORT` | `--transport` | Transport mode: `stdio`, `http`, `sse` | `stdio` | | `CB_MCP_HOST` | `--host` | Host for HTTP/SSE transport modes | `127.0.0.1` | | `CB_MCP_PORT` | `--port` | Port for HTTP/SSE transport modes | `8000` | + + + You can also check the version of the server using: ```bash diff --git a/src/mcp_server.py b/src/mcp_server.py index dcb2f74..f2b63fc 100644 --- a/src/mcp_server.py +++ b/src/mcp_server.py @@ -1,13 +1,10 @@ -""" -Couchbase MCP Server -""" - +import os import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager - import click from mcp.server.fastmcp import FastMCP +from utils.config import validate_authentication_method # Import tools from tools import ALL_TOOLS @@ -77,6 +74,20 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: envvar="CB_PASSWORD", help="Couchbase database password (required for operations)", ) +@click.option( + '--ca-cert-path', + envvar="CA_CERT_PATH", + type=click.Path(exists=True), + default=None, + help='Path to Server TLS certificate, required for secure connections.') + +@click.option( + "--client-cert-path", + envvar="CLIENT_CERT_PATH", + default=None, + help="Path to client.key and client.pem files for mtls client authentication", +) + @click.option( "--bucket-name", envvar="CB_BUCKET_NAME", @@ -124,6 +135,8 @@ def main( bucket_name, read_only_query_mode, transport, + ca_cert_path, + client_cert_path, host, port, ): @@ -135,10 +148,18 @@ def main( "password": password, "bucket_name": bucket_name, "read_only_query_mode": read_only_query_mode, + "ca_cert_path" : ca_cert_path, + "client_cert_path" : client_cert_path, "transport": transport, "host": host, "port": port, - } + } + + try: + validate_authentication_method(ctx.obj) + except Exception as e: + logger.error(f"Failed to validate auth method params: {e}") + raise # Map user-friendly transport names to SDK transport names sdk_transport = NETWORK_TRANSPORTS_SDK_MAPPING.get(transport, transport) diff --git a/src/utils/config.py b/src/utils/config.py index d2f4408..0ce1fbf 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,7 +1,7 @@ import logging import click - +import os from .constants import MCP_SERVER_NAME logger = logging.getLogger(f"{MCP_SERVER_NAME}.utils.config") @@ -21,11 +21,10 @@ def get_settings() -> dict: ctx = click.get_current_context() return ctx.obj or {} - def validate_connection_config() -> None: """Validate that all required parameters for the MCP server are available when needed.""" settings = get_settings() - required_params = ["connection_string", "username", "password", "bucket_name"] + required_params = ["connection_string", "bucket_name"] missing_params = [] for param in required_params: @@ -36,3 +35,50 @@ def validate_connection_config() -> None: error_msg = f"Missing required parameters for the MCP server: {', '.join(missing_params)}" logger.error(error_msg) raise ValueError(error_msg) + + try: + validate_authentication_method(settings) + except Exception as e: + logger.error(f"Error validating authentication method: {e}") + raise + + +def validate_authentication_method(params : dict ) -> bool: + """Util function to verify either user/password combination OR client certificates have been included""" + username = params.get("username") + password = params.get("password") + client_cert_path = params.get("client_cert_path") + ca_cert_path = params.get("ca_cert_path") + + # Strip values to check for empty strings + if username is not None: + username = username.strip() + if password is not None: + password = password.strip() + + if client_cert_path: + client_cert = os.path.join(client_cert_path, "client.pem") + client_key = os.path.join(client_cert_path, "client.key") + + if not os.path.isfile(client_cert) or not os.path.isfile(client_key): + raise click.BadParameter( + f"Client certificate files not found in {client_cert_path}. Required: client.pem and client.key." + ) + + if username or password or username == "" or password =="": + raise click.BadParameter( + "You must use either a client certificate or username/password, not both." + ) + + elif username or password: + if not username or not password: + raise click.BadParameter( + "Both username and password must be provided and non-empty if using basic authentication." + ) + else: + raise click.BadParameter( + "You must provide either a client certificate path or username/password combination, neither received." + ) + + if not ca_cert_path: + logger.warning(f"A trusted CA certificate has not been provided, using local trust store for TLS connections") \ No newline at end of file diff --git a/src/utils/connection.py b/src/utils/connection.py index 9b07589..5df047a 100644 --- a/src/utils/connection.py +++ b/src/utils/connection.py @@ -1,8 +1,8 @@ import logging from datetime import timedelta - -from couchbase.auth import PasswordAuthenticator -from couchbase.cluster import Bucket, Cluster +import os +from couchbase.auth import PasswordAuthenticator, CertificateAuthenticator +from couchbase.cluster import Bucket,Cluster from couchbase.options import ClusterOptions from .constants import MCP_SERVER_NAME @@ -11,18 +11,28 @@ def connect_to_couchbase_cluster( - connection_string: str, username: str, password: str + connection_string: str, username: str, password: str, ca_cert_path : str = None, client_cert_path : str = None ) -> Cluster: """Connect to Couchbase cluster and return the cluster object if successful. + Requires either a username/password or client certificate path. A CA certificate path is optional, if None then local trust store is used. If the connection fails, it will raise an exception. """ - try: logger.info("Connecting to Couchbase cluster...") - auth = PasswordAuthenticator(username, password) + if client_cert_path: + + tls_conf = { + "cert_path" : os.path.join(client_cert_path, "client.pem"), + "key_path" : os.path.join(client_cert_path, "client.key"), + } + #set ca cert as trust store if provided + if ca_cert_path: + tls_conf["trust_store_path"] = ca_cert_path + auth = CertificateAuthenticator(**tls_conf) + else: + auth = PasswordAuthenticator(username, password, cert_path = ca_cert_path) options = ClusterOptions(auth) options.apply_profile("wan_development") - cluster = Cluster(connection_string, options) # type: ignore cluster.wait_until_ready(timedelta(seconds=5)) diff --git a/src/utils/context.py b/src/utils/context.py index a549160..592882c 100644 --- a/src/utils/context.py +++ b/src/utils/context.py @@ -30,10 +30,14 @@ def _set_cluster_in_lifespan_context(ctx: Context) -> None: connection_string = settings.get("connection_string") username = settings.get("username") password = settings.get("password") + ca_cert_path = settings.get("ca_cert_path") + client_cert_path = settings.get("client_cert_path") cluster = connect_to_couchbase_cluster( connection_string, # type: ignore username, # type: ignore password, # type: ignore + ca_cert_path, # type: ignore + client_cert_path # type: ignore ) ctx.request_context.lifespan_context.cluster = cluster except Exception as e: