libentry 1.22.4__py3-none-any.whl → 1.23__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.
libentry/mcp/client.py ADDED
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env python3
2
+
3
+ __author__ = "xi"
4
+
5
+ import abc
6
+ import uuid
7
+ from queue import Queue
8
+ from threading import Semaphore, Thread
9
+ from time import sleep
10
+ from types import GeneratorType
11
+ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
12
+ from urllib.parse import urlencode, urljoin
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, TypeAdapter
16
+
17
+ from libentry import json
18
+ from libentry.mcp.types import CallToolRequestParams, CallToolResult, ClientCapabilities, HTTPOptions, HTTPRequest, \
19
+ HTTPResponse, Implementation, InitializeRequestParams, InitializeResult, JSONObject, JSONRPCError, \
20
+ JSONRPCNotification, \
21
+ JSONRPCRequest, \
22
+ JSONRPCResponse, JSONType, ListResourcesResult, ListToolsResult, MIME, ReadResourceRequestParams, \
23
+ ReadResourceResult, SSE, SubroutineError, SubroutineResponse
24
+
25
+
26
+ class ServiceError(RuntimeError):
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ cause: Optional[str] = None,
32
+ _traceback: Optional[str] = None
33
+ ):
34
+ self.message = message
35
+ self.cause = cause
36
+ self.traceback = _traceback
37
+
38
+ def __str__(self):
39
+ lines = []
40
+ if self.message:
41
+ lines += [self.message, "\n\n"]
42
+ if self.cause:
43
+ lines += ["This is caused by server side error ", self.cause, ".\n"]
44
+ if self.traceback:
45
+ lines += ["Below is the stacktrace:\n", self.traceback.rstrip()]
46
+ return "".join(lines)
47
+
48
+ @staticmethod
49
+ def from_subroutine_error(error: SubroutineError):
50
+ return ServiceError(error.message, error.error, error.traceback)
51
+
52
+ @staticmethod
53
+ def from_jsonrpc_error(error: JSONRPCError):
54
+ cause = None
55
+ traceback_ = None
56
+ if isinstance(error.data, Dict):
57
+ cause = error.data.get("error")
58
+ traceback_ = error.data.get("traceback")
59
+ return ServiceError(error.message, cause, traceback_)
60
+
61
+
62
+ class SSEDecoder:
63
+
64
+ def __init__(self) -> None:
65
+ self._event = ""
66
+ self._data: List[str] = []
67
+ self._last_event_id = ""
68
+ self._retry: Optional[int] = None
69
+
70
+ def decode(self, line: str) -> Optional[SSE]:
71
+ if not line:
72
+ if (
73
+ not self._event
74
+ and not self._data
75
+ and not self._last_event_id
76
+ and self._retry is None
77
+ ):
78
+ return None
79
+
80
+ sse = SSE(
81
+ event=self._event,
82
+ data="\n".join(self._data),
83
+ )
84
+
85
+ # NOTE: as per the SSE spec, do not reset last_event_id.
86
+ self._event = ""
87
+ self._data = []
88
+ self._retry = None
89
+
90
+ return sse
91
+
92
+ if line.startswith(":"):
93
+ return None
94
+
95
+ fieldname, _, value = line.partition(":")
96
+
97
+ if value.startswith(" "):
98
+ value = value[1:]
99
+
100
+ if fieldname == "event":
101
+ self._event = value
102
+ elif fieldname == "data":
103
+ self._data.append(value)
104
+ elif fieldname == "id":
105
+ if "\0" in value:
106
+ pass
107
+ else:
108
+ self._last_event_id = value
109
+ elif fieldname == "retry":
110
+ try:
111
+ self._retry = int(value)
112
+ except (TypeError, ValueError):
113
+ pass
114
+ else:
115
+ pass # Field is ignored.
116
+
117
+ return None
118
+
119
+
120
+ class SubroutineMixIn(abc.ABC):
121
+
122
+ @abc.abstractmethod
123
+ def subroutine_request(
124
+ self,
125
+ path: str,
126
+ params: Dict[str, Any],
127
+ options: Optional[HTTPOptions] = None
128
+ ) -> Union[SubroutineResponse, Iterable[SubroutineResponse]]:
129
+ raise NotImplementedError()
130
+
131
+ def request(
132
+ self,
133
+ path: str,
134
+ params: Optional[JSONObject] = None,
135
+ options: Optional[HTTPOptions] = None
136
+ ) -> Union[JSONType, Iterable[JSONType]]:
137
+ response = self.subroutine_request(path, params, options)
138
+ if not isinstance(response, GeneratorType):
139
+ if response.error is None:
140
+ return response.result
141
+ else:
142
+ raise ServiceError.from_subroutine_error(response.error)
143
+ else:
144
+ return self._iter_results_from_subroutine(response)
145
+
146
+ @staticmethod
147
+ def _iter_results_from_subroutine(responses: Iterable[SubroutineResponse]) -> Iterable[JSONType]:
148
+ for response in responses:
149
+ if response.error is None:
150
+ yield response.result
151
+ else:
152
+ raise ServiceError.from_subroutine_error(response.error)
153
+
154
+ def get(
155
+ self,
156
+ path: str,
157
+ options: Optional[HTTPOptions] = None
158
+ ) -> Union[JSONType, Iterable[JSONType]]:
159
+ if options is None:
160
+ options = HTTPOptions(method="GET")
161
+ if options.method != "GET":
162
+ raise ValueError("options.method should be \"GET\".")
163
+ return self.request(path=path, params=None, options=options)
164
+
165
+ def post(
166
+ self,
167
+ path: str,
168
+ params: Optional[JSONObject] = None,
169
+ options: Optional[HTTPOptions] = None
170
+ ) -> Union[JSONType, Iterable[JSONType]]:
171
+ if options is None:
172
+ options = HTTPOptions(method="POST")
173
+ if options.method != "POST":
174
+ raise ValueError("options.method should be \"POST\".")
175
+ return self.request(path=path, params=params, options=options)
176
+
177
+
178
+ class JSONRPCMixIn(abc.ABC):
179
+
180
+ @abc.abstractmethod
181
+ def jsonrpc_request(
182
+ self,
183
+ request: JSONRPCRequest,
184
+ path: Optional[str] = None,
185
+ options: Optional[HTTPOptions] = None
186
+ ) -> Union[JSONRPCResponse, Iterable[JSONRPCResponse]]:
187
+ raise NotImplementedError()
188
+
189
+ @abc.abstractmethod
190
+ def jsonrpc_notify(
191
+ self,
192
+ request: JSONRPCNotification,
193
+ path: Optional[str] = None,
194
+ options: Optional[HTTPOptions] = None
195
+ ) -> None:
196
+ raise NotImplementedError()
197
+
198
+ def call(
199
+ self,
200
+ method: str,
201
+ params: Optional[JSONObject] = None,
202
+ options: Optional[HTTPOptions] = None
203
+ ) -> Union[JSONType, Iterable[JSONType]]:
204
+ request = JSONRPCRequest(
205
+ jsonrpc="2.0",
206
+ id=str(uuid.uuid4()),
207
+ method=method,
208
+ params=params
209
+ )
210
+
211
+ response = self.jsonrpc_request(request, options=options)
212
+
213
+ if not isinstance(response, GeneratorType):
214
+ if response.error is None:
215
+ return response.result
216
+ else:
217
+ raise ServiceError.from_jsonrpc_error(response.error)
218
+ else:
219
+ return self._iter_results_from_jsonrpc(response)
220
+
221
+ @staticmethod
222
+ def _iter_results_from_jsonrpc(responses: Iterable[JSONRPCResponse]) -> Iterable[JSONType]:
223
+ for response in responses:
224
+ if response.error is None:
225
+ yield response.result
226
+ else:
227
+ raise ServiceError.from_jsonrpc_error(response.error)
228
+
229
+
230
+ class MCPMixIn(JSONRPCMixIn, abc.ABC):
231
+
232
+ def initialize(self) -> InitializeResult:
233
+ params = InitializeRequestParams(
234
+ protocolVersion="2024-11-05",
235
+ capabilities=ClientCapabilities(),
236
+ clientInfo=Implementation(name="libentry-client", version="1.0.0")
237
+ ).model_dump(exclude_none=True)
238
+
239
+ result = self.call("initialize", params)
240
+
241
+ self.jsonrpc_notify(JSONRPCNotification(
242
+ jsonrpc="2.0", method="notifications/initialized"
243
+ ))
244
+
245
+ return InitializeResult.model_validate(result)
246
+
247
+ def list_tools(self) -> ListToolsResult:
248
+ result = self.call("tools/list")
249
+ return ListToolsResult.model_validate(result)
250
+
251
+ def call_tool(self, name: str, arguments: Dict[str, Any]) -> Union[CallToolResult, Iterable[CallToolResult]]:
252
+ params = CallToolRequestParams(
253
+ name=name,
254
+ arguments=arguments
255
+ ).model_dump()
256
+ result = self.call("tools/call", params)
257
+ if not isinstance(result, GeneratorType):
258
+ return CallToolResult.model_validate(result)
259
+ else:
260
+ return (
261
+ CallToolResult.model_validate(item)
262
+ for item in result
263
+ )
264
+
265
+ def list_resources(self) -> ListResourcesResult:
266
+ result = self.call("resources/list")
267
+ return ListResourcesResult.model_validate(result)
268
+
269
+ def read_resource(self, uri: str) -> Union[ReadResourceResult, Iterable[ReadResourceResult]]:
270
+ params = ReadResourceRequestParams(uri=uri).model_dump()
271
+ result = self.call("resources/read", params)
272
+ if not isinstance(result, GeneratorType):
273
+ return ReadResourceResult.model_validate(result)
274
+ else:
275
+ return (
276
+ ReadResourceResult.model_validate(item)
277
+ for item in result
278
+ )
279
+
280
+
281
+ class APIClient(SubroutineMixIn, MCPMixIn):
282
+
283
+ def __init__(
284
+ self,
285
+ base_url: Optional[str] = None,
286
+ *,
287
+ headers: Optional[Dict[str, str]] = None,
288
+ content_type: str = MIME.json.value,
289
+ accept: str = f"{MIME.plain.value},{MIME.json.value},{MIME.sse.value}",
290
+ user_agent: str = "python-libentry",
291
+ connection: str = "keep-alive",
292
+ api_key: Optional[str] = None,
293
+ verify=False,
294
+ stream_read_size: int = 512,
295
+ sse_endpoint: str = "/sse",
296
+ jsonrpc_endpoint: str = "/message"
297
+ ) -> None:
298
+ self.base_url = base_url
299
+
300
+ self.headers = {} if headers is None else {**headers}
301
+ self.headers["Content-Type"] = content_type
302
+ self.headers["Accept"] = accept
303
+ self.headers["User-Agent"] = user_agent
304
+ self.headers["Connection"] = connection
305
+
306
+ if api_key is not None:
307
+ self.headers["Authorization"] = f"Bearer {api_key}"
308
+
309
+ self.verify = verify
310
+ self.stream_read_size = stream_read_size
311
+ self.sse_endpoint = sse_endpoint
312
+ self.jsonrpc_endpoint = jsonrpc_endpoint
313
+
314
+ self.client = httpx.Client(verify=verify)
315
+
316
+ @staticmethod
317
+ def x_www_form_urlencoded(json_data: Dict[str, Any]):
318
+ result = []
319
+ for k, v in json_data.items():
320
+ if v is not None:
321
+ result.append((
322
+ k.encode("utf-8") if isinstance(k, str) else k,
323
+ v.encode("utf-8") if isinstance(v, str) else v,
324
+ ))
325
+ return urlencode(result, doseq=True)
326
+
327
+ @staticmethod
328
+ def find_content_type(*headers: Optional[Dict[str, str]]) -> Tuple[Optional[str], Dict[str, str]]:
329
+ content_type = None
330
+ for h in headers:
331
+ if h is None:
332
+ continue
333
+ try:
334
+ content_type = h["Content-Type"]
335
+ except KeyError:
336
+ continue
337
+
338
+ if content_type is None:
339
+ return None, {}
340
+
341
+ items = content_type.split(";")
342
+ mime = items[0].strip()
343
+ params = {}
344
+ for item in items[1:]:
345
+ item = item.strip()
346
+ i = item.find("=")
347
+ if i < 0:
348
+ continue
349
+ params[item[:i]] = item[i + 1:]
350
+ return mime, params
351
+
352
+ def http_request(self, request: HTTPRequest) -> HTTPResponse:
353
+ options = request.options
354
+ timeout_value = options.timeout
355
+ err = None
356
+ for i in range(options.num_trials):
357
+ timeout_value *= (1 + i * options.retry_factor)
358
+ try:
359
+ return self._http_request(request, timeout_value)
360
+ except httpx.TimeoutException as e:
361
+ err = e
362
+ if callable(options.on_error):
363
+ options.on_error(e)
364
+ except httpx.HTTPError as e:
365
+ err = e
366
+ if callable(options.on_error):
367
+ options.on_error(e)
368
+ sleep(options.interval)
369
+ raise err
370
+
371
+ def _http_request(self, request: HTTPRequest, timeout: float) -> HTTPResponse:
372
+ full_url = urljoin(self.base_url, request.path)
373
+ headers = (
374
+ {**self.headers}
375
+ if request.options.headers is None else
376
+ {**self.headers, **request.options.headers}
377
+ )
378
+ req_mime, _ = self.find_content_type(headers)
379
+ if (req_mime is None) or req_mime in {MIME.json.value, MIME.plain.value}:
380
+ payload = json.dumps(request.json_obj) if request.json_obj is not None else None
381
+ elif req_mime == MIME.form.value:
382
+ payload = self.x_www_form_urlencoded(request.json_obj) if request.json_obj is not None else None
383
+ else:
384
+ raise ValueError(f"Unsupported request MIME: \"{req_mime}\".")
385
+
386
+ httpx_request = self.client.build_request(
387
+ method=request.options.method,
388
+ url=full_url,
389
+ content=payload,
390
+ headers=headers,
391
+ timeout=timeout
392
+ )
393
+ httpx_response = self.client.send(httpx_request, stream=True)
394
+
395
+ if httpx_response.status_code // 100 != 2:
396
+ raise ServiceError(self._read_content(httpx_response))
397
+
398
+ resp_mime, _ = self.find_content_type(httpx_response.headers)
399
+
400
+ stream = request.options.stream
401
+ if stream is None:
402
+ stream = "-stream" in resp_mime
403
+
404
+ if not stream:
405
+ if resp_mime is None or resp_mime == MIME.plain.value:
406
+ content = self._read_content(httpx_response)
407
+ elif resp_mime == MIME.json.value:
408
+ content = self._read_content(httpx_response)
409
+ content = json.loads(content) if content else None
410
+ else:
411
+ raise RuntimeError(f"Unsupported response MIME: \"{resp_mime}\".")
412
+ else:
413
+ if resp_mime is None or resp_mime == MIME.sse.value:
414
+ content = self._iter_events(self._iter_lines(httpx_response))
415
+ elif resp_mime == MIME.json_stream.value:
416
+ content = self._iter_objs(self._iter_lines(httpx_response))
417
+ else:
418
+ raise RuntimeError(f"Unsupported response MIME: \"{resp_mime}\".")
419
+ return HTTPResponse(
420
+ status_code=httpx_response.status_code,
421
+ headers={**httpx_response.headers},
422
+ stream=stream,
423
+ content=content
424
+ )
425
+
426
+ # noinspection PyTypeChecker
427
+ @staticmethod
428
+ def _read_content(response: httpx.Response) -> str:
429
+ try:
430
+ charset = response.charset_encoding or "utf-8"
431
+ return response.read().decode(charset)
432
+ finally:
433
+ response.close()
434
+
435
+ @staticmethod
436
+ def _iter_lines(response: httpx.Response) -> Iterable[str]:
437
+ try:
438
+ for line in response.iter_lines():
439
+ yield line
440
+ finally:
441
+ response.close()
442
+
443
+ @staticmethod
444
+ def _iter_events(lines: Iterable[str]) -> Iterable[SSE]:
445
+ decoder = SSEDecoder()
446
+ for line in lines:
447
+ line = line.rstrip()
448
+ sse = decoder.decode(line)
449
+ if sse is not None:
450
+ yield sse
451
+
452
+ @staticmethod
453
+ def _iter_objs(lines: Iterable[str]) -> Iterable[Dict]:
454
+ for line in lines:
455
+ line = line.strip()
456
+ if not line:
457
+ continue
458
+ yield json.loads(line)
459
+
460
+ def subroutine_request(
461
+ self,
462
+ path: str,
463
+ params: Dict[str, Any],
464
+ options: Optional[HTTPOptions] = None
465
+ ) -> Union[SubroutineResponse, Iterable[SubroutineResponse]]:
466
+ if isinstance(params, BaseModel):
467
+ params = params.model_dump(exclude_none=True)
468
+
469
+ json_request = HTTPRequest(
470
+ path=path,
471
+ json_obj=params,
472
+ options=options or HTTPOptions()
473
+ )
474
+ json_response = self.http_request(json_request)
475
+ if not json_response.stream:
476
+ return SubroutineResponse.model_validate(json_response.content)
477
+ else:
478
+ return self._iter_subroutine_responses(json_response)
479
+
480
+ @staticmethod
481
+ def _iter_subroutine_responses(response: HTTPResponse) -> Iterable[SubroutineResponse]:
482
+ for sse in response.content:
483
+ assert isinstance(sse, SSE)
484
+ if sse.event != "message":
485
+ continue
486
+ if not sse.data:
487
+ continue
488
+ json_obj = json.loads(sse.data)
489
+ yield SubroutineResponse.model_validate(json_obj)
490
+
491
+ def jsonrpc_request(
492
+ self,
493
+ request: JSONRPCRequest,
494
+ path: Optional[str] = None,
495
+ options: Optional[HTTPOptions] = None
496
+ ) -> Union[JSONRPCResponse, Iterable[JSONRPCResponse]]:
497
+ json_request = HTTPRequest(
498
+ path=path or self.jsonrpc_endpoint,
499
+ json_obj=request.model_dump(),
500
+ options=options or HTTPOptions()
501
+ )
502
+ json_response = self.http_request(json_request)
503
+ if not json_response.stream:
504
+ return JSONRPCResponse.model_validate(json_response.content)
505
+ else:
506
+ return self._iter_jsonrpc_responses(json_response)
507
+
508
+ @staticmethod
509
+ def _iter_jsonrpc_responses(response: HTTPResponse) -> Iterable[JSONRPCResponse]:
510
+ for sse in response.content:
511
+ assert isinstance(sse, SSE)
512
+ if sse.event != "message":
513
+ continue
514
+ if not sse.data:
515
+ continue
516
+ json_obj = json.loads(sse.data)
517
+ yield JSONRPCResponse.model_validate(json_obj)
518
+
519
+ def jsonrpc_notify(
520
+ self,
521
+ request: JSONRPCNotification,
522
+ path: Optional[str] = None,
523
+ options: Optional[HTTPOptions] = None
524
+ ) -> None:
525
+ pass
526
+
527
+ def start_session(self, sse_endpoint: Optional[str] = None):
528
+ return SSESession(self, sse_endpoint=sse_endpoint or self.sse_endpoint)
529
+
530
+
531
+ class SSESession(MCPMixIn):
532
+
533
+ def __init__(self, client: APIClient, sse_endpoint: str):
534
+ self.client = client
535
+ self.sse_endpoint = sse_endpoint
536
+
537
+ self.sse_thread = Thread(target=self._sse_loop, daemon=True)
538
+ self.sse_thread.start()
539
+
540
+ self.lock = Semaphore(0)
541
+ self.endpoint = None
542
+ self.pendings = {}
543
+
544
+ def _sse_loop(self):
545
+ request = HTTPRequest(
546
+ path=self.sse_endpoint,
547
+ options=HTTPOptions(
548
+ method="GET",
549
+ timeout=60
550
+ )
551
+ )
552
+ response = self.client.http_request(request)
553
+ assert response.stream
554
+ type_adapter = TypeAdapter(Union[JSONRPCRequest, JSONRPCResponse, JSONRPCNotification])
555
+ for sse in response.content:
556
+ assert isinstance(sse, SSE)
557
+ if sse.event == "endpoint":
558
+ self.endpoint = sse.data
559
+ self.lock.release()
560
+ elif sse.event == "message":
561
+ json_obj = json.loads(sse.data)
562
+ obj = type_adapter.validate_python(json_obj)
563
+ if isinstance(obj, JSONRPCRequest):
564
+ self._on_request(obj)
565
+ elif isinstance(obj, JSONRPCNotification):
566
+ self._on_notification(obj)
567
+ elif isinstance(obj, JSONRPCResponse):
568
+ self._on_response(obj)
569
+ else:
570
+ pass
571
+ else:
572
+ raise RuntimeError(f"Unknown event {sse.event}.")
573
+
574
+ def _on_request(self, request: JSONRPCRequest):
575
+ pass
576
+
577
+ def _on_notification(self, notification: JSONRPCNotification):
578
+ pass
579
+
580
+ def _on_response(self, response: JSONRPCResponse):
581
+ request_id = response.id
582
+ with self.lock:
583
+ pending = self.pendings.get(request_id)
584
+
585
+ if pending is not None:
586
+ pending.put(response)
587
+
588
+ def jsonrpc_request(
589
+ self,
590
+ request: JSONRPCRequest,
591
+ path: Optional[str] = None,
592
+ options: Optional[HTTPOptions] = None
593
+ ) -> JSONRPCResponse:
594
+ with self.lock:
595
+ if path is None:
596
+ path = self.endpoint
597
+ assert request.id not in self.pendings
598
+ pending = Queue(8)
599
+ self.pendings[request.id] = pending
600
+
601
+ if options is None:
602
+ options = HTTPOptions(stream=False)
603
+
604
+ if options.stream is None or options.stream == True:
605
+ raise ValueError(f"options.stream should be False.")
606
+
607
+ self.client.http_request(HTTPRequest(
608
+ path=path,
609
+ json_obj=request.model_dump(),
610
+ options=options
611
+ ))
612
+
613
+ response = pending.get()
614
+ with self.lock:
615
+ del self.pendings[request.id]
616
+
617
+ if not isinstance(response, JSONRPCResponse):
618
+ raise ServiceError(
619
+ f"Invalid response type. "
620
+ f"Expect JSONRPCResponse, got {type(response)}."
621
+ )
622
+ return response
623
+
624
+ def jsonrpc_notify(
625
+ self,
626
+ request: JSONRPCNotification,
627
+ path: Optional[str] = None,
628
+ options: Optional[HTTPOptions] = None
629
+ ) -> None:
630
+ if path is None:
631
+ with self.lock:
632
+ path = self.endpoint
633
+
634
+ if options is None:
635
+ options = HTTPOptions(stream=False)
636
+
637
+ if options.stream is None or options.stream == True:
638
+ raise ValueError(f"options.stream should be False.")
639
+
640
+ self.client.http_request(HTTPRequest(
641
+ path=path,
642
+ json_obj=request.model_dump(),
643
+ options=options
644
+ ))