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.
@@ -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