couchbase-mcp-server 0.5.2rc5__py3-none-any.whl → 0.5.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: couchbase-mcp-server
3
- Version: 0.5.2rc5
3
+ Version: 0.5.3
4
4
  Summary: Couchbase MCP Server - The Developer Data Platform for Critical Applications in Our AI World
5
5
  Project-URL: Homepage, https://github.com/Couchbase-Ecosystem/mcp-server-couchbase
6
6
  Project-URL: Documentation, https://github.com/Couchbase-Ecosystem/mcp-server-couchbase#readme
@@ -19,6 +19,8 @@ Requires-Dist: mcp[cli]<2.0.0,>=1.20.0
19
19
  Requires-Dist: urllib3>=2.0.0
20
20
  Provides-Extra: dev
21
21
  Requires-Dist: pre-commit==4.2.0; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio==0.24.0; extra == 'dev'
23
+ Requires-Dist: pytest==8.3.3; extra == 'dev'
22
24
  Requires-Dist: ruff==0.12.5; extra == 'dev'
23
25
  Description-Content-Type: text/markdown
24
26
 
@@ -45,12 +47,21 @@ An [MCP](https://modelcontextprotocol.io/) server implementation of Couchbase th
45
47
  - Upsert a document by ID to a specified scope and collection
46
48
  - Delete a document by ID from a specified scope and collection
47
49
  - Run a [SQL++ query](https://www.couchbase.com/sqlplusplus/) on a specified scope
50
+ - Queries are automatically scoped to the specified bucket and scope, so use collection names directly (e.g., use `SELECT * FROM users` instead of `SELECT * FROM bucket.scope.users`)
48
51
  - There is an option in the MCP server, `CB_MCP_READ_ONLY_QUERY_MODE` that is set to true by default to disable running SQL++ queries that change the data or the underlying collection structure. Note that the documents can still be updated by ID.
49
52
  - Get the status of the MCP server
50
53
  - Check the cluster credentials by connecting to the cluster
51
54
  - List all indexes in the cluster with their definitions, with optional filtering by bucket, scope, collection and index name.
52
55
  - Get index recommendations from Couchbase Index Advisor for a given SQL++ query to optimize query performance
53
56
  - Get cluster health status and list of all running services
57
+ - Query performance analysis tools:
58
+ - Get longest running queries by average service time
59
+ - Get most frequently executed queries
60
+ - Get queries with the largest response sizes
61
+ - Get queries with the largest result counts
62
+ - Get queries that use a primary index (potential performance concern)
63
+ - Get queries that don't use a covering index
64
+ - Get queries that are not selective (index scans return many more documents than final result)
54
65
 
55
66
  ## Prerequisites
56
67
 
@@ -433,6 +444,21 @@ The Couchbase MCP server can also be used as a managed server in your agentic ap
433
444
  - Check the logs for any errors or warnings that may indicate issues with the MCP server. The location of the logs depend on your MCP client.
434
445
  - If you are observing issues running your MCP server from source after updating your local MCP server repository, try running `uv sync` to update the [dependencies](https://docs.astral.sh/uv/concepts/projects/sync/#syncing-the-environment).
435
446
 
447
+ ## Integration testing
448
+
449
+ We provide high-level MCP integration tests to verify that the server exposes the expected tools and that they can be invoked against a demo Couchbase cluster.
450
+
451
+ 1. Export demo cluster credentials:
452
+ - `CB_CONNECTION_STRING`
453
+ - `CB_USERNAME`
454
+ - `CB_PASSWORD`
455
+ - Optional: `CB_MCP_TEST_BUCKET` (a bucket to probe during the tests)
456
+ 2. Run the tests:
457
+
458
+ ```bash
459
+ uv run pytest tests/ -v
460
+ ```
461
+
436
462
  ---
437
463
 
438
464
  ## 👩‍💻 Contributing
@@ -1,10 +1,10 @@
1
1
  certs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  certs/capella_root_ca.pem,sha256=SuSjgKclcQQg0kheTRd3dg6B0FUsUy717T5n3xcAU_E,1131
3
3
  mcp_server.py,sha256=dvHQR-55JhuXX_bj2hLU0ek411qPp0qHEKqAmPdwjKU,4991
4
- tools/__init__.py,sha256=otQd8IiEILtl1jgorEO1E9KqL_2xBBlqwnVHejoCPE0,1668
4
+ tools/__init__.py,sha256=72DlDEv1j_IpZTp9rL1fA5QGFXp5eburCDObGqpPuFc,2462
5
5
  tools/index.py,sha256=cCBr0ptFBVc-HN5SoCauQAh2DsAP_Is8NPUSa6QcLM0,6682
6
6
  tools/kv.py,sha256=NGUs43iuXElj9rYe4RCyCStqoh5y1fUgbg1oWuU4WeQ,2493
7
- tools/query.py,sha256=fSSG7XuxnND1lHwI-r8y_M3h6f82vCXxCwB_A2wHN-M,3598
7
+ tools/query.py,sha256=5fYrMm6ReXuDn7uu1QdVxoCtUnaqaIFPcAWBNhiTa4Y,11303
8
8
  tools/server.py,sha256=4krPjBoQmUHUmOE03T0CcCGFJtoaeHVS91oOrYj8JsA,6670
9
9
  utils/__init__.py,sha256=Fcbp-VIK0Gwwy7hl1AjV9NBKZsscmOV2vYdTBam5M3A,1361
10
10
  utils/config.py,sha256=B6H_JYDn6uxtu9juM924zdvNQgSaHh_u6rYME3_0_xQ,268
@@ -12,8 +12,8 @@ utils/connection.py,sha256=NtAU4pmHMZubSJcs_X_lai9o8dih5mW0RyrRdmyp1Po,2892
12
12
  utils/constants.py,sha256=w0zvQ1zMzJBg44Yl3aQW8KfaaRPn0BgPOLEe8xLeLSE,487
13
13
  utils/context.py,sha256=XZL4M70BMdFBptJ9sT0zxhEey-EvvoSKZJrP_sb7q-A,2286
14
14
  utils/index_utils.py,sha256=W0rvoBXU_2aB9m-HDlLChZoMzvlIX6FUWF6RTsYGfYM,10910
15
- couchbase_mcp_server-0.5.2rc5.dist-info/METADATA,sha256=sf0BmIE-WHJj-uf-GCXrGYWYaP2tE6hNjhic6iblthQ,21038
16
- couchbase_mcp_server-0.5.2rc5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
- couchbase_mcp_server-0.5.2rc5.dist-info/entry_points.txt,sha256=iU5pF4kIMTnNhoMPHhdH-k8o1Fmxb_iM9qJHCZcL6Ak,57
18
- couchbase_mcp_server-0.5.2rc5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
19
- couchbase_mcp_server-0.5.2rc5.dist-info/RECORD,,
15
+ couchbase_mcp_server-0.5.3.dist-info/METADATA,sha256=-UqmmCTDyeujZ_sDWuPEsngT0ufZ4iAJaQUiPBjD3ls,22182
16
+ couchbase_mcp_server-0.5.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
17
+ couchbase_mcp_server-0.5.3.dist-info/entry_points.txt,sha256=iU5pF4kIMTnNhoMPHhdH-k8o1Fmxb_iM9qJHCZcL6Ak,57
18
+ couchbase_mcp_server-0.5.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
19
+ couchbase_mcp_server-0.5.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
tools/__init__.py CHANGED
@@ -16,6 +16,13 @@ from .kv import (
16
16
 
17
17
  # Query tools
18
18
  from .query import (
19
+ get_longest_running_queries,
20
+ get_most_frequent_queries,
21
+ get_queries_not_selective,
22
+ get_queries_not_using_covering_index,
23
+ get_queries_using_primary_index,
24
+ get_queries_with_large_result_count,
25
+ get_queries_with_largest_response_sizes,
19
26
  get_schema_for_collection,
20
27
  run_sql_plus_plus_query,
21
28
  )
@@ -47,6 +54,13 @@ ALL_TOOLS = [
47
54
  get_index_advisor_recommendations,
48
55
  list_indexes,
49
56
  get_cluster_health_and_services,
57
+ get_queries_not_selective,
58
+ get_queries_not_using_covering_index,
59
+ get_queries_using_primary_index,
60
+ get_queries_with_large_result_count,
61
+ get_queries_with_largest_response_sizes,
62
+ get_longest_running_queries,
63
+ get_most_frequent_queries,
50
64
  ]
51
65
 
52
66
  __all__ = [
@@ -65,6 +79,13 @@ __all__ = [
65
79
  "get_index_advisor_recommendations",
66
80
  "list_indexes",
67
81
  "get_cluster_health_and_services",
82
+ "get_queries_not_selective",
83
+ "get_queries_not_using_covering_index",
84
+ "get_queries_using_primary_index",
85
+ "get_queries_with_large_result_count",
86
+ "get_queries_with_largest_response_sizes",
87
+ "get_longest_running_queries",
88
+ "get_most_frequent_queries",
68
89
  # Convenience
69
90
  "ALL_TOOLS",
70
91
  ]
tools/query.py CHANGED
@@ -39,7 +39,15 @@ def get_schema_for_collection(
39
39
  def run_sql_plus_plus_query(
40
40
  ctx: Context, bucket_name: str, scope_name: str, query: str
41
41
  ) -> list[dict[str, Any]]:
42
- """Run a SQL++ query on a scope and return the results as a list of JSON objects."""
42
+ """Run a SQL++ query on a scope and return the results as a list of JSON objects.
43
+
44
+ The query will be run on the specified scope in the specified bucket.
45
+ The query should use collection names directly without bucket/scope prefixes, as the scope context is automatically set.
46
+
47
+ Example:
48
+ query = "SELECT * FROM users WHERE age > 18"
49
+ # Incorrect: "SELECT * FROM bucket.scope.users WHERE age > 18"
50
+ """
43
51
  cluster = get_cluster_connection(ctx)
44
52
 
45
53
  bucket = connect_to_bucket(cluster, bucket_name)
@@ -81,7 +89,6 @@ def run_sql_plus_plus_query(
81
89
  raise
82
90
 
83
91
 
84
- # Don't expose this function to the MCP server until we have a use case
85
92
  def run_cluster_query(ctx: Context, query: str, **kwargs: Any) -> list[dict[str, Any]]:
86
93
  """Run a query on the cluster object and return the results as a list of JSON objects."""
87
94
 
@@ -96,3 +103,258 @@ def run_cluster_query(ctx: Context, query: str, **kwargs: Any) -> list[dict[str,
96
103
  except Exception as e:
97
104
  logger.error(f"Error running query: {e}")
98
105
  raise
106
+
107
+
108
+ def _run_query_tool_with_empty_message(
109
+ ctx: Context,
110
+ query: str,
111
+ *,
112
+ limit: int,
113
+ empty_message: str,
114
+ extra_payload: dict[str, Any] | None = None,
115
+ **query_kwargs: Any,
116
+ ) -> list[dict[str, Any]]:
117
+ """Execute a cluster query with a consistent empty-result response."""
118
+ results = run_cluster_query(ctx, query, limit=limit, **query_kwargs)
119
+
120
+ if results:
121
+ return results
122
+
123
+ payload: dict[str, Any] = {"message": empty_message, "results": []}
124
+ if extra_payload:
125
+ payload.update(extra_payload)
126
+ return [payload]
127
+
128
+
129
+ def get_longest_running_queries(ctx: Context, limit: int = 10) -> list[dict[str, Any]]:
130
+ """Get the N longest running queries from the system:completed_requests catalog.
131
+
132
+ Args:
133
+ limit: Number of queries to return (default: 10)
134
+
135
+ Returns:
136
+ List of queries with their average service time and count
137
+ """
138
+ query = """
139
+ SELECT statement,
140
+ DURATION_TO_STR(avgServiceTime) AS avgServiceTime,
141
+ COUNT(1) AS queries
142
+ FROM system:completed_requests
143
+ WHERE UPPER(statement) NOT LIKE 'INFER %'
144
+ AND UPPER(statement) NOT LIKE 'CREATE INDEX%'
145
+ AND UPPER(statement) NOT LIKE 'CREATE PRIMARY INDEX%'
146
+ AND UPPER(statement) NOT LIKE '% SYSTEM:%'
147
+ GROUP BY statement
148
+ LETTING avgServiceTime = AVG(STR_TO_DURATION(serviceTime))
149
+ ORDER BY avgServiceTime DESC
150
+ LIMIT $limit
151
+ """
152
+
153
+ return _run_query_tool_with_empty_message(
154
+ ctx,
155
+ query,
156
+ limit=limit,
157
+ empty_message=(
158
+ "No completed queries were available to calculate longest running queries."
159
+ ),
160
+ )
161
+
162
+
163
+ def get_most_frequent_queries(ctx: Context, limit: int = 10) -> list[dict[str, Any]]:
164
+ """Get the N most frequent queries from the system:completed_requests catalog.
165
+
166
+ Args:
167
+ limit: Number of queries to return (default: 10)
168
+
169
+ Returns:
170
+ List of queries with their frequency count
171
+ """
172
+ query = """
173
+ SELECT statement,
174
+ COUNT(1) AS queries
175
+ FROM system:completed_requests
176
+ WHERE UPPER(statement) NOT LIKE 'INFER %'
177
+ AND UPPER(statement) NOT LIKE 'CREATE INDEX%'
178
+ AND UPPER(statement) NOT LIKE 'CREATE PRIMARY INDEX%'
179
+ AND UPPER(statement) NOT LIKE 'EXPLAIN %'
180
+ AND UPPER(statement) NOT LIKE 'ADVISE %'
181
+ AND UPPER(statement) NOT LIKE '% SYSTEM:%'
182
+ GROUP BY statement
183
+ LETTING queries = COUNT(1)
184
+ ORDER BY queries DESC
185
+ LIMIT $limit
186
+ """
187
+
188
+ return _run_query_tool_with_empty_message(
189
+ ctx,
190
+ query,
191
+ limit=limit,
192
+ empty_message=(
193
+ "No completed queries were available to calculate most frequent queries."
194
+ ),
195
+ )
196
+
197
+
198
+ def get_queries_with_largest_response_sizes(
199
+ ctx: Context, limit: int = 10
200
+ ) -> list[dict[str, Any]]:
201
+ """Get queries with the largest response sizes from the system:completed_requests catalog.
202
+
203
+ Args:
204
+ limit: Number of queries to return (default: 10)
205
+
206
+ Returns:
207
+ List of queries with their average result size in bytes, KB, and MB
208
+ """
209
+ query = """
210
+ SELECT statement,
211
+ avgResultSize AS avgResultSizeBytes,
212
+ (avgResultSize / 1000) AS avgResultSizeKB,
213
+ (avgResultSize / 1000000) AS avgResultSizeMB,
214
+ COUNT(1) AS queries
215
+ FROM system:completed_requests
216
+ WHERE UPPER(statement) NOT LIKE 'INFER %'
217
+ AND UPPER(statement) NOT LIKE 'CREATE INDEX%'
218
+ AND UPPER(statement) NOT LIKE 'CREATE PRIMARY INDEX%'
219
+ AND UPPER(statement) NOT LIKE '% SYSTEM:%'
220
+ GROUP BY statement
221
+ LETTING avgResultSize = AVG(resultSize)
222
+ ORDER BY avgResultSize DESC
223
+ LIMIT $limit
224
+ """
225
+
226
+ return _run_query_tool_with_empty_message(
227
+ ctx,
228
+ query,
229
+ limit=limit,
230
+ empty_message=(
231
+ "No completed queries were available to calculate response sizes."
232
+ ),
233
+ )
234
+
235
+
236
+ def get_queries_with_large_result_count(
237
+ ctx: Context, limit: int = 10
238
+ ) -> list[dict[str, Any]]:
239
+ """Get queries with the largest result counts from the system:completed_requests catalog.
240
+
241
+ Args:
242
+ limit: Number of queries to return (default: 10)
243
+
244
+ Returns:
245
+ List of queries with their average result count
246
+ """
247
+ query = """
248
+ SELECT statement,
249
+ avgResultCount,
250
+ COUNT(1) AS queries
251
+ FROM system:completed_requests
252
+ WHERE UPPER(statement) NOT LIKE 'INFER %' AND
253
+ UPPER(statement) NOT LIKE 'CREATE INDEX%' AND
254
+ UPPER(statement) NOT LIKE 'CREATE PRIMARY INDEX%' AND
255
+ UPPER(statement) NOT LIKE '% SYSTEM:%'
256
+ GROUP BY statement
257
+ LETTING avgResultCount = AVG(resultCount)
258
+ ORDER BY avgResultCount DESC
259
+ LIMIT $limit
260
+ """
261
+
262
+ return _run_query_tool_with_empty_message(
263
+ ctx,
264
+ query,
265
+ limit=limit,
266
+ empty_message=(
267
+ "No completed queries were available to calculate result counts."
268
+ ),
269
+ )
270
+
271
+
272
+ def get_queries_using_primary_index(
273
+ ctx: Context, limit: int = 10
274
+ ) -> list[dict[str, Any]]:
275
+ """Get queries that use a primary index from the system:completed_requests catalog.
276
+
277
+ Args:
278
+ limit: Number of queries to return (default: 10)
279
+
280
+ Returns:
281
+ List of queries that use primary indexes, ordered by result count
282
+ """
283
+ query = """
284
+ SELECT *
285
+ FROM system:completed_requests
286
+ WHERE phaseCounts.`primaryScan` IS NOT MISSING
287
+ AND UPPER(statement) NOT LIKE '% SYSTEM:%'
288
+ ORDER BY resultCount DESC
289
+ LIMIT $limit
290
+ """
291
+
292
+ return _run_query_tool_with_empty_message(
293
+ ctx,
294
+ query,
295
+ limit=limit,
296
+ empty_message=(
297
+ "No queries using the primary index were found in system:completed_requests."
298
+ ),
299
+ )
300
+
301
+
302
+ def get_queries_not_using_covering_index(
303
+ ctx: Context, limit: int = 10
304
+ ) -> list[dict[str, Any]]:
305
+ """Get queries that don't use a covering index from the system:completed_requests catalog.
306
+
307
+ Args:
308
+ limit: Number of queries to return (default: 10)
309
+
310
+ Returns:
311
+ List of queries that perform index scans but also require fetches (not covering)
312
+ """
313
+ query = """
314
+ SELECT *
315
+ FROM system:completed_requests
316
+ WHERE phaseCounts.`indexScan` IS NOT MISSING
317
+ AND phaseCounts.`fetch` IS NOT MISSING
318
+ AND UPPER(statement) NOT LIKE '% SYSTEM:%'
319
+ ORDER BY resultCount DESC
320
+ LIMIT $limit
321
+ """
322
+
323
+ return _run_query_tool_with_empty_message(
324
+ ctx,
325
+ query,
326
+ limit=limit,
327
+ empty_message=(
328
+ "No queries that require fetches after index scans were found "
329
+ "in system:completed_requests."
330
+ ),
331
+ )
332
+
333
+
334
+ def get_queries_not_selective(ctx: Context, limit: int = 10) -> list[dict[str, Any]]:
335
+ """Get queries that are not very selective from the system:completed_requests catalog.
336
+
337
+ Args:
338
+ limit: Number of queries to return (default: 10)
339
+
340
+ Returns:
341
+ List of queries where index scans return significantly more documents than the final result
342
+ """
343
+ query = """
344
+ SELECT statement,
345
+ AVG(phaseCounts.`indexScan` - resultCount) AS diff
346
+ FROM system:completed_requests
347
+ WHERE phaseCounts.`indexScan` > resultCount
348
+ GROUP BY statement
349
+ ORDER BY diff DESC
350
+ LIMIT $limit
351
+ """
352
+
353
+ return _run_query_tool_with_empty_message(
354
+ ctx,
355
+ query,
356
+ limit=limit,
357
+ empty_message=(
358
+ "No non-selective queries were found in system:completed_requests."
359
+ ),
360
+ )