timebase-mcp 0.1.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.
- timebase_mcp/__init__.py +1 -0
- timebase_mcp/clients/__init__.py +1 -0
- timebase_mcp/clients/base.py +510 -0
- timebase_mcp/clients/community.py +144 -0
- timebase_mcp/clients/enterprise.py +176 -0
- timebase_mcp/clients/factory.py +199 -0
- timebase_mcp/config.py +265 -0
- timebase_mcp/constants.py +5 -0
- timebase_mcp/errors.py +27 -0
- timebase_mcp/instructions.py +16 -0
- timebase_mcp/logging_config.py +18 -0
- timebase_mcp/main.py +131 -0
- timebase_mcp/models.py +55 -0
- timebase_mcp/oauth2.py +208 -0
- timebase_mcp/operations.py +28 -0
- timebase_mcp/resources.py +40 -0
- timebase_mcp/server.py +20 -0
- timebase_mcp/tools/__init__.py +12 -0
- timebase_mcp/tools/queries.py +59 -0
- timebase_mcp/tools/streams.py +124 -0
- timebase_mcp/tools/system.py +29 -0
- timebase_mcp-0.1.1.dist-info/METADATA +417 -0
- timebase_mcp-0.1.1.dist-info/RECORD +25 -0
- timebase_mcp-0.1.1.dist-info/WHEEL +4 -0
- timebase_mcp-0.1.1.dist-info/entry_points.txt +3 -0
timebase_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TimeBase MCP server package"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TimeBase MCP client adapters."""
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from binascii import Error as BinasciiError
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from contextlib import AbstractContextManager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from timebase_mcp.config import MCPSettings
|
|
13
|
+
from timebase_mcp.errors import InvalidStreamTimeRangeError
|
|
14
|
+
from timebase_mcp.models import (
|
|
15
|
+
CompileQQLResult,
|
|
16
|
+
QQLErrorPosition,
|
|
17
|
+
StreamInfo,
|
|
18
|
+
StreamSchema,
|
|
19
|
+
StreamSymbols,
|
|
20
|
+
StreamTimeRange,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TimeBaseClient(AbstractContextManager["TimeBaseClient"], ABC):
|
|
25
|
+
_DEFAULT_STREAM_SYMBOLS_PAGE_SIZE = 100
|
|
26
|
+
_MAX_STREAM_SYMBOLS_PAGE_SIZE = 500
|
|
27
|
+
_ERROR_CONTEXT_CHARS = 40
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: MCPSettings, *, read_only: bool = True) -> None:
|
|
30
|
+
self._read_only = read_only
|
|
31
|
+
self._settings = settings
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> "TimeBaseClient":
|
|
34
|
+
self.open()
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(
|
|
38
|
+
self,
|
|
39
|
+
exc_type: type[BaseException] | None,
|
|
40
|
+
exc: BaseException | None,
|
|
41
|
+
tb: TracebackType | None,
|
|
42
|
+
) -> Literal[False]:
|
|
43
|
+
self.close()
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def open(self) -> Any:
|
|
48
|
+
"""Opens a TimeBase connection."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
"""Closes the TimeBase connection."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def _require_db(self) -> Any:
|
|
56
|
+
"""Returns an open TimeBase connection."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def get_stream(self, stream_key: str) -> Any:
|
|
60
|
+
"""Returns a stream by key or raises StreamNotFoundError."""
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def _get_stream_schema_text(self, stream: Any) -> str:
|
|
64
|
+
"""Returns the text for a stream schema."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def _list_stream_symbols(self, stream: Any) -> list[str]:
|
|
68
|
+
"""Returns stream symbols/entities."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def _read_stream_messages(
|
|
72
|
+
self,
|
|
73
|
+
stream: Any,
|
|
74
|
+
reverse: bool,
|
|
75
|
+
count: int,
|
|
76
|
+
) -> list[dict[str, Any]]:
|
|
77
|
+
"""Read stream messages for preview output."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def _read_query_messages(self, query_text: str, limit: int) -> list[dict[str, Any]]:
|
|
81
|
+
"""Read query messages for preview output."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def _compile_query_tokens(self, query_text: str) -> list[Any]:
|
|
85
|
+
"""Compile QQL and return raw token objects."""
|
|
86
|
+
|
|
87
|
+
def list_streams(self) -> list[StreamInfo]:
|
|
88
|
+
streams = sorted(
|
|
89
|
+
self._require_db().listStreams(),
|
|
90
|
+
key=lambda stream: stream.key(),
|
|
91
|
+
)
|
|
92
|
+
return [
|
|
93
|
+
StreamInfo(key=stream.key(), description=stream.description())
|
|
94
|
+
for stream in streams
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
def get_stream_schema(self, stream_key: str) -> StreamSchema:
|
|
98
|
+
stream = self.get_stream(stream_key)
|
|
99
|
+
return StreamSchema(
|
|
100
|
+
stream_key=stream_key,
|
|
101
|
+
schema_text=self._get_stream_schema_text(stream),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def get_stream_symbols(
|
|
105
|
+
self,
|
|
106
|
+
stream_key: str,
|
|
107
|
+
limit: int = _DEFAULT_STREAM_SYMBOLS_PAGE_SIZE,
|
|
108
|
+
cursor: str | None = None,
|
|
109
|
+
) -> StreamSymbols:
|
|
110
|
+
if limit < 1:
|
|
111
|
+
raise ValueError("limit must be at least 1.")
|
|
112
|
+
|
|
113
|
+
page_size = min(limit, self._MAX_STREAM_SYMBOLS_PAGE_SIZE)
|
|
114
|
+
offset, cursor_total_symbols = self._decode_stream_symbols_cursor(
|
|
115
|
+
cursor, stream_key
|
|
116
|
+
)
|
|
117
|
+
stream = self.get_stream(stream_key)
|
|
118
|
+
symbols = sorted(self._list_stream_symbols(stream))
|
|
119
|
+
total_symbols = len(symbols)
|
|
120
|
+
symbols_changed_since_cursor = (
|
|
121
|
+
cursor is not None
|
|
122
|
+
and cursor_total_symbols is not None
|
|
123
|
+
and cursor_total_symbols != total_symbols
|
|
124
|
+
)
|
|
125
|
+
page_symbols = symbols[offset : offset + page_size]
|
|
126
|
+
next_offset = offset + len(page_symbols)
|
|
127
|
+
next_cursor = (
|
|
128
|
+
self._encode_stream_symbols_cursor(
|
|
129
|
+
stream_key=stream_key,
|
|
130
|
+
offset=next_offset,
|
|
131
|
+
total_symbols=total_symbols,
|
|
132
|
+
)
|
|
133
|
+
if next_offset < total_symbols
|
|
134
|
+
else None
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return StreamSymbols(
|
|
138
|
+
stream_key=stream_key,
|
|
139
|
+
symbols=page_symbols,
|
|
140
|
+
returned_count=len(page_symbols),
|
|
141
|
+
symbols_changed_since_cursor=symbols_changed_since_cursor,
|
|
142
|
+
next_cursor=next_cursor,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def get_stream_time_range(self, stream_key: str) -> StreamTimeRange:
|
|
146
|
+
stream = self.get_stream(stream_key)
|
|
147
|
+
time_range_ms = stream.getTimeRange()
|
|
148
|
+
|
|
149
|
+
if not time_range_ms:
|
|
150
|
+
return StreamTimeRange(stream_key=stream_key)
|
|
151
|
+
|
|
152
|
+
if len(time_range_ms) != 2:
|
|
153
|
+
raise InvalidStreamTimeRangeError(stream_key, time_range_ms)
|
|
154
|
+
|
|
155
|
+
start_timestamp_ms = time_range_ms[0]
|
|
156
|
+
end_timestamp_ms = time_range_ms[1]
|
|
157
|
+
|
|
158
|
+
if start_timestamp_ms > end_timestamp_ms:
|
|
159
|
+
raise InvalidStreamTimeRangeError(stream_key, time_range_ms)
|
|
160
|
+
|
|
161
|
+
return StreamTimeRange(
|
|
162
|
+
stream_key=stream_key,
|
|
163
|
+
start=self._timestamp_ms_to_datetime_utc(start_timestamp_ms),
|
|
164
|
+
end=self._timestamp_ms_to_datetime_utc(end_timestamp_ms),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def get_stream_messages_text(
|
|
168
|
+
self,
|
|
169
|
+
stream_key: str,
|
|
170
|
+
reverse: bool = False,
|
|
171
|
+
count: int = 10,
|
|
172
|
+
) -> str:
|
|
173
|
+
if count < 1:
|
|
174
|
+
raise ValueError("count must be at least 1.")
|
|
175
|
+
|
|
176
|
+
stream = self.get_stream(stream_key)
|
|
177
|
+
messages = self._read_stream_messages(stream, reverse, count)
|
|
178
|
+
|
|
179
|
+
return self._format_stream_messages_preview(
|
|
180
|
+
stream_key=stream_key,
|
|
181
|
+
reverse=reverse,
|
|
182
|
+
count=count,
|
|
183
|
+
messages=messages,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def execute_query(self, query: str, limit: int = 50) -> str:
|
|
187
|
+
query_text = query.strip()
|
|
188
|
+
if not query_text:
|
|
189
|
+
raise ValueError("query must not be empty.")
|
|
190
|
+
if limit < 1:
|
|
191
|
+
raise ValueError("limit must be at least 1.")
|
|
192
|
+
|
|
193
|
+
messages = self._read_query_messages(query_text, limit)
|
|
194
|
+
return self._format_query_messages_preview(
|
|
195
|
+
query_text=query_text,
|
|
196
|
+
limit=limit,
|
|
197
|
+
messages=messages,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def compile_query(self, query: str) -> CompileQQLResult:
|
|
201
|
+
query_text = query.strip()
|
|
202
|
+
if not query_text:
|
|
203
|
+
raise ValueError("query must not be empty.")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self._compile_query_tokens(query_text)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
error_text = str(exc)
|
|
209
|
+
error_position = self._parse_compile_error_position(error_text)
|
|
210
|
+
error_token, error_context = self._extract_error_details(
|
|
211
|
+
query_text, error_position
|
|
212
|
+
)
|
|
213
|
+
return CompileQQLResult(
|
|
214
|
+
valid=False,
|
|
215
|
+
error=error_text,
|
|
216
|
+
error_token=error_token,
|
|
217
|
+
error_context=error_context,
|
|
218
|
+
error_position=error_position,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return CompileQQLResult(valid=True)
|
|
222
|
+
|
|
223
|
+
def _normalize_message(self, message: Any) -> dict[str, Any]:
|
|
224
|
+
payload: dict[str, Any] = {
|
|
225
|
+
"type": type(message).__name__,
|
|
226
|
+
**self._message_payload(message),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
timestamp = self._message_timestamp(message)
|
|
230
|
+
if timestamp is not None:
|
|
231
|
+
payload["timestamp"] = timestamp
|
|
232
|
+
|
|
233
|
+
return payload
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _message_payload(message: Any) -> dict[str, Any]:
|
|
237
|
+
message_to_dict = getattr(message, "to_dict", None)
|
|
238
|
+
if callable(message_to_dict):
|
|
239
|
+
raw_payload = message_to_dict()
|
|
240
|
+
if isinstance(raw_payload, dict):
|
|
241
|
+
return {str(key): value for key, value in raw_payload.items()}
|
|
242
|
+
|
|
243
|
+
return dict(vars(message))
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _message_timestamp(message: Any) -> datetime | None:
|
|
247
|
+
get_datetime = getattr(message, "getDateTime", None)
|
|
248
|
+
if not callable(get_datetime):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
timestamp = get_datetime()
|
|
252
|
+
if not isinstance(timestamp, datetime):
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
if timestamp.tzinfo is None:
|
|
256
|
+
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
|
257
|
+
return timestamp.astimezone(timezone.utc)
|
|
258
|
+
|
|
259
|
+
def _format_stream_messages_preview(
|
|
260
|
+
self,
|
|
261
|
+
stream_key: str,
|
|
262
|
+
reverse: bool,
|
|
263
|
+
count: int,
|
|
264
|
+
messages: list[dict[str, Any]],
|
|
265
|
+
) -> str:
|
|
266
|
+
direction = "last" if reverse else "first"
|
|
267
|
+
header_lines = [
|
|
268
|
+
f"Stream: {stream_key}",
|
|
269
|
+
f"Showing {len(messages)} of requested {count} {direction} messages",
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
return self._format_messages_preview(
|
|
273
|
+
header_lines=header_lines,
|
|
274
|
+
messages=messages,
|
|
275
|
+
empty_text="No messages found.",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _format_query_messages_preview(
|
|
279
|
+
self,
|
|
280
|
+
query_text: str,
|
|
281
|
+
limit: int,
|
|
282
|
+
messages: list[dict[str, Any]],
|
|
283
|
+
) -> str:
|
|
284
|
+
header_lines = [
|
|
285
|
+
f"Query: {query_text}",
|
|
286
|
+
f"Showing {len(messages)} of requested {limit} result rows",
|
|
287
|
+
]
|
|
288
|
+
return self._format_messages_preview(
|
|
289
|
+
header_lines=header_lines,
|
|
290
|
+
messages=messages,
|
|
291
|
+
empty_text="No result rows.",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _format_messages_preview(
|
|
295
|
+
self,
|
|
296
|
+
header_lines: list[str],
|
|
297
|
+
messages: list[dict[str, Any]],
|
|
298
|
+
empty_text: str,
|
|
299
|
+
) -> str:
|
|
300
|
+
if not messages:
|
|
301
|
+
return "\n".join([*header_lines, "", empty_text])
|
|
302
|
+
|
|
303
|
+
return "\n".join(
|
|
304
|
+
[
|
|
305
|
+
*header_lines,
|
|
306
|
+
"",
|
|
307
|
+
*[
|
|
308
|
+
f"{index}. {json.dumps(message, default=self._json_default, sort_keys=True)}"
|
|
309
|
+
for index, message in enumerate(messages, start=1)
|
|
310
|
+
],
|
|
311
|
+
]
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _json_default(value: Any) -> str:
|
|
316
|
+
if isinstance(value, datetime):
|
|
317
|
+
if value.tzinfo is None:
|
|
318
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
319
|
+
return value.astimezone(timezone.utc).isoformat()
|
|
320
|
+
return str(value)
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _parse_compile_error_position(error_text: str) -> QQLErrorPosition | None:
|
|
324
|
+
range_match = re.search(
|
|
325
|
+
r"\[at\s+(\d+)\.(\d+)\.\.(?:(\d+)\.)?(\d+)\]",
|
|
326
|
+
error_text,
|
|
327
|
+
)
|
|
328
|
+
if range_match is not None:
|
|
329
|
+
start_line = int(range_match.group(1))
|
|
330
|
+
start_column = int(range_match.group(2))
|
|
331
|
+
end_line = (
|
|
332
|
+
int(range_match.group(3))
|
|
333
|
+
if range_match.group(3) is not None
|
|
334
|
+
else start_line
|
|
335
|
+
)
|
|
336
|
+
end_column = int(range_match.group(4))
|
|
337
|
+
return QQLErrorPosition(
|
|
338
|
+
start_line=start_line,
|
|
339
|
+
start_column=start_column,
|
|
340
|
+
end_line=end_line,
|
|
341
|
+
end_column=end_column,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
point_match = re.search(r"\[at\s+(\d+)[:.](\d+)\]", error_text)
|
|
345
|
+
if point_match is None:
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
line = int(point_match.group(1))
|
|
349
|
+
column = int(point_match.group(2))
|
|
350
|
+
return QQLErrorPosition(
|
|
351
|
+
start_line=line,
|
|
352
|
+
start_column=column,
|
|
353
|
+
end_line=line,
|
|
354
|
+
end_column=column,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
@classmethod
|
|
358
|
+
def _extract_error_details(
|
|
359
|
+
cls,
|
|
360
|
+
query_text: str,
|
|
361
|
+
error_position: QQLErrorPosition | None,
|
|
362
|
+
) -> tuple[str | None, str | None]:
|
|
363
|
+
if error_position is None:
|
|
364
|
+
return None, None
|
|
365
|
+
|
|
366
|
+
start_offset = cls._line_column_to_offset(
|
|
367
|
+
query_text,
|
|
368
|
+
error_position.start_line,
|
|
369
|
+
error_position.start_column,
|
|
370
|
+
is_end=False,
|
|
371
|
+
)
|
|
372
|
+
end_offset = cls._line_column_to_offset(
|
|
373
|
+
query_text,
|
|
374
|
+
error_position.end_line,
|
|
375
|
+
error_position.end_column,
|
|
376
|
+
is_end=True,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if start_offset is None or end_offset is None:
|
|
380
|
+
return None, None
|
|
381
|
+
|
|
382
|
+
if end_offset < start_offset:
|
|
383
|
+
end_offset = start_offset
|
|
384
|
+
|
|
385
|
+
error_token_value = query_text[start_offset:end_offset].strip()
|
|
386
|
+
error_token = error_token_value or None
|
|
387
|
+
context_start = max(0, start_offset - cls._ERROR_CONTEXT_CHARS)
|
|
388
|
+
context_end = min(len(query_text), end_offset + cls._ERROR_CONTEXT_CHARS)
|
|
389
|
+
context = query_text[context_start:context_end]
|
|
390
|
+
if not context:
|
|
391
|
+
return error_token, None
|
|
392
|
+
if context_start > 0:
|
|
393
|
+
context = f"...{context}"
|
|
394
|
+
if context_end < len(query_text):
|
|
395
|
+
context = f"{context}..."
|
|
396
|
+
return error_token, context
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _line_column_to_offset(
|
|
400
|
+
text: str,
|
|
401
|
+
line: int,
|
|
402
|
+
column: int,
|
|
403
|
+
*,
|
|
404
|
+
is_end: bool,
|
|
405
|
+
) -> int | None:
|
|
406
|
+
if line < 1 or column < 1:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
lines = text.splitlines(keepends=True)
|
|
410
|
+
if line > len(lines):
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
raw_line = lines[line - 1]
|
|
414
|
+
line_without_newline = raw_line.rstrip("\r\n")
|
|
415
|
+
line_start = sum(len(lines[index]) for index in range(line - 1))
|
|
416
|
+
line_length = len(line_without_newline)
|
|
417
|
+
|
|
418
|
+
if is_end:
|
|
419
|
+
clamped_column = min(column, line_length)
|
|
420
|
+
return line_start + clamped_column
|
|
421
|
+
|
|
422
|
+
clamped_column = min(column - 1, line_length)
|
|
423
|
+
return line_start + clamped_column
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _encode_stream_symbols_cursor(
|
|
427
|
+
stream_key: str,
|
|
428
|
+
offset: int,
|
|
429
|
+
total_symbols: int,
|
|
430
|
+
) -> str:
|
|
431
|
+
cursor_payload = json.dumps(
|
|
432
|
+
{
|
|
433
|
+
"stream_key": stream_key,
|
|
434
|
+
"offset": offset,
|
|
435
|
+
"total_symbols": total_symbols,
|
|
436
|
+
},
|
|
437
|
+
separators=(",", ":"),
|
|
438
|
+
).encode("utf-8")
|
|
439
|
+
return base64.urlsafe_b64encode(cursor_payload).decode("ascii").rstrip("=")
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def _decode_stream_symbols_cursor(
|
|
443
|
+
cursor: str | None,
|
|
444
|
+
expected_stream_key: str,
|
|
445
|
+
) -> tuple[int, int | None]:
|
|
446
|
+
if cursor is None:
|
|
447
|
+
return 0, None
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
padding = "=" * (-len(cursor) % 4)
|
|
451
|
+
decoded_payload = base64.urlsafe_b64decode(cursor + padding).decode("utf-8")
|
|
452
|
+
payload = json.loads(decoded_payload)
|
|
453
|
+
except (ValueError, UnicodeDecodeError, BinasciiError) as exc:
|
|
454
|
+
raise ValueError("Invalid cursor.") from exc
|
|
455
|
+
|
|
456
|
+
offset = payload.get("offset")
|
|
457
|
+
stream_key = payload.get("stream_key")
|
|
458
|
+
total_symbols = payload.get("total_symbols")
|
|
459
|
+
if (
|
|
460
|
+
not isinstance(offset, int)
|
|
461
|
+
or offset < 0
|
|
462
|
+
or not isinstance(stream_key, str)
|
|
463
|
+
or stream_key != expected_stream_key
|
|
464
|
+
):
|
|
465
|
+
raise ValueError("Invalid cursor.")
|
|
466
|
+
|
|
467
|
+
if total_symbols is not None and (
|
|
468
|
+
not isinstance(total_symbols, int) or total_symbols < 0
|
|
469
|
+
):
|
|
470
|
+
raise ValueError("Invalid cursor.")
|
|
471
|
+
|
|
472
|
+
return offset, total_symbols
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _timestamp_ms_to_datetime_utc(timestamp_ms: int) -> datetime:
|
|
476
|
+
return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _closing(cursor: Any) -> AbstractContextManager[Any]:
|
|
480
|
+
close = getattr(cursor, "close", None)
|
|
481
|
+
if not callable(close):
|
|
482
|
+
raise TypeError("Cursor object does not provide close().")
|
|
483
|
+
|
|
484
|
+
class CursorContext(AbstractContextManager[Any]):
|
|
485
|
+
def __enter__(self) -> Any:
|
|
486
|
+
return cursor
|
|
487
|
+
|
|
488
|
+
def __exit__(
|
|
489
|
+
self,
|
|
490
|
+
exc_type: type[BaseException] | None,
|
|
491
|
+
exc: BaseException | None,
|
|
492
|
+
tb: TracebackType | None,
|
|
493
|
+
) -> Literal[False]:
|
|
494
|
+
close()
|
|
495
|
+
return False
|
|
496
|
+
|
|
497
|
+
return CursorContext()
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _call_cursor_context(
|
|
501
|
+
factory: Callable[..., Any],
|
|
502
|
+
*args: Any,
|
|
503
|
+
**kwargs: Any,
|
|
504
|
+
) -> AbstractContextManager[Any]:
|
|
505
|
+
result = factory(*args, **kwargs)
|
|
506
|
+
enter = getattr(result, "__enter__", None)
|
|
507
|
+
exit_ = getattr(result, "__exit__", None)
|
|
508
|
+
if callable(enter) and callable(exit_):
|
|
509
|
+
return result
|
|
510
|
+
return TimeBaseClient._closing(result)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
from timebase_mcp.clients.base import TimeBaseClient
|
|
7
|
+
from timebase_mcp.config import MCPSettings
|
|
8
|
+
from timebase_mcp.errors import (
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
StreamNotFoundError,
|
|
11
|
+
TimeBaseConnectionError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import dxapi_ce as dxapi_ce_types
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import dxapi_ce
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
dxapi_ce = None
|
|
21
|
+
_DXAPI_CE_IMPORT_ERROR = exc
|
|
22
|
+
else:
|
|
23
|
+
_DXAPI_CE_IMPORT_ERROR = None
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CommunityTimeBaseClient(TimeBaseClient):
|
|
29
|
+
def __init__(self, settings: MCPSettings, *, read_only: bool = True) -> None:
|
|
30
|
+
super().__init__(settings, read_only=read_only)
|
|
31
|
+
self._db: dxapi_ce_types.TickDb | None = None
|
|
32
|
+
|
|
33
|
+
def open(self) -> dxapi_ce_types.TickDb:
|
|
34
|
+
if self._db is not None and self._db.isOpen():
|
|
35
|
+
return self._db
|
|
36
|
+
|
|
37
|
+
self._ensure_dxapi_ce()
|
|
38
|
+
assert dxapi_ce is not None
|
|
39
|
+
password = None
|
|
40
|
+
if self._settings.tb_password is not None:
|
|
41
|
+
password = self._settings.tb_password.get_secret_value()
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
if self._settings.tb_username is None and password is None:
|
|
45
|
+
db = dxapi_ce.TickDb.createFromUrl(self._settings.tb_url)
|
|
46
|
+
else:
|
|
47
|
+
username = self._settings.tb_username
|
|
48
|
+
assert username is not None
|
|
49
|
+
assert password is not None
|
|
50
|
+
db = dxapi_ce.TickDb.createFromUrl(
|
|
51
|
+
self._settings.tb_url, username, password
|
|
52
|
+
)
|
|
53
|
+
db.open(self._read_only)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
raise TimeBaseConnectionError(
|
|
56
|
+
f"Failed to connect to TimeBase at '{self._settings.tb_url}': {exc}"
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
logger.info(
|
|
60
|
+
"Connected to TimeBase via community client at %s",
|
|
61
|
+
self._settings.tb_url,
|
|
62
|
+
)
|
|
63
|
+
self._db = db
|
|
64
|
+
return db
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
if self._db is None:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
self._db.close()
|
|
72
|
+
finally:
|
|
73
|
+
self._db = None
|
|
74
|
+
|
|
75
|
+
def get_stream(self, stream_key: str) -> dxapi_ce_types.TickStream:
|
|
76
|
+
stream = self._require_db().getStream(stream_key)
|
|
77
|
+
# type:
|
|
78
|
+
if stream is None:
|
|
79
|
+
raise StreamNotFoundError(stream_key)
|
|
80
|
+
return stream
|
|
81
|
+
|
|
82
|
+
def _require_db(self) -> dxapi_ce_types.TickDb:
|
|
83
|
+
if self._db is None or not self._db.isOpen():
|
|
84
|
+
return self.open()
|
|
85
|
+
return self._db
|
|
86
|
+
|
|
87
|
+
def _get_stream_schema_text(self, stream: dxapi_ce_types.TickStream) -> str:
|
|
88
|
+
return stream.describe()
|
|
89
|
+
|
|
90
|
+
def _list_stream_symbols(self, stream: dxapi_ce_types.TickStream) -> list[str]:
|
|
91
|
+
return list[str](stream.listSymbols())
|
|
92
|
+
|
|
93
|
+
def _read_stream_messages(
|
|
94
|
+
self,
|
|
95
|
+
stream: dxapi_ce_types.TickStream,
|
|
96
|
+
reverse: bool,
|
|
97
|
+
count: int,
|
|
98
|
+
) -> list[dict[str, Any]]:
|
|
99
|
+
self._ensure_dxapi_ce()
|
|
100
|
+
assert dxapi_ce is not None
|
|
101
|
+
options = dxapi_ce.SelectionOptions()
|
|
102
|
+
options.live = False
|
|
103
|
+
options.reverse = reverse
|
|
104
|
+
timestamp = (
|
|
105
|
+
dxapi_ce.JAVA_LONG_MAX_VALUE if reverse else dxapi_ce.JAVA_LONG_MIN_VALUE
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
messages: list[dict[str, Any]] = []
|
|
109
|
+
with self._call_cursor_context(
|
|
110
|
+
stream.select,
|
|
111
|
+
timestamp,
|
|
112
|
+
options,
|
|
113
|
+
None,
|
|
114
|
+
None,
|
|
115
|
+
) as cursor:
|
|
116
|
+
cursor = cast("dxapi_ce_types.TickCursor", cursor)
|
|
117
|
+
while len(messages) < count and cursor.next():
|
|
118
|
+
messages.append(self._normalize_message(cursor.getMessage()))
|
|
119
|
+
|
|
120
|
+
if reverse:
|
|
121
|
+
messages.reverse()
|
|
122
|
+
|
|
123
|
+
return messages
|
|
124
|
+
|
|
125
|
+
def _read_query_messages(self, query_text: str, limit: int) -> list[dict[str, Any]]:
|
|
126
|
+
messages: list[dict[str, Any]] = []
|
|
127
|
+
with self._call_cursor_context(
|
|
128
|
+
self._require_db().tryExecuteQuery,
|
|
129
|
+
query_text,
|
|
130
|
+
) as cursor:
|
|
131
|
+
cursor = cast("dxapi_ce_types.TickCursor", cursor)
|
|
132
|
+
while len(messages) < limit and cursor.next():
|
|
133
|
+
messages.append(self._normalize_message(cursor.getMessage()))
|
|
134
|
+
|
|
135
|
+
return messages
|
|
136
|
+
|
|
137
|
+
def _compile_query_tokens(self, query_text: str) -> list[Any]:
|
|
138
|
+
return list[Any](self._require_db().compileQuery(query_text))
|
|
139
|
+
|
|
140
|
+
def _ensure_dxapi_ce(self) -> None:
|
|
141
|
+
if dxapi_ce is None:
|
|
142
|
+
raise ConfigurationError(
|
|
143
|
+
"Community edition requires installing timebase-mcp[community]"
|
|
144
|
+
) from _DXAPI_CE_IMPORT_ERROR
|