couchbase-mcp-server 0.4.0rc1__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tools/query.py CHANGED
@@ -10,22 +10,23 @@ from typing import Any
10
10
  from lark_sqlpp import modifies_data, modifies_structure, parse_sqlpp
11
11
  from mcp.server.fastmcp import Context
12
12
 
13
+ from utils.connection import connect_to_bucket
13
14
  from utils.constants import MCP_SERVER_NAME
14
- from utils.context import ensure_bucket_connection
15
+ from utils.context import get_cluster_connection
15
16
 
16
17
  logger = logging.getLogger(f"{MCP_SERVER_NAME}.tools.query")
17
18
 
18
19
 
19
20
  def get_schema_for_collection(
20
- ctx: Context, scope_name: str, collection_name: str
21
+ ctx: Context, bucket_name: str, scope_name: str, collection_name: str
21
22
  ) -> dict[str, Any]:
22
23
  """Get the schema for a collection in the specified scope.
23
24
  Returns a dictionary with the collection name and the schema returned by running INFER query on the Couchbase collection.
24
25
  """
25
26
  schema = {"collection_name": collection_name, "schema": []}
26
27
  try:
27
- query = f"INFER {collection_name}"
28
- result = run_sql_plus_plus_query(ctx, scope_name, query)
28
+ query = f"INFER `{collection_name}`"
29
+ result = run_sql_plus_plus_query(ctx, bucket_name, scope_name, query)
29
30
  # Result is a list of list of schemas. We convert it to a list of schemas.
30
31
  if result:
31
32
  schema["schema"] = result[0]
@@ -36,10 +37,13 @@ def get_schema_for_collection(
36
37
 
37
38
 
38
39
  def run_sql_plus_plus_query(
39
- ctx: Context, scope_name: str, query: str
40
+ ctx: Context, bucket_name: str, scope_name: str, query: str
40
41
  ) -> list[dict[str, Any]]:
41
42
  """Run a SQL++ query on a scope and return the results as a list of JSON objects."""
42
- bucket = ensure_bucket_connection(ctx)
43
+ cluster = get_cluster_connection(ctx)
44
+
45
+ bucket = connect_to_bucket(cluster, bucket_name)
46
+
43
47
  app_context = ctx.request_context.lifespan_context
44
48
  read_only_query_mode = app_context.read_only_query_mode
45
49
  logger.info(f"Running SQL++ queries in read-only mode: {read_only_query_mode}")
@@ -75,3 +79,20 @@ def run_sql_plus_plus_query(
75
79
  except Exception as e:
76
80
  logger.error(f"Error running query: {e!s}", exc_info=True)
77
81
  raise
82
+
83
+
84
+ # Don't expose this function to the MCP server until we have a use case
85
+ def run_cluster_query(ctx: Context, query: str, **kwargs: Any) -> list[dict[str, Any]]:
86
+ """Run a query on the cluster object and return the results as a list of JSON objects."""
87
+
88
+ cluster = get_cluster_connection(ctx)
89
+ results = []
90
+
91
+ try:
92
+ result = cluster.query(query, **kwargs)
93
+ for row in result:
94
+ results.append(row)
95
+ return results
96
+ except Exception as e:
97
+ logger.error(f"Error running query: {e}")
98
+ raise
tools/server.py CHANGED
@@ -1,17 +1,20 @@
1
1
  """
2
2
  Tools for server operations.
3
3
 
4
- This module contains tools for getting the server status, testing the connection, and getting the scopes and collections in the bucket.
4
+ This module contains tools for getting the server status, testing the connection, and getting the buckets in the cluster, the scopes and collections in the bucket.
5
5
  """
6
6
 
7
+ import json
7
8
  import logging
8
9
  from typing import Any
9
10
 
10
11
  from mcp.server.fastmcp import Context
11
12
 
13
+ from tools.query import run_cluster_query
12
14
  from utils.config import get_settings
15
+ from utils.connection import connect_to_bucket
13
16
  from utils.constants import MCP_SERVER_NAME
14
- from utils.context import ensure_bucket_connection
17
+ from utils.context import get_cluster_connection
15
18
 
16
19
  logger = logging.getLogger(f"{MCP_SERVER_NAME}.tools.server")
17
20
 
@@ -26,15 +29,16 @@ def get_server_configuration_status(ctx: Context) -> dict[str, Any]:
26
29
  configuration = {
27
30
  "connection_string": settings.get("connection_string", "Not set"),
28
31
  "username": settings.get("username", "Not set"),
29
- "bucket_name": settings.get("bucket_name", "Not set"),
30
32
  "read_only_query_mode": settings.get("read_only_query_mode", True),
31
33
  "password_configured": bool(settings.get("password")),
34
+ "ca_cert_path_configured": bool(settings.get("ca_cert_path")),
35
+ "client_cert_path_configured": bool(settings.get("client_cert_path")),
36
+ "client_key_path_configured": bool(settings.get("client_key_path")),
32
37
  }
33
38
 
34
39
  app_context = ctx.request_context.lifespan_context
35
40
  connection_status = {
36
41
  "cluster_connected": app_context.cluster is not None,
37
- "bucket_connected": app_context.bucket is not None,
38
42
  }
39
43
 
40
44
  return {
@@ -45,39 +49,46 @@ def get_server_configuration_status(ctx: Context) -> dict[str, Any]:
45
49
  }
46
50
 
47
51
 
48
- def test_cluster_connection(ctx: Context) -> dict[str, Any]:
49
- """Test the connection to Couchbase cluster and bucket.
52
+ def test_cluster_connection(
53
+ ctx: Context, bucket_name: str | None = None
54
+ ) -> dict[str, Any]:
55
+ """Test the connection to Couchbase cluster and optionally to a bucket.
50
56
  This tool verifies the connection to the Couchbase cluster and bucket by establishing the connection if it is not already established.
57
+ If bucket name is not provided, it will not try to connect to the bucket specified in the MCP server settings.
51
58
  Returns connection status and basic cluster information.
52
59
  """
53
60
  try:
54
- bucket = ensure_bucket_connection(ctx)
55
-
56
- # Test basic connectivity by getting bucket name
57
- bucket_name = bucket.name
61
+ cluster = get_cluster_connection(ctx)
62
+ bucket = None
63
+ if bucket_name:
64
+ bucket = connect_to_bucket(cluster, bucket_name)
58
65
 
59
66
  return {
60
67
  "status": "success",
61
- "cluster_connected": True,
62
- "bucket_connected": True,
68
+ "cluster_connected": cluster.connected,
69
+ "bucket_connected": bucket is not None,
63
70
  "bucket_name": bucket_name,
64
- "message": "Successfully connected to Couchbase cluster and bucket",
71
+ "message": "Successfully connected to Couchbase cluster",
65
72
  }
66
73
  except Exception as e:
67
74
  return {
68
75
  "status": "error",
69
76
  "cluster_connected": False,
70
77
  "bucket_connected": False,
78
+ "bucket_name": bucket_name,
71
79
  "error": str(e),
72
- "message": "Failed to connect to Couchbase",
80
+ "message": "Failed to connect to Couchbase cluster",
73
81
  }
74
82
 
75
83
 
76
- def get_scopes_and_collections_in_bucket(ctx: Context) -> dict[str, list[str]]:
84
+ def get_scopes_and_collections_in_bucket(
85
+ ctx: Context, bucket_name: str
86
+ ) -> dict[str, list[str]]:
77
87
  """Get the names of all scopes and collections in the bucket.
78
88
  Returns a dictionary with scope names as keys and lists of collection names as values.
79
89
  """
80
- bucket = ensure_bucket_connection(ctx)
90
+ cluster = get_cluster_connection(ctx)
91
+ bucket = connect_to_bucket(cluster, bucket_name)
81
92
  try:
82
93
  scopes_collections = {}
83
94
  collection_manager = bucket.collections()
@@ -89,3 +100,81 @@ def get_scopes_and_collections_in_bucket(ctx: Context) -> dict[str, list[str]]:
89
100
  except Exception as e:
90
101
  logger.error(f"Error getting scopes and collections: {e}")
91
102
  raise
103
+
104
+
105
+ def get_buckets_in_cluster(ctx: Context) -> list[str]:
106
+ """Get the names of all the accessible buckets in the cluster."""
107
+ cluster = get_cluster_connection(ctx)
108
+ bucket_manager = cluster.buckets()
109
+ buckets_with_settings = bucket_manager.get_all_buckets()
110
+
111
+ buckets = []
112
+ for bucket in buckets_with_settings:
113
+ buckets.append(bucket.name)
114
+
115
+ return buckets
116
+
117
+
118
+ def get_scopes_in_bucket(ctx: Context, bucket_name: str) -> list[str]:
119
+ """Get the names of all scopes in the given bucket."""
120
+ cluster = get_cluster_connection(ctx)
121
+ bucket = connect_to_bucket(cluster, bucket_name)
122
+ try:
123
+ scopes = bucket.collections().get_all_scopes()
124
+ return [scope.name for scope in scopes]
125
+ except Exception as e:
126
+ logger.error(f"Error getting scopes in the bucket {bucket_name}: {e}")
127
+ raise
128
+
129
+
130
+ def get_collections_in_scope(
131
+ ctx: Context, bucket_name: str, scope_name: str
132
+ ) -> list[str]:
133
+ """Get the names of all collections in the given scope and bucket."""
134
+
135
+ # Get the collections in the scope using system:all_keyspaces collection
136
+ query = "SELECT DISTINCT(name) as collection_name FROM system:all_keyspaces where `bucket`=$bucket_name and `scope`=$scope_name"
137
+ results = run_cluster_query(
138
+ ctx, query, bucket_name=bucket_name, scope_name=scope_name
139
+ )
140
+ return [result["collection_name"] for result in results]
141
+
142
+
143
+ def get_cluster_health_and_services(
144
+ ctx: Context, bucket_name: str | None = None
145
+ ) -> dict[str, Any]:
146
+ """Get cluster health status and list of all running services.
147
+
148
+ This tool provides health monitoring by:
149
+ - Getting health status of all running services with latency information (via ping)
150
+ - Listing all services running on the cluster with their endpoints
151
+ - Showing connection status and node information for each service
152
+
153
+ If bucket_name is provided, it actively pings services from the perspective of the bucket.
154
+ Otherwise, it uses cluster-level ping to get the health status of the cluster.
155
+
156
+ Returns:
157
+ - Cluster health status with service-level connection details and latency measurements
158
+ """
159
+ try:
160
+ cluster = get_cluster_connection(ctx)
161
+
162
+ if bucket_name:
163
+ # Ping services from the perspective of the bucket
164
+ bucket = connect_to_bucket(cluster, bucket_name)
165
+ result = bucket.ping().as_json()
166
+ else:
167
+ # Ping services from the perspective of the cluster
168
+ result = cluster.ping().as_json()
169
+
170
+ return {
171
+ "status": "success",
172
+ "data": json.loads(result),
173
+ }
174
+ except Exception as e:
175
+ logger.error(f"Error getting cluster health: {e}")
176
+ return {
177
+ "status": "error",
178
+ "error": str(e),
179
+ "message": "Failed to get cluster health and services information",
180
+ }
utils/__init__.py CHANGED
@@ -7,8 +7,6 @@ This module contains utility functions for configuration, connection, and contex
7
7
  # Configuration utilities
8
8
  from .config import (
9
9
  get_settings,
10
- validate_connection_config,
11
- validate_required_param,
12
10
  )
13
11
 
14
12
  # Connection utilities
@@ -33,7 +31,12 @@ from .constants import (
33
31
  # Context utilities
34
32
  from .context import (
35
33
  AppContext,
36
- ensure_bucket_connection,
34
+ get_cluster_connection,
35
+ )
36
+
37
+ # Index utilities
38
+ from .index_utils import (
39
+ fetch_indexes_from_rest_api,
37
40
  )
38
41
 
39
42
  # Note: Individual modules create their own hierarchical loggers using:
@@ -42,14 +45,14 @@ from .context import (
42
45
  __all__ = [
43
46
  # Config
44
47
  "get_settings",
45
- "validate_required_param",
46
- "validate_connection_config",
47
48
  # Connection
48
49
  "connect_to_couchbase_cluster",
49
50
  "connect_to_bucket",
50
51
  # Context
51
52
  "AppContext",
52
- "ensure_bucket_connection",
53
+ "get_cluster_connection",
54
+ # Index utilities
55
+ "fetch_indexes_from_rest_api",
53
56
  # Constants
54
57
  "MCP_SERVER_NAME",
55
58
  "DEFAULT_READ_ONLY_MODE",
utils/config.py CHANGED
@@ -7,32 +7,7 @@ from .constants import MCP_SERVER_NAME
7
7
  logger = logging.getLogger(f"{MCP_SERVER_NAME}.utils.config")
8
8
 
9
9
 
10
- def validate_required_param(
11
- ctx: click.Context, param: click.Parameter, value: str | None
12
- ) -> str:
13
- """Validate that a required parameter is not empty."""
14
- if not value or value.strip() == "":
15
- raise click.BadParameter(f"{param.name} cannot be empty")
16
- return value
17
-
18
-
19
10
  def get_settings() -> dict:
20
11
  """Get settings from Click context."""
21
12
  ctx = click.get_current_context()
22
13
  return ctx.obj or {}
23
-
24
-
25
- def validate_connection_config() -> None:
26
- """Validate that all required parameters for the MCP server are available when needed."""
27
- settings = get_settings()
28
- required_params = ["connection_string", "username", "password", "bucket_name"]
29
- missing_params = []
30
-
31
- for param in required_params:
32
- if not settings.get(param):
33
- missing_params.append(param)
34
-
35
- if missing_params:
36
- error_msg = f"Missing required parameters for the MCP server: {', '.join(missing_params)}"
37
- logger.error(error_msg)
38
- raise ValueError(error_msg)
utils/connection.py CHANGED
@@ -1,7 +1,8 @@
1
1
  import logging
2
+ import os
2
3
  from datetime import timedelta
3
4
 
4
- from couchbase.auth import PasswordAuthenticator
5
+ from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
5
6
  from couchbase.cluster import Bucket, Cluster
6
7
  from couchbase.options import ClusterOptions
7
8
 
@@ -11,15 +12,40 @@ logger = logging.getLogger(f"{MCP_SERVER_NAME}.utils.connection")
11
12
 
12
13
 
13
14
  def connect_to_couchbase_cluster(
14
- connection_string: str, username: str, password: str
15
+ connection_string: str,
16
+ username: str,
17
+ password: str,
18
+ ca_cert_path: str | None = None,
19
+ client_cert_path: str | None = None,
20
+ client_key_path: str | None = None,
15
21
  ) -> Cluster:
16
22
  """Connect to Couchbase cluster and return the cluster object if successful.
23
+ The connection can be established using the client certificate and key or the username and password. Optionally, the CA root certificate path can also be provided.
24
+ Either of the path to the client certificate and key or the username and password should be provided.
25
+ If the client certificate and key are provided, the username and password are not used.
26
+ If both the client certificate and key and the username and password are provided, the client certificate is used for authentication.
17
27
  If the connection fails, it will raise an exception.
18
28
  """
19
29
 
20
30
  try:
21
31
  logger.info("Connecting to Couchbase cluster...")
22
- auth = PasswordAuthenticator(username, password)
32
+ if client_cert_path and client_key_path:
33
+ logger.info("Connecting to Couchbase cluster with client certificate...")
34
+ if not os.path.exists(client_cert_path) or not os.path.exists(
35
+ client_key_path
36
+ ):
37
+ raise FileNotFoundError(
38
+ f"Client certificate files not found at {os.path.basename(client_cert_path)} or {os.path.basename(client_key_path)}."
39
+ )
40
+
41
+ auth = CertificateAuthenticator(
42
+ cert_path=client_cert_path,
43
+ key_path=client_key_path,
44
+ trust_store_path=ca_cert_path,
45
+ )
46
+ else:
47
+ logger.info("Connecting to Couchbase cluster with password...")
48
+ auth = PasswordAuthenticator(username, password, cert_path=ca_cert_path)
23
49
  options = ClusterOptions(auth)
24
50
  options.apply_profile("wan_development")
25
51
 
@@ -38,7 +64,6 @@ def connect_to_bucket(cluster: Cluster, bucket_name: str) -> Bucket:
38
64
  If the operation fails, it will raise an exception.
39
65
  """
40
66
  try:
41
- logger.info(f"Connecting to bucket: {bucket_name}")
42
67
  bucket = cluster.bucket(bucket_name)
43
68
  return bucket
44
69
  except Exception as e:
utils/context.py CHANGED
@@ -1,11 +1,11 @@
1
1
  import logging
2
2
  from dataclasses import dataclass
3
3
 
4
- from couchbase.cluster import Bucket, Cluster
4
+ from couchbase.cluster import Cluster
5
5
  from mcp.server.fastmcp import Context
6
6
 
7
- from utils.config import get_settings, validate_connection_config
8
- from utils.connection import connect_to_bucket, connect_to_couchbase_cluster
7
+ from utils.config import get_settings
8
+ from utils.connection import connect_to_couchbase_cluster
9
9
  from utils.constants import MCP_SERVER_NAME
10
10
 
11
11
  logger = logging.getLogger(f"{MCP_SERVER_NAME}.utils.context")
@@ -16,7 +16,6 @@ class AppContext:
16
16
  """Context for the MCP server."""
17
17
 
18
18
  cluster: Cluster | None = None
19
- bucket: Bucket | None = None
20
19
  read_only_query_mode: bool = True
21
20
 
22
21
 
@@ -30,51 +29,36 @@ def _set_cluster_in_lifespan_context(ctx: Context) -> None:
30
29
  connection_string = settings.get("connection_string")
31
30
  username = settings.get("username")
32
31
  password = settings.get("password")
32
+ ca_cert_path = settings.get("ca_cert_path")
33
+ client_cert_path = settings.get("client_cert_path")
34
+ client_key_path = settings.get("client_key_path")
35
+
33
36
  cluster = connect_to_couchbase_cluster(
34
37
  connection_string, # type: ignore
35
38
  username, # type: ignore
36
39
  password, # type: ignore
40
+ ca_cert_path,
41
+ client_cert_path,
42
+ client_key_path,
37
43
  )
38
44
  ctx.request_context.lifespan_context.cluster = cluster
39
45
  except Exception as e:
40
46
  logger.error(
41
- f"Failed to connect to Couchbase: {e} \n Please check your connection string, username and password"
47
+ "Failed to connect to Couchbase: %s\n"
48
+ "Verify connection string, and either:\n"
49
+ "- Username/password are correct, or\n"
50
+ "- Client certificate and key exist and match server mapping.\n"
51
+ "If using self-signed or custom CA, set CB_CA_CERT_PATH to the CA file.",
52
+ e,
42
53
  )
43
54
  raise
44
55
 
45
56
 
46
- def _set_bucket_in_lifespan_context(ctx: Context) -> None:
47
- """Set the bucket in the lifespan context.
48
- If the bucket is not set, it will try to connect to the bucket using the cluster object in the lifespan context.
57
+ def get_cluster_connection(ctx: Context) -> Cluster:
58
+ """Return the cluster connection from the lifespan context.
49
59
  If the cluster is not set, it will try to connect to the cluster using the connection string, username, and password.
50
- If the connection fails, it will raise an exception.
51
60
  """
52
- settings = get_settings()
53
- bucket_name = settings.get("bucket_name")
54
-
55
- # If the bucket is not set, try to connect to the bucket using the cluster object in the lifespan context
56
- app_context = ctx.request_context.lifespan_context
57
-
58
- try:
59
- # If the cluster is not set, try to connect to the cluster
60
- if not app_context.cluster:
61
- _set_cluster_in_lifespan_context(ctx)
62
- cluster = app_context.cluster
63
-
64
- # Try to connect to the bucket using the cluster object
65
- bucket = connect_to_bucket(cluster, bucket_name) # type: ignore
66
- app_context.bucket = bucket
67
- except Exception as e:
68
- logger.error(
69
- f"Failed to connect to bucket: {e} \n Please check your bucket name and credentials."
70
- )
71
- raise
72
-
73
-
74
- def ensure_bucket_connection(ctx: Context) -> Bucket:
75
- """Ensure bucket connection is established and return the bucket object."""
76
- validate_connection_config()
77
61
  app_context = ctx.request_context.lifespan_context
78
- if not app_context.bucket:
79
- _set_bucket_in_lifespan_context(ctx)
80
- return app_context.bucket
62
+ if not app_context.cluster:
63
+ _set_cluster_in_lifespan_context(ctx)
64
+ return app_context.cluster