libentry 1.22.4__py3-none-any.whl → 1.23.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.
- 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 +110 -51
- libentry/service/flask.py +1 -1
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/METADATA +2 -1
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/RECORD +14 -10
- libentry/service/flask_mcp.py +0 -337
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/LICENSE +0 -0
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/WHEEL +0 -0
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/entry_points.txt +0 -0
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/top_level.txt +0 -0
- {libentry-1.22.4.dist-info → libentry-1.23.1.dist-info}/zip-safe +0 -0
libentry/mcp/service.py
ADDED
@@ -0,0 +1,883 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
__author__ = "xi"
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import base64
|
7
|
+
import io
|
8
|
+
import uuid
|
9
|
+
from dataclasses import dataclass
|
10
|
+
from queue import Empty, Queue
|
11
|
+
from threading import Lock
|
12
|
+
from types import GeneratorType
|
13
|
+
from typing import Any, Callable, Dict, Generator, Iterable, List, Optional, Type, Union
|
14
|
+
|
15
|
+
from flask import Flask, request as flask_request
|
16
|
+
from pydantic import BaseModel, TypeAdapter
|
17
|
+
|
18
|
+
from libentry import json, logger
|
19
|
+
from libentry.mcp import api
|
20
|
+
from libentry.mcp.api import APIInfo, list_api_info
|
21
|
+
from libentry.mcp.types import BlobResourceContents, CallToolRequestParams, CallToolResult, Implementation, \
|
22
|
+
InitializeRequestParams, InitializeResult, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, \
|
23
|
+
ListResourcesResult, ListToolsResult, MIME, ReadResourceRequestParams, ReadResourceResult, Resource, SSE, \
|
24
|
+
ServerCapabilities, SubroutineError, SubroutineResponse, TextContent, TextResourceContents, Tool, ToolProperty, \
|
25
|
+
ToolSchema, ToolsCapability
|
26
|
+
from libentry.schema import APISignature, get_api_signature, query_api
|
27
|
+
|
28
|
+
try:
|
29
|
+
from gunicorn.app.base import BaseApplication
|
30
|
+
except ImportError:
|
31
|
+
class BaseApplication:
|
32
|
+
|
33
|
+
def load(self) -> Flask:
|
34
|
+
pass
|
35
|
+
|
36
|
+
def run(self):
|
37
|
+
flask_server = self.load()
|
38
|
+
assert hasattr(self, "options")
|
39
|
+
bind = getattr(self, "options")["bind"]
|
40
|
+
pos = bind.rfind(":")
|
41
|
+
host = bind[:pos]
|
42
|
+
port = int(bind[pos + 1:])
|
43
|
+
logger.warn("Your system doesn't support gunicorn.")
|
44
|
+
logger.warn("Use Flask directly.")
|
45
|
+
logger.warn("Options like \"num_threads\", \"num_workers\" are ignored.")
|
46
|
+
return flask_server.run(host=host, port=port)
|
47
|
+
|
48
|
+
|
49
|
+
class SubroutineAdapter:
|
50
|
+
|
51
|
+
def __init__(self, fn: Callable, api_signature: Optional[APISignature] = None):
|
52
|
+
self.fn = fn
|
53
|
+
assert hasattr(fn, "__name__")
|
54
|
+
self.__name__ = fn.__name__
|
55
|
+
|
56
|
+
self.api_signature = api_signature or get_api_signature(fn)
|
57
|
+
|
58
|
+
def __call__(
|
59
|
+
self,
|
60
|
+
request: Dict[str, Any]
|
61
|
+
) -> Union[SubroutineResponse, Iterable[SubroutineResponse]]:
|
62
|
+
if isinstance(request, BaseModel):
|
63
|
+
request = request.model_dump()
|
64
|
+
|
65
|
+
try:
|
66
|
+
input_model = self.api_signature.input_model
|
67
|
+
if input_model is not None:
|
68
|
+
# This is the special case: the only one argument is a BaseModel object.
|
69
|
+
# In this case, we omit this argument name, and validate directly.
|
70
|
+
arg = input_model.model_validate(request or {})
|
71
|
+
response = self.fn(arg)
|
72
|
+
else:
|
73
|
+
# The arguments are bundled together to perform validation.
|
74
|
+
bundled_model = self.api_signature.bundled_model
|
75
|
+
kwargs = bundled_model.model_validate(request or {}).model_dump()
|
76
|
+
response = self.fn(**kwargs)
|
77
|
+
except Exception as e:
|
78
|
+
return SubroutineResponse(error=SubroutineError.from_exception(e))
|
79
|
+
|
80
|
+
if not isinstance(response, GeneratorType):
|
81
|
+
return SubroutineResponse(result=response)
|
82
|
+
else:
|
83
|
+
return self._iter_response(response)
|
84
|
+
|
85
|
+
@staticmethod
|
86
|
+
def _iter_response(
|
87
|
+
results: Iterable[Any]
|
88
|
+
) -> Generator[SubroutineResponse, None, Optional[SubroutineResponse]]:
|
89
|
+
it = iter(results)
|
90
|
+
while True:
|
91
|
+
try:
|
92
|
+
result = next(it)
|
93
|
+
if not isinstance(result, SubroutineResponse):
|
94
|
+
result = SubroutineResponse(result=result)
|
95
|
+
yield result
|
96
|
+
except StopIteration as e:
|
97
|
+
final_result = e.value
|
98
|
+
if not isinstance(final_result, SubroutineResponse):
|
99
|
+
final_result = SubroutineResponse(result=final_result)
|
100
|
+
break
|
101
|
+
except Exception as e:
|
102
|
+
final_result = SubroutineResponse(error=SubroutineError.from_exception(e))
|
103
|
+
yield final_result
|
104
|
+
break
|
105
|
+
return final_result
|
106
|
+
|
107
|
+
|
108
|
+
class JSONRPCAdapter:
|
109
|
+
|
110
|
+
def __init__(self, fn: Callable, api_signature: Optional[APISignature] = None):
|
111
|
+
self.fn = fn
|
112
|
+
assert hasattr(fn, "__name__")
|
113
|
+
self.__name__ = fn.__name__
|
114
|
+
|
115
|
+
self.api_signature = api_signature or get_api_signature(fn)
|
116
|
+
self.type_adapter = TypeAdapter(Union[JSONRPCRequest, JSONRPCNotification])
|
117
|
+
|
118
|
+
def __call__(
|
119
|
+
self,
|
120
|
+
request: Union[JSONRPCRequest, JSONRPCNotification, Dict[str, Any]]
|
121
|
+
) -> Union[JSONRPCResponse, Iterable[JSONRPCResponse], None]:
|
122
|
+
if isinstance(request, Dict):
|
123
|
+
request = self.type_adapter.validate_python(request)
|
124
|
+
|
125
|
+
try:
|
126
|
+
if isinstance(request, JSONRPCRequest):
|
127
|
+
return self._apply_request(request)
|
128
|
+
else:
|
129
|
+
return self._apply_notification(request)
|
130
|
+
except SystemExit as e:
|
131
|
+
raise e
|
132
|
+
except KeyboardInterrupt as e:
|
133
|
+
raise e
|
134
|
+
except Exception as e:
|
135
|
+
if isinstance(request, JSONRPCRequest):
|
136
|
+
return JSONRPCResponse(
|
137
|
+
jsonrpc="2.0",
|
138
|
+
id=request.id,
|
139
|
+
error=JSONRPCError.from_exception(e)
|
140
|
+
)
|
141
|
+
else:
|
142
|
+
return None
|
143
|
+
|
144
|
+
def _apply_request(
|
145
|
+
self,
|
146
|
+
request: JSONRPCRequest
|
147
|
+
) -> Union[JSONRPCResponse, Iterable[JSONRPCResponse]]:
|
148
|
+
input_model = self.api_signature.input_model
|
149
|
+
if input_model is not None:
|
150
|
+
arg = input_model.model_validate(request.params or {})
|
151
|
+
result = self.fn(arg)
|
152
|
+
else:
|
153
|
+
bundled_model = self.api_signature.bundled_model
|
154
|
+
kwargs = bundled_model.model_validate(request.params or {}).model_dump()
|
155
|
+
result = self.fn(**kwargs)
|
156
|
+
|
157
|
+
if not isinstance(result, (GeneratorType, range)):
|
158
|
+
if isinstance(result, JSONRPCResponse):
|
159
|
+
response = result
|
160
|
+
else:
|
161
|
+
response = JSONRPCResponse(
|
162
|
+
jsonrpc="2.0",
|
163
|
+
id=request.id,
|
164
|
+
result=result
|
165
|
+
)
|
166
|
+
else:
|
167
|
+
response = self._iter_response(
|
168
|
+
results=result,
|
169
|
+
request_id=request.id
|
170
|
+
)
|
171
|
+
|
172
|
+
return response
|
173
|
+
|
174
|
+
@staticmethod
|
175
|
+
def _iter_response(
|
176
|
+
results: Iterable[Any],
|
177
|
+
request_id: Union[int, str]
|
178
|
+
) -> Generator[JSONRPCResponse, None, Optional[JSONRPCResponse]]:
|
179
|
+
it = iter(results)
|
180
|
+
while True:
|
181
|
+
try:
|
182
|
+
result = next(it)
|
183
|
+
if not isinstance(result, JSONRPCResponse):
|
184
|
+
result = JSONRPCResponse(
|
185
|
+
jsonrpc="2.0",
|
186
|
+
id=request_id,
|
187
|
+
result=result
|
188
|
+
)
|
189
|
+
yield result
|
190
|
+
except StopIteration as e:
|
191
|
+
final_result = e.value
|
192
|
+
if not isinstance(final_result, JSONRPCResponse):
|
193
|
+
final_result = JSONRPCResponse(
|
194
|
+
jsonrpc="2.0",
|
195
|
+
id=request_id,
|
196
|
+
result=final_result
|
197
|
+
)
|
198
|
+
break
|
199
|
+
except Exception as e:
|
200
|
+
final_result = JSONRPCResponse(
|
201
|
+
jsonrpc="2.0",
|
202
|
+
id=request_id,
|
203
|
+
error=JSONRPCError.from_exception(e)
|
204
|
+
)
|
205
|
+
yield final_result
|
206
|
+
break
|
207
|
+
return final_result
|
208
|
+
|
209
|
+
def _apply_notification(self, request: JSONRPCNotification) -> None:
|
210
|
+
input_model = self.api_signature.input_model
|
211
|
+
if input_model is not None:
|
212
|
+
arg = input_model.model_validate(request.params or {})
|
213
|
+
self.fn(arg)
|
214
|
+
else:
|
215
|
+
bundled_model = self.api_signature.bundled_model
|
216
|
+
kwargs = bundled_model.model_validate(request.params or {}).model_dump()
|
217
|
+
self.fn(**kwargs)
|
218
|
+
|
219
|
+
return None
|
220
|
+
|
221
|
+
|
222
|
+
class FlaskHandler:
|
223
|
+
|
224
|
+
def __init__(self, fn: Callable, api_info: APIInfo, app: "FlaskServer"):
|
225
|
+
assert hasattr(fn, "__name__")
|
226
|
+
self.__name__ = fn.__name__
|
227
|
+
|
228
|
+
self.fn = fn
|
229
|
+
self.api_info = api_info
|
230
|
+
self.app = app
|
231
|
+
|
232
|
+
self.api_signature = get_api_signature(fn)
|
233
|
+
|
234
|
+
self.subroutine_adapter = SubroutineAdapter(fn, self.api_signature)
|
235
|
+
self.jsonrpc_adapter = JSONRPCAdapter(fn, self.api_signature)
|
236
|
+
self.default_adapter = self.subroutine_adapter
|
237
|
+
|
238
|
+
tag = self.api_info.tag if self.api_info else None
|
239
|
+
if tag is not None:
|
240
|
+
tag = tag.lower()
|
241
|
+
if tag in {"free", "schema_free", "schema-free"}:
|
242
|
+
self.default_adapter = self.fn
|
243
|
+
elif tag in {"jsonrpc", "rpc"}:
|
244
|
+
self.default_adapter = self.jsonrpc_adapter
|
245
|
+
|
246
|
+
# todo: debug info
|
247
|
+
print(f"{api_info.path}: {type(self.default_adapter)}\t{self.api_signature.input_model}")
|
248
|
+
|
249
|
+
def __call__(self):
|
250
|
+
args = flask_request.args
|
251
|
+
data = flask_request.data
|
252
|
+
content_type = flask_request.content_type
|
253
|
+
|
254
|
+
json_from_url = {**args}
|
255
|
+
if data:
|
256
|
+
if (not content_type) or content_type == MIME.json.value:
|
257
|
+
json_from_data = json.loads(data)
|
258
|
+
else:
|
259
|
+
return self.app.error(f"Unsupported Content-Type: \"{content_type}\".")
|
260
|
+
else:
|
261
|
+
json_from_data = {}
|
262
|
+
|
263
|
+
conflicts = json_from_url.keys() & json_from_data.keys()
|
264
|
+
if len(conflicts) > 0:
|
265
|
+
return self.app.error(f"Duplicated fields: \"{conflicts}\".")
|
266
|
+
|
267
|
+
input_json = {**json_from_url, **json_from_data}
|
268
|
+
|
269
|
+
################################################################################
|
270
|
+
# Call method as MCP
|
271
|
+
################################################################################
|
272
|
+
try:
|
273
|
+
mcp_response = self.default_adapter(input_json)
|
274
|
+
except Exception as e:
|
275
|
+
error = json.dumps(SubroutineError.from_exception(e))
|
276
|
+
return self.app.error(error, mimetype=MIME.json.value)
|
277
|
+
|
278
|
+
################################################################################
|
279
|
+
# Parse MCP response
|
280
|
+
################################################################################
|
281
|
+
accepts = flask_request.accept_mimetypes
|
282
|
+
mimetype = MIME.json.value if MIME.json.value in accepts else MIME.plain.value
|
283
|
+
if mcp_response is None:
|
284
|
+
return self.app.ok(
|
285
|
+
None,
|
286
|
+
mimetype=mimetype
|
287
|
+
)
|
288
|
+
elif isinstance(mcp_response, BaseModel):
|
289
|
+
# BaseModel
|
290
|
+
return self.app.ok(
|
291
|
+
json.dumps(mcp_response.model_dump(exclude_none=True)),
|
292
|
+
mimetype=mimetype
|
293
|
+
)
|
294
|
+
elif isinstance(mcp_response, (Dict, List)):
|
295
|
+
# JSON Object and Array
|
296
|
+
return self.app.ok(
|
297
|
+
json.dumps(mcp_response),
|
298
|
+
mimetype=mimetype
|
299
|
+
)
|
300
|
+
elif isinstance(mcp_response, (GeneratorType, range)):
|
301
|
+
# Stream response
|
302
|
+
if MIME.sse.value in accepts:
|
303
|
+
# SSE is first considered
|
304
|
+
return self.app.ok(
|
305
|
+
self._iter_sse_stream(mcp_response),
|
306
|
+
mimetype=MIME.sse.value
|
307
|
+
)
|
308
|
+
else:
|
309
|
+
# JSON Stream for fallback
|
310
|
+
return self.app.ok(
|
311
|
+
self._iter_sse_stream(mcp_response),
|
312
|
+
mimetype=MIME.json_stream.value
|
313
|
+
)
|
314
|
+
else:
|
315
|
+
# Plain text
|
316
|
+
return self.app.ok(
|
317
|
+
str(mcp_response),
|
318
|
+
mimetype=MIME.plain.value
|
319
|
+
)
|
320
|
+
|
321
|
+
def _iter_sse_stream(self, events: Iterable[Union[SSE, Dict[str, Any]]]) -> Iterable[str]:
|
322
|
+
for item in events:
|
323
|
+
if isinstance(item, SSE):
|
324
|
+
event = item.event
|
325
|
+
data = item.data
|
326
|
+
else:
|
327
|
+
event = "message"
|
328
|
+
data = item
|
329
|
+
yield "event:"
|
330
|
+
yield event
|
331
|
+
if data is not None:
|
332
|
+
yield "\n"
|
333
|
+
yield "data:"
|
334
|
+
if isinstance(data, BaseModel):
|
335
|
+
# BaseModel
|
336
|
+
yield json.dumps(data.model_dump(exclude_none=True))
|
337
|
+
elif isinstance(data, (Dict, List)):
|
338
|
+
# JSON Object and Array
|
339
|
+
yield json.dumps(data)
|
340
|
+
else:
|
341
|
+
# Plain text
|
342
|
+
yield str(data)
|
343
|
+
yield "\n\n"
|
344
|
+
|
345
|
+
def _iter_json_stream(self, objs: Iterable[Union[BaseModel, Dict[str, Any]]]) -> Iterable[str]:
|
346
|
+
for obj in objs:
|
347
|
+
if isinstance(obj, BaseModel):
|
348
|
+
# BaseModel
|
349
|
+
yield json.dumps(obj.model_dump(exclude_none=True))
|
350
|
+
elif isinstance(obj, (Dict, List)):
|
351
|
+
# JSON Object and Array
|
352
|
+
yield json.dumps(obj)
|
353
|
+
else:
|
354
|
+
# Plain text
|
355
|
+
yield str(obj)
|
356
|
+
yield "\n"
|
357
|
+
|
358
|
+
|
359
|
+
class SSEService:
|
360
|
+
|
361
|
+
def __init__(
|
362
|
+
self,
|
363
|
+
service_routes: Dict[str, "Route"],
|
364
|
+
builtin_routes: Dict[str, "Route"]
|
365
|
+
):
|
366
|
+
self.service_routes = service_routes
|
367
|
+
self.builtin_routes = builtin_routes
|
368
|
+
self.lock = Lock()
|
369
|
+
self.sse_dict = {}
|
370
|
+
|
371
|
+
# noinspection PyUnusedLocal
|
372
|
+
@api.get("/sse", tag="schema_free")
|
373
|
+
def sse(self, raw_request: Dict[str, Any]) -> Iterable[SSE]:
|
374
|
+
session_id = str(uuid.uuid4())
|
375
|
+
queue = Queue(8)
|
376
|
+
with self.lock:
|
377
|
+
self.sse_dict[session_id] = queue
|
378
|
+
|
379
|
+
def _stream():
|
380
|
+
yield SSE(event="endpoint", data=f"/sse/message?sessionId={session_id}")
|
381
|
+
try:
|
382
|
+
while True:
|
383
|
+
try:
|
384
|
+
message = queue.get(timeout=3)
|
385
|
+
if message is None:
|
386
|
+
break
|
387
|
+
yield SSE(event="message", data=message)
|
388
|
+
except Empty:
|
389
|
+
ping_request = JSONRPCRequest(jsonrpc="2.0", id=str(uuid.uuid4()), method="ping")
|
390
|
+
yield SSE(event="message", data=ping_request)
|
391
|
+
finally:
|
392
|
+
with self.lock:
|
393
|
+
del self.sse_dict[session_id]
|
394
|
+
logger.info(f"Session {session_id} cleaned.")
|
395
|
+
|
396
|
+
return _stream()
|
397
|
+
|
398
|
+
@api.route("/sse/message", tag="schema_free")
|
399
|
+
def sse_message(self, raw_request: Dict[str, Any]) -> None:
|
400
|
+
################################################################################
|
401
|
+
# session validation
|
402
|
+
################################################################################
|
403
|
+
session_id = raw_request.get("sessionId")
|
404
|
+
if session_id is None:
|
405
|
+
raise RuntimeError("You should start a session by request the \"/sse\" endpoint first.")
|
406
|
+
with self.lock:
|
407
|
+
if session_id not in self.sse_dict:
|
408
|
+
raise RuntimeError(f"Invalid session: \"{session_id}\".")
|
409
|
+
|
410
|
+
################################################################################
|
411
|
+
# validate request
|
412
|
+
################################################################################
|
413
|
+
type_adapter = TypeAdapter(Union[JSONRPCRequest, JSONRPCNotification])
|
414
|
+
request = type_adapter.validate_python(raw_request)
|
415
|
+
|
416
|
+
################################################################################
|
417
|
+
# call the mcp method
|
418
|
+
################################################################################
|
419
|
+
path = f"/{request.method}"
|
420
|
+
route = self.service_routes.get(path, self.builtin_routes.get(path))
|
421
|
+
if route is None:
|
422
|
+
raise RuntimeError(f"Method {request.method} doesn't exist.")
|
423
|
+
|
424
|
+
response = route.handler.jsonrpc_adapter(request)
|
425
|
+
|
426
|
+
################################################################################
|
427
|
+
# put response
|
428
|
+
################################################################################
|
429
|
+
if isinstance(request, JSONRPCNotification):
|
430
|
+
return None
|
431
|
+
|
432
|
+
with self.lock:
|
433
|
+
queue = self.sse_dict[session_id]
|
434
|
+
|
435
|
+
# todo: remove debug info
|
436
|
+
print("/sse/message")
|
437
|
+
print(request)
|
438
|
+
print(response)
|
439
|
+
print()
|
440
|
+
|
441
|
+
if not isinstance(response, (GeneratorType, range)):
|
442
|
+
queue.put(response)
|
443
|
+
else:
|
444
|
+
it = iter(response)
|
445
|
+
last_response = None
|
446
|
+
while True:
|
447
|
+
try:
|
448
|
+
last_response = next(it)
|
449
|
+
except StopIteration as e:
|
450
|
+
final_response = e.value
|
451
|
+
break
|
452
|
+
if final_response is None:
|
453
|
+
final_response = last_response
|
454
|
+
|
455
|
+
if final_response is None:
|
456
|
+
raise RuntimeError(f"Method {request.method} doesn't return anything.")
|
457
|
+
|
458
|
+
queue.put(final_response)
|
459
|
+
return None
|
460
|
+
|
461
|
+
|
462
|
+
class JSONRPCService:
|
463
|
+
|
464
|
+
def __init__(
|
465
|
+
self,
|
466
|
+
service_routes: Dict[str, "Route"],
|
467
|
+
builtin_routes: Dict[str, "Route"]
|
468
|
+
):
|
469
|
+
self.service_routes = service_routes
|
470
|
+
self.builtin_routes = builtin_routes
|
471
|
+
|
472
|
+
self.type_adapter = TypeAdapter(Union[JSONRPCRequest, JSONRPCNotification])
|
473
|
+
|
474
|
+
@api.route(tag="schema_free")
|
475
|
+
def message(self, raw_request: Dict[str, Any]) -> Union[JSONRPCResponse, Iterable[JSONRPCResponse], None]:
|
476
|
+
request = self.type_adapter.validate_python(raw_request)
|
477
|
+
path = f"/{request.method}"
|
478
|
+
route = self.service_routes.get(path, self.builtin_routes.get(path))
|
479
|
+
if route is None:
|
480
|
+
if isinstance(request, JSONRPCRequest):
|
481
|
+
return JSONRPCResponse(
|
482
|
+
jsonrpc="2.0",
|
483
|
+
id=request.id,
|
484
|
+
error=JSONRPCError(message=f"Method \"{request.method}\" doesn't exist.")
|
485
|
+
)
|
486
|
+
else:
|
487
|
+
return None
|
488
|
+
return route.handler.jsonrpc_adapter(request)
|
489
|
+
|
490
|
+
|
491
|
+
class LifeCycleService:
|
492
|
+
|
493
|
+
@api.route()
|
494
|
+
def live(self):
|
495
|
+
return "OK"
|
496
|
+
|
497
|
+
@api.route()
|
498
|
+
def initialize(self, _params: InitializeRequestParams) -> InitializeResult:
|
499
|
+
return InitializeResult(
|
500
|
+
protocolVersion="2024-11-05",
|
501
|
+
capabilities=ServerCapabilities(tools=ToolsCapability(listChanged=False)),
|
502
|
+
serverInfo=Implementation(name="python-libentry", version="1.0.0")
|
503
|
+
)
|
504
|
+
|
505
|
+
|
506
|
+
class NotificationsService:
|
507
|
+
|
508
|
+
@api.route("/notifications/initialized")
|
509
|
+
def notifications_initialized(self):
|
510
|
+
pass
|
511
|
+
|
512
|
+
|
513
|
+
class ToolsService:
|
514
|
+
|
515
|
+
def __init__(self, service_routes: Dict[str, "Route"]):
|
516
|
+
self.service_routes = service_routes
|
517
|
+
self._tool_routes = None
|
518
|
+
|
519
|
+
def get_tool_routes(self) -> Dict[str, "Route"]:
|
520
|
+
if self._tool_routes is None:
|
521
|
+
self._tool_routes = {}
|
522
|
+
for route in self.service_routes.values():
|
523
|
+
api_info = route.api_info
|
524
|
+
if api_info.tag != "tool":
|
525
|
+
continue
|
526
|
+
self._tool_routes[api_info.name] = route
|
527
|
+
return self._tool_routes
|
528
|
+
|
529
|
+
@api.route("/tools/list")
|
530
|
+
def tools_list(self) -> ListToolsResult:
|
531
|
+
tools = []
|
532
|
+
for name, route in self.get_tool_routes().items():
|
533
|
+
api_info = route.api_info
|
534
|
+
tool = Tool(
|
535
|
+
name=api_info.name,
|
536
|
+
description=api_info.description,
|
537
|
+
inputSchema=ToolSchema()
|
538
|
+
)
|
539
|
+
tools.append(tool)
|
540
|
+
schema = query_api(route.fn)
|
541
|
+
input_schema = schema.context[schema.input_schema]
|
542
|
+
for field in input_schema.fields:
|
543
|
+
type_ = field.type
|
544
|
+
if isinstance(type_, List):
|
545
|
+
type_ = "|".join(type_)
|
546
|
+
tool.inputSchema.properties[field.name] = ToolProperty(
|
547
|
+
type=type_,
|
548
|
+
description=field.description
|
549
|
+
)
|
550
|
+
if field.is_required:
|
551
|
+
tool.inputSchema.required.append(field.name)
|
552
|
+
return ListToolsResult(tools=tools)
|
553
|
+
|
554
|
+
@api.route("/tools/call")
|
555
|
+
def tools_call(self, params: CallToolRequestParams) -> Union[CallToolResult, Iterable[CallToolResult]]:
|
556
|
+
route = self.get_tool_routes().get(params.name)
|
557
|
+
if route is None:
|
558
|
+
raise RuntimeError(f"Tool \"{params.name}\" doesn't exist.")
|
559
|
+
|
560
|
+
try:
|
561
|
+
response = route.handler.subroutine_adapter(params.arguments)
|
562
|
+
except Exception as e:
|
563
|
+
error = json.dumps(SubroutineError.from_exception(e))
|
564
|
+
return CallToolResult(
|
565
|
+
content=[TextContent(text=error)],
|
566
|
+
isError=True
|
567
|
+
)
|
568
|
+
|
569
|
+
if not isinstance(response, GeneratorType):
|
570
|
+
if response.error is not None:
|
571
|
+
text = json.dumps(response.error)
|
572
|
+
return CallToolResult(
|
573
|
+
content=[TextContent(text=text)],
|
574
|
+
isError=True
|
575
|
+
)
|
576
|
+
else:
|
577
|
+
result = response.result
|
578
|
+
text = json.dumps(result) if isinstance(result, (Dict, BaseModel)) else str(result)
|
579
|
+
return CallToolResult(
|
580
|
+
content=[TextContent(text=text)],
|
581
|
+
isError=False
|
582
|
+
)
|
583
|
+
else:
|
584
|
+
return self._iter_tool_results(response)
|
585
|
+
|
586
|
+
@staticmethod
|
587
|
+
def _iter_tool_results(
|
588
|
+
responses: Iterable[SubroutineResponse]
|
589
|
+
) -> Generator[CallToolResult, None, CallToolResult]:
|
590
|
+
final_text = io.StringIO()
|
591
|
+
error = None
|
592
|
+
try:
|
593
|
+
for response in responses:
|
594
|
+
if response.error is not None:
|
595
|
+
text = json.dumps(response.error)
|
596
|
+
error = CallToolResult(
|
597
|
+
content=[TextContent(text=text)],
|
598
|
+
isError=True
|
599
|
+
)
|
600
|
+
yield error
|
601
|
+
break
|
602
|
+
else:
|
603
|
+
result = response.result
|
604
|
+
text = json.dumps(result) if isinstance(result, (Dict, BaseModel)) else str(result)
|
605
|
+
yield CallToolResult(
|
606
|
+
content=[TextContent(text=text)],
|
607
|
+
isError=False
|
608
|
+
)
|
609
|
+
final_text.write(text)
|
610
|
+
except Exception as e:
|
611
|
+
text = json.dumps(SubroutineError.from_exception(e))
|
612
|
+
error = CallToolResult(
|
613
|
+
content=[TextContent(text=text)],
|
614
|
+
isError=True
|
615
|
+
)
|
616
|
+
yield error
|
617
|
+
|
618
|
+
if error is None:
|
619
|
+
return CallToolResult(
|
620
|
+
content=[TextContent(text=final_text.getvalue())],
|
621
|
+
isError=False
|
622
|
+
)
|
623
|
+
else:
|
624
|
+
return error
|
625
|
+
|
626
|
+
|
627
|
+
class ResourcesService:
|
628
|
+
|
629
|
+
def __init__(self, service_routes: Dict[str, "Route"]):
|
630
|
+
self.service_routes = service_routes
|
631
|
+
|
632
|
+
self._resource_routes = None
|
633
|
+
|
634
|
+
def get_resource_routes(self) -> Dict[str, "Route"]:
|
635
|
+
if self._resource_routes is None:
|
636
|
+
self._resource_routes = {}
|
637
|
+
for route in self.service_routes.values():
|
638
|
+
api_info = route.api_info
|
639
|
+
if api_info.tag != "resource":
|
640
|
+
continue
|
641
|
+
uri = api_info.model_extra.get("uri", api_info.path)
|
642
|
+
self._resource_routes[uri] = route
|
643
|
+
return self._resource_routes
|
644
|
+
|
645
|
+
@api.route("/resources/list")
|
646
|
+
def resources_list(self) -> ListResourcesResult:
|
647
|
+
resources = []
|
648
|
+
for uri, route in self.get_resource_routes().items():
|
649
|
+
api_info = route.api_info
|
650
|
+
resources.append(Resource(
|
651
|
+
uri=uri,
|
652
|
+
name=api_info.name,
|
653
|
+
description=api_info.description,
|
654
|
+
mimeType=api_info.model_extra.get("mimeType"),
|
655
|
+
size=api_info.model_extra.get("size")
|
656
|
+
))
|
657
|
+
return ListResourcesResult(resources=resources)
|
658
|
+
|
659
|
+
@api.route("/resources/read")
|
660
|
+
def resources_read(
|
661
|
+
self,
|
662
|
+
request: ReadResourceRequestParams
|
663
|
+
) -> Union[ReadResourceResult, Iterable[ReadResourceResult]]:
|
664
|
+
route = self.get_resource_routes().get(request.uri)
|
665
|
+
if route is None:
|
666
|
+
raise RuntimeError(f"Resource \"{request.uri}\" doesn't exist.")
|
667
|
+
|
668
|
+
api_info = route.api_info
|
669
|
+
result = ReadResourceResult(contents=[])
|
670
|
+
content = route.fn()
|
671
|
+
mime_type = api_info.model_extra.get("mimeType")
|
672
|
+
if not isinstance(content, GeneratorType):
|
673
|
+
if isinstance(content, str):
|
674
|
+
result.contents.append(TextResourceContents(
|
675
|
+
uri=request.uri,
|
676
|
+
mimeType=mime_type or "text/*",
|
677
|
+
text=content
|
678
|
+
))
|
679
|
+
elif isinstance(content, bytes):
|
680
|
+
result.contents.append(BlobResourceContents(
|
681
|
+
uri=request.uri,
|
682
|
+
mimeType=mime_type or "binary/*",
|
683
|
+
blob=base64.b64encode(content).decode()
|
684
|
+
))
|
685
|
+
else:
|
686
|
+
raise RuntimeError(f"Unsupported content type \"{type(content)}\".")
|
687
|
+
return result
|
688
|
+
else:
|
689
|
+
# todo: this branch is not tested yet
|
690
|
+
return self._iter_resource_results(content, request.uri, mime_type)
|
691
|
+
|
692
|
+
@staticmethod
|
693
|
+
def _iter_resource_results(
|
694
|
+
contents: Iterable[Union[str, bytes]],
|
695
|
+
uri: str,
|
696
|
+
mime_type: Optional[str] = None
|
697
|
+
) -> Generator[ReadResourceResult, None, Optional[ReadResourceResult]]:
|
698
|
+
for content in contents:
|
699
|
+
if isinstance(content, str):
|
700
|
+
yield ReadResourceResult(contents=[TextResourceContents(
|
701
|
+
uri=uri,
|
702
|
+
mimeType=mime_type or "text/*",
|
703
|
+
text=content
|
704
|
+
)])
|
705
|
+
elif isinstance(content, bytes):
|
706
|
+
yield ReadResourceResult(contents=[BlobResourceContents(
|
707
|
+
uri=uri,
|
708
|
+
mimeType=mime_type or "binary/*",
|
709
|
+
blob=base64.b64encode(content).decode()
|
710
|
+
)])
|
711
|
+
else:
|
712
|
+
raise RuntimeError(f"Unsupported content type \"{type(content)}\".")
|
713
|
+
|
714
|
+
|
715
|
+
@dataclass
|
716
|
+
class Route:
|
717
|
+
api_info: APIInfo
|
718
|
+
fn: Callable
|
719
|
+
handler: FlaskHandler
|
720
|
+
|
721
|
+
|
722
|
+
class FlaskServer(Flask):
|
723
|
+
|
724
|
+
def __init__(self, service):
|
725
|
+
super().__init__(__name__)
|
726
|
+
|
727
|
+
self.service_routes = {}
|
728
|
+
self.builtin_routes = {}
|
729
|
+
|
730
|
+
self.service = service
|
731
|
+
self.builtin_services = [
|
732
|
+
SSEService(self.service_routes, self.builtin_routes),
|
733
|
+
JSONRPCService(self.service_routes, self.builtin_routes),
|
734
|
+
LifeCycleService(),
|
735
|
+
NotificationsService(),
|
736
|
+
ToolsService(self.service_routes),
|
737
|
+
ResourcesService(self.service_routes)
|
738
|
+
]
|
739
|
+
|
740
|
+
logger.info("Initializing Flask application.")
|
741
|
+
existing_routes = {}
|
742
|
+
|
743
|
+
routes = self._create_routes(self.service)
|
744
|
+
self.service_routes.update(routes)
|
745
|
+
existing_routes.update(self.service_routes)
|
746
|
+
|
747
|
+
for builtin_service in self.builtin_services:
|
748
|
+
routes = self._create_routes(builtin_service, existing_routes)
|
749
|
+
self.builtin_routes.update(routes)
|
750
|
+
existing_routes.update(routes)
|
751
|
+
|
752
|
+
routes = self._create_routes(self, existing_routes)
|
753
|
+
self.builtin_routes.update(routes)
|
754
|
+
existing_routes.update(routes)
|
755
|
+
|
756
|
+
for route in (*self.service_routes.values(), *self.builtin_routes.values()):
|
757
|
+
self.route(route.api_info.path, methods=route.api_info.methods)(route.handler)
|
758
|
+
logger.info("Flask application initialized.")
|
759
|
+
|
760
|
+
def _create_routes(self, service: Any, existing: Optional[Dict[str, Route]] = None) -> Dict[str, Route]:
|
761
|
+
routes = {}
|
762
|
+
for fn, api_info in list_api_info(service):
|
763
|
+
methods = api_info.methods
|
764
|
+
path = api_info.path
|
765
|
+
|
766
|
+
if existing is not None and path in existing:
|
767
|
+
logger.info(f"Duplicated definition of {path}.")
|
768
|
+
continue
|
769
|
+
|
770
|
+
if asyncio.iscoroutinefunction(fn):
|
771
|
+
logger.error(f"Async function \"{fn.__name__}\" is not supported.")
|
772
|
+
continue
|
773
|
+
|
774
|
+
logger.info(f"Serving {path} as {', '.join(methods)}.")
|
775
|
+
|
776
|
+
handler = FlaskHandler(fn, api_info, self)
|
777
|
+
routes[path] = Route(api_info=api_info, fn=fn, handler=handler)
|
778
|
+
return routes
|
779
|
+
|
780
|
+
def ok(self, body: Union[str, Iterable[str], None], mimetype: str):
|
781
|
+
return self.response_class(body, status=200, mimetype=mimetype)
|
782
|
+
|
783
|
+
def error(self, body: str, mimetype=MIME.plain.value):
|
784
|
+
return self.response_class(body, status=500, mimetype=mimetype)
|
785
|
+
|
786
|
+
@api.get("/")
|
787
|
+
def index(self, name: str = None):
|
788
|
+
if name is None:
|
789
|
+
all_api = []
|
790
|
+
for route in self.service_routes.values():
|
791
|
+
api_info = route.api_info
|
792
|
+
all_api.append(api_info)
|
793
|
+
return all_api
|
794
|
+
|
795
|
+
path = "/" + name
|
796
|
+
if path in self.service_routes:
|
797
|
+
fn = self.service_routes[path].fn
|
798
|
+
return query_api(fn).model_dump()
|
799
|
+
else:
|
800
|
+
return f"No API named \"{name}\""
|
801
|
+
|
802
|
+
|
803
|
+
class GunicornApplication(BaseApplication):
|
804
|
+
|
805
|
+
def __init__(self, service_type, service_config=None, options=None):
|
806
|
+
self.service_type = service_type
|
807
|
+
self.service_config = service_config
|
808
|
+
self.options = options or {}
|
809
|
+
super().__init__()
|
810
|
+
|
811
|
+
def load_config(self):
|
812
|
+
config = {
|
813
|
+
key: value
|
814
|
+
for key, value in self.options.items()
|
815
|
+
if key in self.cfg.settings and value is not None
|
816
|
+
}
|
817
|
+
for key, value in config.items():
|
818
|
+
self.cfg.set(key.lower(), value)
|
819
|
+
|
820
|
+
def load(self):
|
821
|
+
logger.info("Initializing the service.")
|
822
|
+
if isinstance(self.service_type, type) or callable(self.service_type):
|
823
|
+
service = self.service_type(self.service_config) if self.service_config else self.service_type()
|
824
|
+
elif self.service_config is None:
|
825
|
+
logger.warning(
|
826
|
+
"Be careful! It is not recommended to start the server from a service instance. "
|
827
|
+
"Use service_type and service_config instead."
|
828
|
+
)
|
829
|
+
service = self.service_type
|
830
|
+
else:
|
831
|
+
raise TypeError(f"Invalid service type \"{type(self.service_type)}\".")
|
832
|
+
logger.info("Service initialized.")
|
833
|
+
|
834
|
+
return FlaskServer(service)
|
835
|
+
|
836
|
+
|
837
|
+
def run_service(
|
838
|
+
service_type: Union[Type, Callable],
|
839
|
+
service_config=None,
|
840
|
+
host: str = "0.0.0.0",
|
841
|
+
port: int = 8888,
|
842
|
+
num_workers: int = 1,
|
843
|
+
num_threads: int = 20,
|
844
|
+
num_connections: Optional[int] = 1000,
|
845
|
+
backlog: Optional[int] = 1000,
|
846
|
+
worker_class: str = "gthread",
|
847
|
+
timeout: int = 60,
|
848
|
+
keyfile: Optional[str] = None,
|
849
|
+
keyfile_password: Optional[str] = None,
|
850
|
+
certfile: Optional[str] = None
|
851
|
+
):
|
852
|
+
logger.info("Starting gunicorn server.")
|
853
|
+
if num_connections is None or num_connections < num_threads * 2:
|
854
|
+
num_connections = num_threads * 2
|
855
|
+
if backlog is None or backlog < num_threads * 2:
|
856
|
+
backlog = num_threads * 2
|
857
|
+
|
858
|
+
def ssl_context(config, _default_ssl_context_factory):
|
859
|
+
import ssl
|
860
|
+
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
861
|
+
context.load_cert_chain(
|
862
|
+
certfile=config.certfile,
|
863
|
+
keyfile=config.keyfile,
|
864
|
+
password=keyfile_password
|
865
|
+
)
|
866
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
867
|
+
return context
|
868
|
+
|
869
|
+
options = {
|
870
|
+
"bind": f"{host}:{port}",
|
871
|
+
"workers": num_workers,
|
872
|
+
"threads": num_threads,
|
873
|
+
"timeout": timeout,
|
874
|
+
"worker_connections": num_connections,
|
875
|
+
"backlog": backlog,
|
876
|
+
"keyfile": keyfile,
|
877
|
+
"certfile": certfile,
|
878
|
+
"worker_class": worker_class,
|
879
|
+
"ssl_context": ssl_context
|
880
|
+
}
|
881
|
+
for name, value in options.items():
|
882
|
+
logger.info(f"Option {name}: {value}")
|
883
|
+
GunicornApplication(service_type, service_config, options).run()
|