Skip to content

Commit d900b22

Browse files
committed
feat: support opensearch
1 parent 71e596c commit d900b22

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1017
-316
lines changed

.env

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
# Elasticsearch connection settings
2-
ELASTIC_HOST=https://localhost:9200
3-
ELASTIC_USERNAME=elastic
4-
ELASTIC_PASSWORD=test123
2+
ELASTICSEARCH_HOST=https://localhost:9200
3+
ELASTICSEARCH_USERNAME=elastic
4+
ELASTICSEARCH_PASSWORD=test123
5+
6+
# OpenSearch connection settings
7+
OPENSEARCH_HOST=https://localhost:9200
8+
OPENSEARCH_USERNAME=elastic
9+
OPENSEARCH_PASSWORD=test123

README.md

+6-6
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ Using `uvx` will automatically install the package from PyPI, no need to clone t
6565
"elasticsearch-mcp-server"
6666
],
6767
"env": {
68-
"ELASTIC_HOST": "https://localhost:9200",
69-
"ELASTIC_USERNAME": "elastic",
70-
"ELASTIC_PASSWORD": "test123"
68+
"ELASTICSEARCH_HOST": "https://localhost:9200",
69+
"ELASTICSEARCH_USERNAME": "elastic",
70+
"ELASTICSEARCH_PASSWORD": "test123"
7171
}
7272
}
7373
}
@@ -90,9 +90,9 @@ Using `uv` requires cloning the repository locally and specifying the path to th
9090
"elasticsearch-mcp-server"
9191
],
9292
"env": {
93-
"ELASTIC_HOST": "https://localhost:9200",
94-
"ELASTIC_USERNAME": "elastic",
95-
"ELASTIC_PASSWORD": "test123"
93+
"ELASTICSEARCH_HOST": "https://localhost:9200",
94+
"ELASTICSEARCH_USERNAME": "elastic",
95+
"ELASTICSEARCH_PASSWORD": "test123"
9696
}
9797
}
9898
}

pyproject.toml

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
[project]
2-
name = "elasticsearch-mcp-server"
2+
name = "search-mcp-server"
33
version = "1.0.0"
4-
description = "MCP Server for interacting with Elasticsearch"
4+
description = "MCP Server for interacting with Elasticsearch and OpenSearch"
55
readme = "README.md"
66
requires-python = ">=3.10"
77
dependencies = [
88
"elasticsearch>=8.0.0",
9+
"opensearch-py>=2.0.0",
910
"mcp>=1.0.0",
1011
"python-dotenv>=1.0.0",
1112
"fastmcp>=0.4.0",
@@ -15,7 +16,11 @@ dependencies = [
1516
file = "LICENSE"
1617

1718
[project.scripts]
18-
elasticsearch-mcp-server = "elasticsearch_mcp_server:main"
19+
elasticsearch-mcp-server = "src.server:elasticsearch_mcp_server"
20+
opensearch-mcp-server = "src.server:opensearch_mcp_server"
21+
22+
[tool.hatch.build.targets.wheel]
23+
packages = ["src"]
1924

2025
[build-system]
2126
requires = [

src/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Search MCP Server package.
3+
"""
4+
from src.server import elasticsearch_mcp_server, opensearch_mcp_server, run_search_server
5+
6+
__all__ = ['elasticsearch_mcp_server', 'opensearch_mcp_server', 'run_search_server']

src/clients/__init__.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Dict
2+
from .elasticsearch.client import ElasticsearchClient
3+
from .opensearch.client import OpenSearchClient
4+
from .interfaces import SearchClient
5+
6+
def create_search_client(config: Dict, engine_type: str) -> SearchClient:
7+
"""
8+
Factory function to create the appropriate search client.
9+
10+
Args:
11+
config: Configuration dictionary with connection parameters
12+
engine_type: Type of search engine to use ("elasticsearch" or "opensearch")
13+
14+
Returns:
15+
SearchClient: An instance of the appropriate search client
16+
17+
Raises:
18+
ValueError: If an invalid engine type is specified
19+
"""
20+
if engine_type.lower() == "elasticsearch":
21+
return ElasticsearchClient(config)
22+
elif engine_type.lower() == "opensearch":
23+
return OpenSearchClient(config)
24+
else:
25+
raise ValueError(f"Invalid engine type: {engine_type}. Must be 'elasticsearch' or 'opensearch'")
26+
27+
__all__ = [
28+
'create_search_client',
29+
'handle_search_exceptions',
30+
'SearchClient',
31+
'ElasticsearchClient',
32+
'OpenSearchClient',
33+
]

src/clients/base.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
Base classes and interfaces for search clients.
3+
"""
4+
from abc import ABC, abstractmethod
5+
import logging
6+
from typing import Dict
7+
8+
class SearchClientBase(ABC):
9+
"""Base abstract class for search clients with common functionality."""
10+
11+
def __init__(self, config: Dict):
12+
"""
13+
Initialize the base search client.
14+
15+
Args:
16+
config: Configuration dictionary with connection parameters
17+
"""
18+
self.logger = logging.getLogger(self.__class__.__name__)
19+
self.config = config
20+
21+
@abstractmethod
22+
def close(self):
23+
"""Close the client connection."""
24+
pass

src/clients/elasticsearch/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .client import ElasticsearchClient
2+
3+
__all__ = ['ElasticsearchClient']

src/clients/elasticsearch/alias.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Dict
2+
from .base import BaseElasticsearchClient
3+
from ..interfaces.alias import AliasClientInterface
4+
5+
class ElasticsearchAliasClient(BaseElasticsearchClient, AliasClientInterface):
6+
def list_aliases(self) -> Dict:
7+
"""Get all aliases."""
8+
return self.client.cat.aliases()
9+
10+
def get_alias(self, index: str) -> Dict:
11+
"""Get aliases for the specified index."""
12+
return self.client.indices.get_alias(index=index)
13+
14+
def put_alias(self, index: str, name: str, body: Dict) -> Dict:
15+
"""Creates or updates an alias for the specified index."""
16+
return self.client.indices.put_alias(index=index, name=name, body=body)
17+
18+
def delete_alias(self, index: str, name: str) -> Dict:
19+
"""Delete an alias for the specified index."""
20+
return self.client.indices.delete_alias(index=index, name=name)

src/clients/elasticsearch/base.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Base Elasticsearch client implementation.
3+
"""
4+
from elasticsearch import Elasticsearch
5+
from typing import Dict
6+
import logging
7+
from ..base import SearchClientBase
8+
9+
class BaseElasticsearchClient(SearchClientBase):
10+
"""Base Elasticsearch client with connection management."""
11+
12+
def __init__(self, config: Dict):
13+
"""
14+
Initialize the Elasticsearch client.
15+
16+
Args:
17+
config: Configuration dictionary with connection parameters
18+
"""
19+
super().__init__(config)
20+
self.logger.info("Initializing Elasticsearch client")
21+
22+
# Extract configuration
23+
hosts = config.get("hosts", ["localhost:9200"])
24+
username = config.get("username")
25+
password = config.get("password")
26+
verify_certs = config.get("verify_certs")
27+
28+
# Create client
29+
self.client = Elasticsearch(
30+
hosts=hosts,
31+
basic_auth=(username, password) if username and password else None,
32+
verify_certs=verify_certs
33+
)
34+
35+
self.logger.info(f"Elasticsearch client initialized with hosts: {hosts}")
36+
37+
def close(self):
38+
"""Close the Elasticsearch client connection."""
39+
self.logger.info("Closing Elasticsearch client connection")
40+
self.client.close()

src/clients/elasticsearch/client.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import Dict
2+
from .index import ElasticsearchIndexClient
3+
from .document import ElasticsearchDocumentClient
4+
from .cluster import ElasticsearchClusterClient
5+
from .alias import ElasticsearchAliasClient
6+
7+
class ElasticsearchClient(ElasticsearchIndexClient,
8+
ElasticsearchDocumentClient,
9+
ElasticsearchClusterClient,
10+
ElasticsearchAliasClient):
11+
"""
12+
Elasticsearch client that implements all required interfaces.
13+
14+
This class uses multiple inheritance to combine all specialized client implementations
15+
into a single unified client, inheriting all methods from the specialized clients.
16+
"""
17+
18+
def __init__(self, config: Dict):
19+
"""
20+
Initialize the Elasticsearch client.
21+
22+
Args:
23+
config: Configuration dictionary with connection parameters
24+
"""
25+
super().__init__(config)
26+
27+
# Log initialization
28+
self.logger.info("Initialized the Elasticsearch client")

src/clients/elasticsearch/cluster.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import Dict
2+
from .base import BaseElasticsearchClient
3+
from ..interfaces.cluster import ClusterClientInterface
4+
5+
class ElasticsearchClusterClient(BaseElasticsearchClient, ClusterClientInterface):
6+
def get_cluster_health(self) -> Dict:
7+
"""Returns basic information about the health of the cluster."""
8+
return self.client.cluster.health()
9+
10+
def get_cluster_stats(self) -> Dict:
11+
"""Returns high-level overview of cluster statistics."""
12+
return self.client.cluster.stats()

src/clients/elasticsearch/document.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Dict, Optional
2+
from .base import BaseElasticsearchClient
3+
from ..interfaces.document import DocumentClientInterface
4+
5+
class ElasticsearchDocumentClient(BaseElasticsearchClient, DocumentClientInterface):
6+
def search_documents(self, index: str, body: Dict) -> Dict:
7+
"""Search for documents in the index."""
8+
return self.client.search(index=index, body=body)
9+
10+
def index_document(self, index: str, document: Dict, id: Optional[str] = None) -> Dict:
11+
"""Creates or updates a document in the index. """
12+
if id is not None:
13+
return self.client.index(index=index, document=document, id=id)
14+
else:
15+
return self.client.index(index=index, document=document)
16+
17+
def get_document(self, index: str, id: str) -> Dict:
18+
"""Get a document by ID."""
19+
return self.client.get(index=index, id=id)
20+
21+
def delete_document(self, index: str, id: str) -> Dict:
22+
"""Removes a document from the index."""
23+
return self.client.delete(index=index, id=id)
24+
25+
def delete_by_query(self, index: str, body: Dict) -> Dict:
26+
"""Deletes documents matching the provided query."""
27+
return self.client.delete_by_query(index=index, body=body)

src/clients/elasticsearch/index.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Dict, Optional
2+
from .base import BaseElasticsearchClient
3+
from ..interfaces.index import IndexClientInterface
4+
5+
class ElasticsearchIndexClient(BaseElasticsearchClient, IndexClientInterface):
6+
def list_indices(self) -> Dict:
7+
"""List all indices."""
8+
return self.client.cat.indices()
9+
10+
def get_index(self, index: str) -> Dict:
11+
"""Returns information (mappings, settings, aliases) about one or more indices."""
12+
return self.client.indices.get(index=index)
13+
14+
def create_index(self, index: str, body: Optional[Dict] = None) -> Dict:
15+
"""Creates an index with optional settings and mappings."""
16+
return self.client.indices.create(index=index, body=body)
17+
18+
def delete_index(self, index: str) -> Dict:
19+
"""Delete an index."""
20+
return self.client.indices.delete(index=index)

src/clients/exceptions.py

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import functools
2+
import logging
3+
from typing import TypeVar, Callable
4+
from mcp.types import TextContent
5+
from fastmcp import FastMCP
6+
7+
T = TypeVar('T')
8+
9+
def handle_search_exceptions(func: Callable[..., T]) -> Callable[..., list[TextContent]]:
10+
"""
11+
Decorator to handle exceptions in search client operations.
12+
13+
Args:
14+
func: The function to decorate
15+
16+
Returns:
17+
Decorated function that handles exceptions
18+
"""
19+
@functools.wraps(func)
20+
def wrapper(*args, **kwargs):
21+
logger = logging.getLogger()
22+
try:
23+
return func(*args, **kwargs)
24+
except Exception as e:
25+
logger.error(f"Unexpected error in {func.__name__}: {e}")
26+
return [TextContent(type="text", text=f"Unexpected error in {func.__name__}: {str(e)}")]
27+
28+
return wrapper
29+
30+
def with_exception_handling(tool_instance: object, mcp: FastMCP) -> None:
31+
"""
32+
Register tools from a tool instance with automatic exception handling applied to all tools.
33+
34+
This function temporarily replaces mcp.tool with a wrapped version that automatically
35+
applies the handle_search_exceptions decorator to all registered tool methods.
36+
37+
Args:
38+
tool_instance: The tool instance that has a register_tools method
39+
mcp: The FastMCP instance used for tool registration
40+
"""
41+
# Save the original tool method
42+
original_tool = mcp.tool
43+
44+
@functools.wraps(original_tool)
45+
def wrapped_tool(*args, **kwargs):
46+
# Get the original decorator
47+
decorator = original_tool(*args, **kwargs)
48+
49+
# Return a new decorator that applies both the exception handler and original decorator
50+
def combined_decorator(func):
51+
# First apply the exception handling decorator
52+
wrapped_func = handle_search_exceptions(func)
53+
# Then apply the original mcp.tool decorator
54+
return decorator(wrapped_func)
55+
56+
return combined_decorator
57+
58+
try:
59+
# Temporarily replace mcp.tool with our wrapped version
60+
mcp.tool = wrapped_tool
61+
62+
# Call the registration method on the tool instance
63+
tool_instance.register_tools(mcp)
64+
finally:
65+
# Restore the original mcp.tool to avoid affecting other code that might use mcp.tool
66+
# This ensures that our modification is isolated to just this tool registration
67+
# and prevents multiple nested decorators if register_all_tools is called multiple times
68+
mcp.tool = original_tool

src/clients/interfaces/__init__.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Export all client interfaces and define a combined interface.
3+
"""
4+
from .index import IndexClientInterface
5+
from .document import DocumentClientInterface
6+
from .cluster import ClusterClientInterface
7+
from .alias import AliasClientInterface
8+
9+
class SearchClient(IndexClientInterface, DocumentClientInterface,
10+
ClusterClientInterface, AliasClientInterface):
11+
"""Complete search client interface combining all functionality."""
12+
13+
def close(self):
14+
"""Close the client connection."""
15+
pass
16+
17+
__all__ = [
18+
'IndexClientInterface',
19+
'DocumentClientInterface',
20+
'ClusterClientInterface',
21+
'AliasClientInterface',
22+
'SearchClient'
23+
]

0 commit comments

Comments
 (0)