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