Skip to content

Commit 421100d

Browse files
authored
Merge branch 'main' into mcp-integration-test
2 parents e67670a + 2ac456e commit 421100d

File tree

39 files changed

+1675
-172
lines changed

39 files changed

+1675
-172
lines changed

components/clp-mcp-server/clp_mcp_server/clp_mcp_server.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
import ipaddress
44
import logging
5+
import os
56
import socket
67
import sys
78
from pathlib import Path
89

910
import click
10-
from clp_py_utils.clp_config import CLPConfig
11+
from clp_py_utils.clp_config import CLPConfig, MCP_SERVER_COMPONENT_NAME
12+
from clp_py_utils.clp_logging import get_logger, get_logging_formatter, set_logging_level
1113
from clp_py_utils.core import read_yaml_config_file
1214
from pydantic import ValidationError
1315

1416
from .server import create_mcp_server
1517

18+
logger = get_logger(MCP_SERVER_COMPONENT_NAME)
19+
1620

1721
@click.command()
1822
@click.option(
@@ -34,10 +38,12 @@ def main(host: str, port: int, config_path: Path) -> int:
3438
:param config_path: The path to server's configuration file.
3539
:return: Exit code (0 for success, non-zero for failure).
3640
"""
37-
logging.basicConfig(
38-
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
39-
)
40-
logger = logging.getLogger(__name__)
41+
# Setup logging to file
42+
log_file_path = Path(os.getenv("CLP_LOGS_DIR")) / "mcp_server.log"
43+
logging_file_handler = logging.FileHandler(filename=log_file_path, encoding="utf-8")
44+
logging_file_handler.setFormatter(get_logging_formatter())
45+
logger.addHandler(logging_file_handler)
46+
set_logging_level(logger, os.getenv("CLP_LOGGING_LEVEL"))
4147

4248
exit_code = 0
4349

components/clp-mcp-server/clp_mcp_server/server/server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from clp_py_utils.clp_config import CLPConfig
66
from fastmcp import Context, FastMCP
7+
from starlette.requests import Request
8+
from starlette.responses import PlainTextResponse
79

810
from clp_mcp_server.clp_connector import ClpConnector
911

@@ -161,4 +163,14 @@ async def search_by_kql_with_timestamp_range(
161163

162164
return await _execute_kql_query(ctx.session_id, kql_query, begin_ts, end_ts)
163165

166+
@mcp.custom_route("/health", methods=["GET"])
167+
async def health_check(_request: Request) -> PlainTextResponse:
168+
"""
169+
Health check endpoint.
170+
171+
:param _request: An HTTP request object.
172+
:return: A plain text response indicating server is healthy.
173+
"""
174+
return PlainTextResponse("OK")
175+
164176
return mcp

components/clp-mcp-server/clp_mcp_server/server/session_manager.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,15 @@ def cache_query_result_and_get_first_page(
8383
"""
8484
:param results: Log entries from the query to cache.
8585
:return: Forwards `SessionState.get_page_data`'s return values.
86-
:return: _GET_INSTRUCTIONS_NOT_RUN_ERROR if `get_instructions` has not been called in this
87-
session.
86+
:return: A dictionary with the following key-value pair on failures:
87+
- "Error": An error message describing the failure.
8888
"""
8989
if self._is_instructions_retrieved is False:
9090
return self._GET_INSTRUCTIONS_NOT_RUN_ERROR.copy()
9191

92+
if len(results) == 0:
93+
return {"Error": "No log events found matching the KQL query."}
94+
9295
self._cached_query_result = PaginatedQueryResult(
9396
log_entries=results, num_items_per_page=self._num_items_per_page
9497
)

components/clp-mcp-server/clp_mcp_server/server/utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ def format_query_results(query_results: list[dict[str, Any]]) -> list[str]:
8383
kv-pairs:
8484
- "timestamp": An integer representing the epoch timestamp in milliseconds.
8585
- "message": A string representing the log message.
86+
- "link": A string representing the link to open the log viewer displaying the message.
8687
87-
The message will be formatted as `timestamp: <date string>, message: <message>`:
88+
The message will be formatted as `timestamp: <date string>, message: <message>, link: <link>`.
8889
8990
:param query_results: A list of dictionaries representing kv-pair log events.
9091
:return: A list of strings representing formatted log events.
@@ -105,7 +106,9 @@ def format_query_results(query_results: list[dict[str, Any]]) -> list[str]:
105106
logger.warning("Empty message attached to a log event: %s.", obj)
106107
continue
107108

108-
formatted_log_events.append(f"timestamp: {timestamp_str}, message: {message}")
109+
link = obj["link"]
110+
111+
formatted_log_events.append(f"timestamp: {timestamp_str}, message: {message}, link: {link}")
109112

110113
return formatted_log_events
111114

components/clp-mcp-server/tests/server/test_utils.py

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
class TestUtils:
1414
"""Test suite for utility functions."""
1515

16+
LINK = "http://localhost:4000/"
17+
1618
# Error Messages:
1719
INVALID_DATE_STRING_ERROR = "Invalid date string"
1820
INVALID_DATE_STRING_FORMAT_ERROR = "Timestamp must end with 'Z' to indicate UTC."
@@ -24,54 +26,68 @@ class TestUtils:
2426
{
2527
"timestamp": None,
2628
"message": '{"message":"Log with None timestamp"}\n',
29+
"link": LINK
2730
},
2831
{
2932
"timestamp": "1729267200000", # str instead of int
3033
"message": '{"message":"Log with str timestamp"}\n',
34+
"link": LINK
3135
},
3236
{
3337
"timestamp": 1729267200000.0, # float instead of int
3438
"message": '{"message":"Log with float timestamp"}\n',
39+
"link": LINK
3540
},
3641
]
3742
EXPECTED_INVALID_TYPE = [
38-
'timestamp: N/A, message: {"message":"Log with None timestamp"}\n',
39-
'timestamp: N/A, message: {"message":"Log with str timestamp"}\n',
40-
'timestamp: N/A, message: {"message":"Log with float timestamp"}\n',
43+
f'timestamp: N/A, message: {{"message":"Log with None timestamp"}}\n, link: {LINK}',
44+
f'timestamp: N/A, message: {{"message":"Log with str timestamp"}}\n, link: {LINK}',
45+
f'timestamp: N/A, message: {{"message":"Log with float timestamp"}}\n, link: {LINK}'
4146
]
4247

4348
# Test case: invalid timestamp values.
4449
INVALID_VALUE_ENTRIES = [
4550
{
4651
"timestamp": 9999999999999999,
4752
"message": '{"message":"Log with overflow timestamp"}\n',
53+
"link": LINK
4854
},
4955
{
5056
"timestamp": -9999999999999999,
5157
"message": '{"message":"Log with negative overflow timestamp"}\n',
58+
"link": LINK
5259
},
5360
]
5461
EXPECTED_INVALID_VALUE = [
55-
'timestamp: N/A, message: {"message":"Log with overflow timestamp"}\n',
56-
'timestamp: N/A, message: {"message":"Log with negative overflow timestamp"}\n',
62+
(
63+
f'timestamp: N/A, message: {{"message":"Log with overflow timestamp"}}\n,'
64+
f' link: {LINK}'
65+
),
66+
(
67+
f'timestamp: N/A, message: {{"message":"Log with negative overflow timestamp"}}\n,'
68+
f' link: {LINK}'
69+
)
5770
]
5871

5972
# Test case: missing timestamp and message fields.
6073
MISSING_TIMESTAMP_AND_MESSAGE_ENTRY = [
6174
{
6275
"_id": "test001",
76+
"link": LINK
6377
},
6478
{
6579
"_id": "test002",
6680
"message": '{"message":"Log with no timestamp"}\n',
81+
"link": LINK
6782
},
6883
{
6984
"_id": "test003",
7085
"timestamp": 0,
86+
"link": LINK
7187
}
7288
]
7389
EXPECTED_MISSING_TIMESTAMP_AND_MESSAGE = [
74-
'timestamp: N/A, message: {"message":"Log with no timestamp"}\n',
90+
f'timestamp: N/A, message: {{"message":"Log with no timestamp"}}\n, link: {LINK}',
7591
]
7692

7793
# Testing basic functionality.
@@ -83,6 +99,7 @@ class TestUtils:
8399
"orig_file_path": "/var/log/app.log",
84100
"archive_id": "abc123",
85101
"log_event_ix": 99,
102+
"link": LINK
86103
},
87104
{
88105
"_id": "test001",
@@ -91,6 +108,7 @@ class TestUtils:
91108
"orig_file_path": "/var/log/app.log",
92109
"archive_id": "abc123",
93110
"log_event_ix": 100,
111+
"link": LINK
94112
},
95113
{
96114
"_id": "test002",
@@ -102,6 +120,7 @@ class TestUtils:
102120
"orig_file_path": "/var/log/app.log",
103121
"archive_id": "abc124",
104122
"log_event_ix": 101,
123+
"link": LINK
105124
},
106125
{
107126
"_id": "test003",
@@ -113,29 +132,42 @@ class TestUtils:
113132
"orig_file_path": "/var/log/app.log",
114133
"archive_id": "abc125",
115134
"log_event_ix": 102,
135+
"link": (
136+
"http://localhost:4000/streamFile"
137+
"?dataset=default"
138+
'&type=json'
139+
"&streamId=abc125"
140+
"&logEventIdx=102"
141+
),
116142
},
117143
]
118144

119145
EXPECTED_RESULTS = [
120146
(
121147
'timestamp: 2024-10-18T16:00:00.123Z, message: '
122148
'{"ts":1729267200123,"pid":1234,"tid":5678,'
123-
'"message":"Log with millisecond precision"}\n'
149+
'"message":"Log with millisecond precision"}\n, '
150+
"link: http://localhost:4000/streamFile"
151+
"?dataset=default"
152+
'&type=json'
153+
"&streamId=abc125"
154+
"&logEventIdx=102"
155+
124156
),
125157
(
126-
'timestamp: 2024-10-18T16:00:00.000Z, message: '
127-
'{"ts":1729267200000,"pid":1234,"tid":5678,'
128-
'"message":"Log with zero milliseconds"}\n'
158+
f'timestamp: 2024-10-18T16:00:00.000Z, message: '
159+
f'{{"ts":1729267200000,"pid":1234,"tid":5678,'
160+
f'"message":"Log with zero milliseconds"}}\n, link: {LINK}'
129161
),
130162
(
131-
'timestamp: 1970-01-01T00:00:00.000Z, message: '
132-
'{"ts":0,"pid":null,"tid":null,'
133-
'"message":"Log at epoch zero"}\n'
163+
f'timestamp: 1970-01-01T00:00:00.000Z, message: '
164+
f'{{"ts":0,"pid":null,"tid":null,'
165+
f'"message":"Log at epoch zero"}}\n, link: {LINK}'
134166
),
135167
(
136-
'timestamp: N/A, message: '
137-
'{"pid":null,"tid":null,'
138-
'"message":"Log at epoch none"}\n'
168+
f'timestamp: N/A, message: '
169+
f'{{"pid":null,"tid":null,'
170+
f'"message":"Log at epoch none"}}\n, link: {LINK}'
139171
),
140172
]
141173

components/clp-mcp-server/tests/test_session_manager.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class TestConstants:
2727
# Number of logs tested in unit test and its expected page counts
2828
EXPECTED_NUM_LOG_ENTRIES = 25
2929
EXPECTED_NUM_PAGES = 3
30+
EMPTY_LOG_ENTRIES = 0
3031

3132
# 0.5 second for fast expiration tests
3233
FAST_SESSION_TTL_SECONDS = 0.5
@@ -41,6 +42,7 @@ class TestConstants:
4142
EXCEEDS_MAX_CACHED_RESULTS_ERR = "exceeds maximum allowed cached results"
4243
INVALID_NUM_ITEMS_PER_PAGE_ERR = "must be a positive integer"
4344
GET_INSTRUCTIONS_NOT_CALLED_ERR = "Please call `get_instructions()`"
45+
NO_RESULTS_FOUND_IN_KQL_QUERY_ERR = "No log events found matching the KQL query."
4446
NO_PREVIOUS_PAGINATED_RESPONSE_ERR = "No previous paginated response in this session."
4547
PAGE_INDEX_OUT_OF_BOUNDS_ERR = "Page index is out of bounds."
4648

@@ -115,6 +117,28 @@ def test_query_result_initialization(self) -> None:
115117
class TestSessionState:
116118
"""Unit tests for SessionState class."""
117119

120+
def test_error_handling(self) -> None:
121+
"""Validates error handling of `SessionState`."""
122+
session = SessionState(
123+
_num_items_per_page=TestConstants.ITEMS_PER_PAGE,
124+
_session_id=TestConstants.TEST_SESSION_ID,
125+
_session_ttl_seconds=TestConstants.SESSION_TTL_SECONDS
126+
)
127+
128+
first_page = session.cache_query_result_and_get_first_page(
129+
TestConstants.create_log_messages(TestConstants.EXPECTED_NUM_LOG_ENTRIES)
130+
)
131+
132+
assert TestConstants.GET_INSTRUCTIONS_NOT_CALLED_ERR in first_page["Error"]
133+
page_data = session.get_page_data(1)
134+
assert TestConstants.GET_INSTRUCTIONS_NOT_CALLED_ERR in page_data["Error"]
135+
136+
_ = session.get_instructions()
137+
first_page = session.cache_query_result_and_get_first_page(
138+
TestConstants.create_log_messages(TestConstants.EMPTY_LOG_ENTRIES)
139+
)
140+
assert TestConstants.NO_RESULTS_FOUND_IN_KQL_QUERY_ERR in first_page["Error"]
141+
118142
def test_get_page_data(self) -> None:
119143
"""Validates pagination functionality is respecting the defined dictionary format."""
120144
session = SessionState(
@@ -162,22 +186,6 @@ def test_get_page_data(self) -> None:
162186
page_data = session.get_page_data(3)
163187
assert page_data["Error"] == TestConstants.PAGE_INDEX_OUT_OF_BOUNDS_ERR
164188

165-
def test_no_get_instruction(self) -> None:
166-
"""Validates error handling when `get_instructions()` has not been called."""
167-
session = SessionState(
168-
_num_items_per_page=TestConstants.ITEMS_PER_PAGE,
169-
_session_id=TestConstants.TEST_SESSION_ID,
170-
_session_ttl_seconds=TestConstants.SESSION_TTL_SECONDS
171-
)
172-
173-
first_page = session.cache_query_result_and_get_first_page(
174-
TestConstants.create_log_messages(TestConstants.EXPECTED_NUM_LOG_ENTRIES)
175-
)
176-
177-
assert TestConstants.GET_INSTRUCTIONS_NOT_CALLED_ERR in first_page["Error"]
178-
page_data = session.get_page_data(1)
179-
assert TestConstants.GET_INSTRUCTIONS_NOT_CALLED_ERR in page_data["Error"]
180-
181189
def test_session_expiration(self) -> None:
182190
"""Validates session expiration check."""
183191
session = SessionState(

0 commit comments

Comments
 (0)