-
Notifications
You must be signed in to change notification settings - Fork 28
Cluster health check and metrics retrieval #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
b3abd77
77ce027
eda8103
03d0d3c
00a391c
1167b06
185adce
84a1bc6
82bc752
4d60d1b
be56033
c2f2a21
08a3334
32c5c7f
7420228
ea2dede
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,8 @@ | |
| from typing import AsyncIterator | ||
| from lark_sqlpp import modifies_data, modifies_structure, parse_sqlpp | ||
| import click | ||
|
|
||
| import requests | ||
| from requests.auth import HTTPBasicAuth | ||
| MCP_SERVER_NAME = "couchbase" | ||
|
|
||
| # Configure logging | ||
|
|
@@ -26,7 +27,6 @@ class AppContext: | |
| """Context for the MCP server.""" | ||
|
|
||
| cluster: Cluster | None = None | ||
| bucket: Any | None = None | ||
| read_only_query_mode: bool = True | ||
|
|
||
|
|
||
|
|
@@ -64,12 +64,7 @@ def get_settings() -> dict: | |
| help="Couchbase database password", | ||
| callback=validate_required_param, | ||
| ) | ||
| @click.option( | ||
| "--bucket-name", | ||
| envvar="CB_BUCKET_NAME", | ||
| help="Couchbase bucket name", | ||
| callback=validate_required_param, | ||
| ) | ||
|
|
||
| @click.option( | ||
| "--read-only-query-mode", | ||
| envvar="READ_ONLY_QUERY_MODE", | ||
|
|
@@ -90,7 +85,6 @@ def main( | |
| connection_string, | ||
| username, | ||
| password, | ||
| bucket_name, | ||
| read_only_query_mode, | ||
| transport, | ||
| ): | ||
|
|
@@ -99,7 +93,6 @@ def main( | |
| "connection_string": connection_string, | ||
| "username": username, | ||
| "password": password, | ||
| "bucket_name": bucket_name, | ||
| "read_only_query_mode": read_only_query_mode, | ||
| } | ||
| mcp.run(transport=transport) | ||
|
|
@@ -114,7 +107,6 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: | |
| connection_string = settings.get("connection_string") | ||
| username = settings.get("username") | ||
| password = settings.get("password") | ||
| bucket_name = settings.get("bucket_name") | ||
| read_only_query_mode = settings.get("read_only_query_mode") | ||
|
|
||
| # Validate configuration | ||
|
|
@@ -128,9 +120,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: | |
| if not password: | ||
| logger.error("Couchbase database password is not set") | ||
| missing_vars.append("password") | ||
| if not bucket_name: | ||
| logger.error("Couchbase bucket name is not set") | ||
| missing_vars.append("bucket_name") | ||
|
|
||
|
|
||
| if missing_vars: | ||
| error_msg = f"Missing required configuration: {', '.join(missing_vars)}" | ||
|
|
@@ -148,9 +138,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: | |
| cluster.wait_until_ready(timedelta(seconds=5)) | ||
| logger.info("Successfully connected to Couchbase cluster") | ||
|
|
||
| bucket = cluster.bucket(bucket_name) | ||
| yield AppContext( | ||
| cluster=cluster, bucket=bucket, read_only_query_mode=read_only_query_mode | ||
| cluster=cluster, | ||
| read_only_query_mode=read_only_query_mode | ||
| ) | ||
|
|
||
| except Exception as e: | ||
|
|
@@ -162,13 +152,103 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: | |
| mcp = FastMCP(MCP_SERVER_NAME, lifespan=app_lifespan) | ||
|
|
||
|
|
||
|
|
||
| # Tools | ||
| @mcp.tool() | ||
| def get_scopes_and_collections_in_bucket(ctx: Context) -> dict[str, list[str]]: | ||
| """Get the names of all scopes and collections in the bucket. | ||
| def get_cluster_health_check( | ||
| ctx: Context) -> list[dict[str,Any]]: | ||
| """Runs a healthcheck (ping report) on the Couchbase cluster. Returns an array of json objects with pintreport results and node IPs. | ||
| Also useful for discovering available services in the cluster. Multiple services may reside on each node. | ||
| Returns: Array of service objects, each having the URL (consisting of IP and port for the service) and state of the service""" | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
|
|
||
| services = [] | ||
| try: | ||
| ping_report = cluster.ping() | ||
| except Exception as e: | ||
| logger.error(f"Unable to reach cluster for health check: {e}") | ||
| raise e | ||
| try: | ||
| services = [] | ||
| for service_type, endpoints in ping_report.endpoints.items(): | ||
| for ep in endpoints: | ||
| services.append({ | ||
| "service": service_type.value, | ||
| "state": ep.state.value, | ||
| "id": ep.id, | ||
| "ip": ep.remote, | ||
| "latency_ms": ep.latency.total_seconds() * 1000 if ep.latency else None, | ||
| "error": str(ep.error) if ep.error else None | ||
| }) | ||
| except Exception as e: | ||
| logger.error(f"Unable to parse ping report: {e}") | ||
| raise e | ||
|
|
||
| return services | ||
|
|
||
| def fetch_metrics(ip, username, password) -> str: | ||
| url = f"https://{ip}:18091/metrics" | ||
|
|
||
| try: | ||
| response = requests.get( | ||
| url, | ||
| auth=HTTPBasicAuth(username, password), | ||
| verify=False, # <--- Ignores SSL cert verification | ||
| timeout=10 | ||
| ) | ||
| response.raise_for_status() | ||
| return response.text | ||
| except requests.exceptions.RequestException as e: | ||
| return f"Error fetching metrics: {e}" | ||
Eyal-CB marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| @mcp.tool() | ||
| def get_cluster_metrics( | ||
| ctx: Context, ip: str) -> str: | ||
| """Runs an API call to the metrics endpoint of a given couchbase node by IP or hostname. Metrics contain info on nodes performance, | ||
| resources and services. | ||
| Returns: String representing the prometheus formatted metrics return by couchbase.""" | ||
|
|
||
| settings = get_settings() | ||
|
|
||
| username = settings.get("username") | ||
| password = settings.get("password") | ||
Eyal-CB marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| metrics = fetch_metrics(ip, username, password) | ||
| return metrics | ||
|
|
||
| @mcp.tool() | ||
| def get_list_of_buckets_with_settings( | ||
| ctx: Context | ||
| ) -> list[str]: | ||
Eyal-CB marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| """Get the list of buckets from the Couchbase cluster, including their bucket settings. | ||
| Returns a list of bucket setting objects. | ||
| """ | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
| result=[] | ||
| try: | ||
| bucket_manager = cluster.buckets() | ||
| buckets = bucket_manager.get_all_buckets() | ||
| for b in buckets: | ||
| result.append(b) | ||
| return result | ||
Eyal-CB marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| except Exception as e: | ||
| logger.error(f"Error getting bucket names: {e}") | ||
| raise e | ||
|
|
||
|
|
||
| @mcp.tool() | ||
| def get_scopes_and_collections_in_bucket(ctx: Context, bucket_name: str) -> dict[str, list[str]]: | ||
| """Get the names of all scopes and collections for a specified bucket. | ||
| Returns a dictionary with scope names as keys and lists of collection names as values. | ||
| """ | ||
| bucket = ctx.request_context.lifespan_context.bucket | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
|
|
||
| try: | ||
| bucket = cluster.bucket(bucket_name) | ||
| except Exception as e: | ||
| logger.error(f"Error accessing bucket: {e}") | ||
| raise ValueError("Tool does not have access to bucket, or bucket does not exist.") | ||
| try: | ||
| scopes_collections = {} | ||
| collection_manager = bucket.collections() | ||
|
|
@@ -184,14 +264,14 @@ def get_scopes_and_collections_in_bucket(ctx: Context) -> dict[str, list[str]]: | |
|
|
||
| @mcp.tool() | ||
| def get_schema_for_collection( | ||
| ctx: Context, scope_name: str, collection_name: str | ||
| ctx: Context, bucket_name: str, scope_name: str, collection_name: str | ||
| ) -> dict[str, Any]: | ||
| """Get the schema for a collection in the specified scope. | ||
| """Get the schema for a collection in the specified scope of a specified bucket. | ||
| Returns a dictionary with the schema returned by running INFER on the Couchbase collection. | ||
| """ | ||
| try: | ||
| query = f"INFER {collection_name}" | ||
| result = run_sql_plus_plus_query(ctx, scope_name, query) | ||
| result = run_sql_plus_plus_query(ctx, bucket_name, scope_name, query) | ||
| return result | ||
| except Exception as e: | ||
| logger.error(f"Error getting schema: {e}") | ||
|
|
@@ -200,10 +280,15 @@ def get_schema_for_collection( | |
|
|
||
| @mcp.tool() | ||
| def get_document_by_id( | ||
| ctx: Context, scope_name: str, collection_name: str, document_id: str | ||
| ctx: Context, bucket_name: str, scope_name: str, collection_name: str, document_id: str | ||
| ) -> dict[str, Any]: | ||
| """Get a document by its ID from the specified scope and collection.""" | ||
| bucket = ctx.request_context.lifespan_context.bucket | ||
| """Get a document by its ID from the specified bucket, scope and collection.""" | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
| try: | ||
| bucket = cluster.bucket(bucket_name) | ||
| except Exception as e: | ||
| logger.error(f"Error accessing bucket: {e}") | ||
| raise ValueError("Tool does not have access to bucket, or bucket does not exist.") | ||
|
Comment on lines
+383
to
+387
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic for retrieving a bucket object from the cluster and handling potential errors is duplicated across multiple tool functions (e.g.,
Comment on lines
+382
to
+387
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This block of code for retrieving a bucket and handling errors is repeated in several functions (e.g., |
||
| try: | ||
| collection = bucket.scope(scope_name).collection(collection_name) | ||
| result = collection.get(document_id) | ||
|
|
@@ -216,14 +301,20 @@ def get_document_by_id( | |
| @mcp.tool() | ||
| def upsert_document_by_id( | ||
| ctx: Context, | ||
| bucket_name: str, | ||
| scope_name: str, | ||
| collection_name: str, | ||
| document_id: str, | ||
| document_content: dict[str, Any], | ||
| ) -> bool: | ||
| """Insert or update a document by its ID. | ||
| """Insert or update a document in a bucket, scope and collection by its ID. | ||
| Returns True on success, False on failure.""" | ||
| bucket = ctx.request_context.lifespan_context.bucket | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
| try: | ||
| bucket = cluster.bucket(bucket_name) | ||
| except Exception as e: | ||
| logger.error(f"Error accessing bucket: {e}") | ||
| raise ValueError("Tool does not have access to bucket, or bucket does not exist.") | ||
| try: | ||
| collection = bucket.scope(scope_name).collection(collection_name) | ||
| collection.upsert(document_id, document_content) | ||
|
|
@@ -236,11 +327,16 @@ def upsert_document_by_id( | |
|
|
||
| @mcp.tool() | ||
| def delete_document_by_id( | ||
| ctx: Context, scope_name: str, collection_name: str, document_id: str | ||
| ctx: Context, bucket_name: str, scope_name: str, collection_name: str, document_id: str | ||
| ) -> bool: | ||
| """Delete a document by its ID. | ||
| """Delete a document in a bucket, scope and collection by its ID. | ||
| Returns True on success, False on failure.""" | ||
| bucket = ctx.request_context.lifespan_context.bucket | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
| try: | ||
| bucket = cluster.bucket(bucket_name) | ||
| except Exception as e: | ||
| logger.error(f"Error accessing bucket: {e}") | ||
| raise ValueError("Tool does not have access to bucket, or bucket does not exist.") | ||
| try: | ||
| collection = bucket.scope(scope_name).collection(collection_name) | ||
| collection.remove(document_id) | ||
|
|
@@ -250,13 +346,44 @@ def delete_document_by_id( | |
| logger.error(f"Error deleting document {document_id}: {e}") | ||
| return False | ||
|
|
||
| @mcp.tool() | ||
| def advise_index_for_sql_plus_plus_query( | ||
| ctx: Context, bucket_name: str, scope_name: str, query: str | ||
| ) -> dict[str, Any]: | ||
| """Get an index recommendation from the SQL++ index advisor for a specified query on a specified bucket and scope. | ||
| Returns a dictionary with the query advised on, as well as: | ||
| 1. an array of the current indexes used and their status (or a string indicating no existing indexes available) | ||
| 2. an array of recommended indexes and/or covering indexes with reasoning (or a string indicating no possible index improvements) | ||
| """ | ||
| response = {} | ||
|
|
||
| try: | ||
| query = f"ADVISE {query}" | ||
| result = run_sql_plus_plus_query(ctx, bucket_name, scope_name, query) | ||
|
|
||
| if result and (advice := result[0].get("advice")): | ||
| if (advice is not None): | ||
| advise_info = advice.get("adviseinfo") | ||
| if ( advise_info is not None): | ||
| response["current_indexes"] = advise_info.get("current_indexes", "No current indexes") | ||
| response["recommended_indexes"] = advise_info.get("recommended_indexes","No index recommendations available") | ||
| response["query"]=result[0].get("query","Query statement unavailable") | ||
|
Comment on lines
+460
to
+466
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These nested if result and (advice := result[0].get("advice")) and (advise_info := advice.get("adviseinfo")):
response["current_indexes"] = advise_info.get("current_indexes", "No current indexes")
response["recommended_indexes"] = advise_info.get("recommended_indexes","No index recommendations available")
response["query"]=result[0].get("query","Query statement unavailable") |
||
| return response | ||
|
Comment on lines
+460
to
+467
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The nested if result and (advice := result[0].get("advice")) and (advise_info := advice.get("adviseinfo")):
response["current_indexes"] = advise_info.get("current_indexes", "No current indexes")
response["recommended_indexes"] = advise_info.get("recommended_indexes","No index recommendations available")
response["query"]=result[0].get("query","Query statement unavailable")
return response |
||
| except Exception as e: | ||
| logger.error(f"Error running Advise on query: {e}") | ||
| raise ValueError(f"Unable to run ADVISE on: {query} for keyspace {bucket_name}.{scope_name}") | ||
|
|
||
| @mcp.tool() | ||
| def run_sql_plus_plus_query( | ||
| ctx: Context, scope_name: str, query: str | ||
| ctx: Context, bucket_name: str, scope_name: str, query: str | ||
| ) -> list[dict[str, Any]]: | ||
| """Run a SQL++ query on a scope and return the results as a list of JSON objects.""" | ||
| bucket = ctx.request_context.lifespan_context.bucket | ||
| """Run a SQL++ query on a scope in a specified bucket and return the results as a list of JSON objects.""" | ||
| cluster = ctx.request_context.lifespan_context.cluster | ||
| try: | ||
| bucket = cluster.bucket(bucket_name) | ||
| except Exception as e: | ||
| logger.error(f"Error accessing bucket: {e}") | ||
| raise ValueError("Tool does not have access to bucket, or bucket does not exist.") | ||
| read_only_query_mode = ctx.request_context.lifespan_context.read_only_query_mode | ||
| logger.info(f"Running SQL++ queries in read-only mode: {read_only_query_mode}") | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.