libentry 1.22.3__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/api.py +1 -1
- libentry/json.py +4 -0
- libentry/mcp/__init__.py +1 -0
- libentry/mcp/api.py +101 -0
- libentry/mcp/client.py +644 -0
- libentry/mcp/service.py +883 -0
- libentry/mcp/types.py +441 -0
- libentry/schema.py +105 -50
- libentry/service/flask.py +15 -2
- {libentry-1.22.3.dist-info → libentry-1.23.dist-info}/METADATA +18 -8
- libentry-1.23.dist-info/RECORD +30 -0
- {libentry-1.22.3.dist-info → libentry-1.23.dist-info}/WHEEL +1 -1
- {libentry-1.22.3.dist-info → libentry-1.23.dist-info}/entry_points.txt +0 -1
- libentry-1.22.3.dist-info/RECORD +0 -25
- {libentry-1.22.3.dist-info → libentry-1.23.dist-info}/LICENSE +0 -0
- {libentry-1.22.3.dist-info → libentry-1.23.dist-info}/top_level.txt +0 -0
- {libentry-1.22.3.dist-info → libentry-1.23.dist-info}/zip-safe +0 -0
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
|
+
))
|