ai-microcore 4.0.0.dev11__tar.gz → 4.0.0.dev13__tar.gz

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.
Files changed (44) hide show
  1. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/PKG-INFO +11 -4
  2. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/README.md +5 -2
  3. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/__init__.py +24 -5
  4. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/_env.py +9 -0
  5. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/ai_func/__init__.py +2 -2
  6. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/ai_func/ai-func.json.j2 +1 -1
  7. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/configuration.py +17 -1
  8. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/interactive_setup.py +78 -75
  9. ai_microcore-4.0.0.dev13/microcore/mcp.py +280 -0
  10. ai_microcore-4.0.0.dev13/microcore/templating/jinja2.py +28 -0
  11. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/utils.py +14 -1
  12. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/wrappers/llm_response_wrapper.py +11 -2
  13. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/pyproject.toml +5 -1
  14. ai_microcore-4.0.0.dev11/microcore/templating/jinja2.py +0 -19
  15. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/LICENSE +0 -0
  16. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/_llm_functions.py +0 -0
  17. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/_prepare_llm_args.py +0 -0
  18. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/ai_func/ai-func.pythonic.j2 +0 -0
  19. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/ai_modules.py +0 -0
  20. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/embedding_db/__init__.py +0 -0
  21. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/embedding_db/chromadb.py +0 -0
  22. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/file_storage.py +0 -0
  23. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/json_parsing.py +0 -0
  24. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/__init__.py +0 -0
  25. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/_openai_llm_v0.py +0 -0
  26. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/_openai_llm_v1.py +0 -0
  27. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/anthropic.py +0 -0
  28. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/google_genai.py +0 -0
  29. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/google_vertex_ai.py +0 -0
  30. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/local_llm.py +0 -0
  31. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/local_transformers.py +0 -0
  32. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/openai_llm.py +0 -0
  33. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/llm/shared.py +0 -0
  34. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/logging.py +0 -0
  35. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/message_types.py +0 -0
  36. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/metrics.py +0 -0
  37. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/python.py +0 -0
  38. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/templating/__init__.py +0 -0
  39. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/text2speech/elevenlabs.py +0 -0
  40. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/tokenizing.py +0 -0
  41. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/types.py +0 -0
  42. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/ui.py +0 -0
  43. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/wrappers/__init__.py +0 -0
  44. {ai_microcore-4.0.0.dev11 → ai_microcore-4.0.0.dev13}/microcore/wrappers/prompt_wrapper.py +0 -0
@@ -1,14 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-microcore
3
- Version: 4.0.0.dev11
3
+ Version: 4.0.0.dev13
4
4
  Summary: # Minimalistic Foundation for AI Applications
5
- Keywords: llm,large language models,ai,similarity search,ai search,gpt,openai
5
+ Keywords: llm,large language models,ai,similarity search,ai search,gpt,openai,framework,adapter
6
6
  Author-email: Vitalii Stepanenko <mail@vitalii.in>
7
7
  Maintainer-email: Vitalii Stepanenko <mail@vitalii.in>
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
12
14
  Classifier: License :: OSI Approved :: MIT License
13
15
  Classifier: Operating System :: OS Independent
14
16
  Classifier: Intended Audience :: Developers
@@ -21,6 +23,8 @@ Requires-Dist: colorama~=0.4.6
21
23
  Requires-Dist: PyYAML~=6.0
22
24
  Requires-Dist: chardet~=5.2.0
23
25
  Requires-Dist: tiktoken>=0.7.0,<1.0
26
+ Requires-Dist: mcp~=1.9.1
27
+ Requires-Dist: docstring_parser~=0.16.0
24
28
  Project-URL: Source Code, https://github.com/Nayjest/ai-microcore
25
29
 
26
30
  <p align="right">
@@ -35,7 +39,7 @@ Project-URL: Source Code, https://github.com/Nayjest/ai-microcore
35
39
  # AI MicroCore: A Minimalistic Foundation for AI Applications
36
40
 
37
41
  **MicroCore** is a collection of python adapters for Large Language Models
38
- and Semantic Search APIs allowing to
42
+ and Vector Databases / Semantic Search APIs allowing to
39
43
  communicate with these services in a convenient way, make them easily switchable
40
44
  and separate business logic from the implementation details.
41
45
 
@@ -45,6 +49,10 @@ without need to change your application code.
45
49
 
46
50
  You even can switch between text completion and chat completion models only using configuration.
47
51
 
52
+ Thanks to LLM-agnostic MCP integration,
53
+ **MicroCore** connects MCP tools to any language models easily,
54
+ whether through API providers that do not support MCP, or through inference using pytorch or arbitrary python functions.
55
+
48
56
  The basic example of usage is as follows:
49
57
 
50
58
  ```python
@@ -288,7 +296,6 @@ import microcore.ai_modules
288
296
 
289
297
  * Automatically registers template folders of AI modules in Jinja2 environment
290
298
 
291
-
292
299
  ## 🛠️ Contributing
293
300
 
294
301
  Please see [CONTRIBUTING](https://github.com/Nayjest/ai-microcore/blob/main/CONTRIBUTING.md) for details.
@@ -10,7 +10,7 @@
10
10
  # AI MicroCore: A Minimalistic Foundation for AI Applications
11
11
 
12
12
  **MicroCore** is a collection of python adapters for Large Language Models
13
- and Semantic Search APIs allowing to
13
+ and Vector Databases / Semantic Search APIs allowing to
14
14
  communicate with these services in a convenient way, make them easily switchable
15
15
  and separate business logic from the implementation details.
16
16
 
@@ -20,6 +20,10 @@ without need to change your application code.
20
20
 
21
21
  You even can switch between text completion and chat completion models only using configuration.
22
22
 
23
+ Thanks to LLM-agnostic MCP integration,
24
+ **MicroCore** connects MCP tools to any language models easily,
25
+ whether through API providers that do not support MCP, or through inference using pytorch or arbitrary python functions.
26
+
23
27
  The basic example of usage is as follows:
24
28
 
25
29
  ```python
@@ -263,7 +267,6 @@ import microcore.ai_modules
263
267
 
264
268
  * Automatically registers template folders of AI modules in Jinja2 environment
265
269
 
266
-
267
270
  ## 🛠️ Contributing
268
271
 
269
272
  Please see [CONTRIBUTING](https://github.com/Nayjest/ai-microcore/blob/main/CONTRIBUTING.md) for details.
@@ -8,8 +8,9 @@ and separate business logic from implementation details.
8
8
  """
9
9
 
10
10
  import os
11
- import microcore.ui # noqa
12
- import microcore.tokenizing # noqa
11
+ from . import mcp
12
+ from . import ui
13
+ from . import tokenizing
13
14
  from .embedding_db import SearchResult, AbstractEmbeddingDB, SearchResults
14
15
  from .file_storage import storage
15
16
  from ._env import configure, env, config
@@ -22,7 +23,6 @@ from .wrappers.llm_response_wrapper import LLMResponse
22
23
  from ._llm_functions import llm, allm, llm_parallel
23
24
  from .utils import parse, dedent
24
25
  from .metrics import Metrics
25
- from .interactive_setup import interactive_setup
26
26
 
27
27
 
28
28
  def tpl(file: os.PathLike[str] | str, **kwargs) -> str | PromptWrapper:
@@ -120,6 +120,23 @@ class _EmbeddingDBProxy(AbstractEmbeddingDB):
120
120
  texts = _EmbeddingDBProxy()
121
121
  """Embedding database, see `microcore.embedding_db.AbstractEmbeddingDB`"""
122
122
 
123
+
124
+ def mcp_server(name: str) -> mcp.MCPServer: # noqa, pylint-disable=E0602
125
+ """
126
+ Returns MCP server by name from the registry.
127
+
128
+ Args:
129
+ name (str): The name of the MCP server.
130
+
131
+ Returns:
132
+ MCPServer: The MCP server instance.
133
+
134
+ Raises:
135
+ ValueError: If the server with the given name is not found in the registry.
136
+ """
137
+ return mcp.server(name) # noqa, pylint-disable=E0602
138
+
139
+
123
140
  __all__ = [
124
141
  "llm",
125
142
  "allm",
@@ -160,9 +177,11 @@ __all__ = [
160
177
  "Config",
161
178
  "types",
162
179
  "ui",
180
+ "mcp",
181
+ "mcp_server",
182
+ "tokenizing",
163
183
  "Metrics",
164
- "interactive_setup"
165
184
  # "wrappers",
166
185
  ]
167
186
 
168
- __version__ = "4.0.0-dev11"
187
+ __version__ = "4.0.0-dev13"
@@ -15,6 +15,7 @@ from .llm.local_llm import make_llm_functions as make_local_llm_functions
15
15
  if TYPE_CHECKING:
16
16
  from .wrappers.llm_response_wrapper import LLMResponse # noqa: F401
17
17
  from transformers import PreTrainedModel, PreTrainedTokenizer # noqa: F401
18
+ from .mcp import MCPRegistry
18
19
 
19
20
 
20
21
  @dataclass
@@ -33,6 +34,7 @@ class Env:
33
34
  tokenizer: "PreTrainedTokenizer" = field( # noqa
34
35
  default=None, init=False, repr=False
35
36
  )
37
+ _mcp_registry: "MCPRegistry" = field(init=False, default=None)
36
38
 
37
39
  def __post_init__(self):
38
40
  global _env
@@ -52,6 +54,13 @@ class Env:
52
54
  self.jinja_env = make_jinja2_env(self)
53
55
  self.tpl_function = make_tpl_function(self)
54
56
 
57
+ @property
58
+ def mcp_registry(self):
59
+ if self._mcp_registry is None:
60
+ from .mcp import MCPRegistry
61
+ self._mcp_registry = MCPRegistry(self.config.MCP_SERVERS)
62
+ return self._mcp_registry
63
+
55
64
  def init_llm(self):
56
65
  if self.config.LLM_API_TYPE == ApiType.NONE:
57
66
 
@@ -8,8 +8,8 @@ import inspect
8
8
  from enum import Enum
9
9
  from typing import Dict, Any
10
10
  import docstring_parser
11
- from .. import tpl
12
11
  from ..utils import dedent
12
+ from .._env import env
13
13
 
14
14
 
15
15
  class AiFuncSyntax(str, Enum):
@@ -95,4 +95,4 @@ def describe_ai_func(func: callable, syntax: AiFuncSyntax | str = None) -> str:
95
95
  syntax = syntax or AiFuncSyntax.DEFAULT
96
96
  tpl_file = f"ai-func.{syntax}.j2" if syntax in AiFuncSyntax else syntax
97
97
  metadata = func_metadata(func)
98
- return tpl(tpl_file, **metadata)
98
+ return env().tpl_function(tpl_file, **metadata)
@@ -1,6 +1,6 @@
1
1
  # {{ description or name.replace('_', ' ').capitalize() }}
2
2
  {
3
- "call": "{{ name }}"{% if args %}{{ "," }}{% endif %}
3
+ "{{ config.AI_SYNTAX_FUNCTION_NAME_FIELD }}": "{{ name }}"{% if args %}{{ "," }}{% endif %}
4
4
  {%- for k,v in args.items() %}
5
5
  "{{ k }}":
6
6
  {%- if v.type %} <{{ v.type }}>{% endif -%}
@@ -404,7 +404,23 @@ class Config(LLMConfig):
404
404
  - LLMResponse objects will not contain the links to the prompt field
405
405
  """
406
406
 
407
+ AI_SYNTAX_FUNCTION_NAME_FIELD: str = from_env(default="call")
408
+
409
+ JINJA2_GLOBALS: dict = from_env(dtype=dict)
410
+
411
+ MCP_SERVERS: list = from_env(dtype=list)
412
+
413
+ INTERACTIVE_SETUP: bool = field(default=False)
414
+
407
415
  def __post_init__(self):
408
- super().__post_init__()
416
+ try:
417
+ super().__post_init__()
418
+ except LLMConfigError as e:
419
+ if self.INTERACTIVE_SETUP and not os.path.isfile(self.DOT_ENV_FILE):
420
+ from .interactive_setup import interactive_setup
421
+ config = interactive_setup(self.DOT_ENV_FILE)
422
+ self.__dict__.update(config.__dict__)
423
+ else:
424
+ raise e
409
425
  if self.TEXT_TO_SPEECH_PATH is None:
410
426
  self.TEXT_TO_SPEECH_PATH = Path(self.STORAGE_PATH) / "voicing"
@@ -1,75 +1,78 @@
1
- from .configuration import EmbeddingDbType, ApiType
2
- from .ui import ask_choose, ask_non_empty, ask_yn, error, yellow
3
- from ._env import configure
4
- from ._llm_functions import llm
5
- from .utils import file_link
6
-
7
-
8
- def interactive_setup(
9
- file_path: str,
10
- defaults: dict = None,
11
- extras: dict | list = None,
12
- ):
13
- """
14
- Interactive setup for LLM API configuration.
15
- Prompts user for configuration details such as API type, key, model name,
16
- and base URL. Tests the LLM API with a sample query and saves the configuration
17
- to a specified file if the user chooses to do so.
18
- Args:
19
- file_path (str): Path to the configuration file.
20
- defaults (dict, optional): Default configuration values.
21
- If provided, user will not be prompted for those values.
22
- Additional values for storing in the file can be added to defaults.
23
- extras (dict | list, optional): Additional configuration fields to prompt for.
24
- """
25
- raw_config = dict(defaults) if defaults else dict()
26
- if "LLM_API_TYPE" not in raw_config:
27
- raw_config["LLM_API_TYPE"] = ask_choose(
28
- "Choose LLM API Type:",
29
- list(i.value for i in ApiType if not ApiType.is_local(i)),
30
- )
31
- if "LLM_API_KEY" not in raw_config:
32
- raw_config["LLM_API_KEY"] = ask_non_empty("API Key: ")
33
- if "MODEL" not in raw_config:
34
- raw_config["MODEL"] = ask_non_empty("Model Name: ")
35
- if "LLM_API_BASE" not in raw_config:
36
- raw_config["LLM_API_BASE"] = input("API Base URL (may be empty for some API types): ")
37
- if extras:
38
- if isinstance(extras, list):
39
- extras = {
40
- i: str(i)
41
- .replace('_', ' ')
42
- .capitalize()
43
- .replace('Llm', 'LLM')
44
- .replace('Api', 'API')
45
- for i in extras
46
- }
47
- for field, title in extras.items():
48
- if field not in raw_config:
49
- raw_config[field] = ask_non_empty(f"{title}: ")
50
- try:
51
- configure(
52
- **{
53
- **dict(
54
- USE_DOT_ENV=False,
55
- EMBEDDING_DB_TYPE=EmbeddingDbType.NONE,
56
- USE_LOGGING=True,
57
- ),
58
- **raw_config
59
- }
60
- )
61
- print("Testing LLM...")
62
- q = "What is capital of France?\n(!) IMPORTANT: Answer only with one word"
63
- assert "pari" in llm(q).lower()
64
- except Exception as e: # pylint: disable=W0718
65
- error(f"Error testing LLM API: {e}")
66
- if ask_yn("Restart configuring?"):
67
- interactive_setup(file_path, defaults, extras)
68
- return
69
-
70
- config_body = ''.join(f"{k}={v}\n" for k, v in raw_config.items())
71
- print(f"Configuration:\n{yellow(config_body)}")
72
- if ask_yn("Save configuration to file?"):
73
- print(f"Saved to {file_link(file_path)}")
74
- with open(file_path, "w", encoding="utf-8") as f:
75
- f.write(config_body)
1
+ from .configuration import EmbeddingDbType, ApiType, Config
2
+ from .ui import ask_choose, ask_non_empty, ask_yn, error, yellow
3
+ from ._env import configure
4
+ from ._llm_functions import llm
5
+ from .utils import file_link
6
+
7
+
8
+ def interactive_setup(
9
+ file_path: str,
10
+ defaults: dict = None,
11
+ extras: dict | list = None,
12
+ ) -> Config | None:
13
+ """
14
+ Interactive setup for LLM API configuration.
15
+ Prompts user for configuration details such as API type, key, model name,
16
+ and base URL. Tests the LLM API with a sample query and saves the configuration
17
+ to a specified file if the user chooses to do so.
18
+ Args:
19
+ file_path (str): Path to the configuration file.
20
+ defaults (dict, optional): Default configuration values.
21
+ If provided, user will not be prompted for those values.
22
+ Additional values for storing in the file can be added to defaults.
23
+ extras (dict | list, optional): Additional configuration fields to prompt for.
24
+ """
25
+ raw_config = dict(defaults) if defaults else dict()
26
+ if "LLM_API_TYPE" not in raw_config:
27
+ raw_config["LLM_API_TYPE"] = ask_choose(
28
+ "Choose LLM API Type:",
29
+ list(i.value for i in ApiType if not ApiType.is_local(i)),
30
+ )
31
+ if "LLM_API_KEY" not in raw_config:
32
+ raw_config["LLM_API_KEY"] = ask_non_empty("API Key: ")
33
+ if "MODEL" not in raw_config:
34
+ raw_config["MODEL"] = ask_non_empty("Model Name: ")
35
+ if "LLM_API_BASE" not in raw_config:
36
+ raw_config["LLM_API_BASE"] = input("API Base URL (may be empty for some API types): ")
37
+ if extras:
38
+ if isinstance(extras, list):
39
+ extras = {
40
+ i: (
41
+ str(i)
42
+ .replace('_', ' ')
43
+ .capitalize()
44
+ .replace('Llm', 'LLM')
45
+ .replace('Api', 'API')
46
+ )
47
+ for i in extras
48
+ }
49
+ for field, title in extras.items():
50
+ if field not in raw_config:
51
+ raw_config[field] = ask_non_empty(f"{title}: ")
52
+ try:
53
+ config = configure(
54
+ **{
55
+ **dict(
56
+ USE_DOT_ENV=False,
57
+ EMBEDDING_DB_TYPE=EmbeddingDbType.NONE,
58
+ USE_LOGGING=True,
59
+ ),
60
+ **raw_config
61
+ }
62
+ )
63
+ print("Testing LLM...")
64
+ q = "What is capital of France?\n(!) IMPORTANT: Answer only with one word"
65
+ assert "pari" in llm(q).lower()
66
+ except Exception as e: # pylint: disable=W0718
67
+ error(f"Error testing LLM API: {e}")
68
+ if ask_yn("Restart configuring?"):
69
+ return interactive_setup(file_path, defaults, extras)
70
+ return None
71
+
72
+ config_body = ''.join(f"{k}={v}\n" for k, v in raw_config.items())
73
+ print(f"Configuration:\n{yellow(config_body)}")
74
+ if ask_yn("Save configuration to file?"):
75
+ print(f"Saved to {file_link(file_path)}")
76
+ with open(file_path, "w", encoding="utf-8") as f:
77
+ f.write(config_body)
78
+ return config
@@ -0,0 +1,280 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Optional
4
+ from dataclasses import dataclass, field
5
+
6
+ from mcp.client.streamable_http import streamablehttp_client
7
+ from mcp import ClientSession, types
8
+
9
+ from .utils import ExtendedString
10
+ from .ai_func import AiFuncSyntax
11
+ from . import ui
12
+ from .types import BadAIAnswer, BadAIJsonAnswer
13
+ from .wrappers.llm_response_wrapper import LLMResponse
14
+ from ._env import env
15
+ from .file_storage import storage
16
+
17
+
18
+ class WrongMcpUsage(BadAIAnswer):
19
+ ...
20
+
21
+
22
+ class ToolsCache:
23
+ FILE = "mcp/tools.json"
24
+
25
+ @staticmethod
26
+ def read(mcp_url: str) -> Optional["Tools"]:
27
+ logging.info(f"Checking MCP tools cache for {ui.green(mcp_url)}...")
28
+ tools = storage.read_json(ToolsCache.FILE, default={}).get(mcp_url, None)
29
+ if tools is not None:
30
+ return Tools.from_list([Tool(**raw) for raw in tools.values()])
31
+ return None
32
+
33
+ @staticmethod
34
+ def write(mcp_url: str, tools: "Tools"):
35
+ logging.info(f"Storing MCP tools cache for {ui.green(mcp_url)}...")
36
+ cached_tools = storage.read_json(ToolsCache.FILE, default={})
37
+ cached_tools[mcp_url] = tools
38
+ storage.write_json(ToolsCache.FILE, cached_tools)
39
+
40
+
41
+ @dataclass
42
+ class MCPConnection:
43
+ url: str = None
44
+ read_stream: any = None
45
+ write_stream: any = None
46
+ connection: any = None
47
+ context_manager: any = None
48
+ session: ClientSession = field(default=None, init=False)
49
+ del_event: asyncio.Event = field(default=None, init=False)
50
+ tools: Optional["Tools"] = field(default=None, init=False)
51
+
52
+ @staticmethod
53
+ async def init(
54
+ url: str,
55
+ fetch_tools: bool = True,
56
+ use_cache: bool = True,
57
+ ) -> "MCPConnection":
58
+
59
+ del_event = asyncio.Event()
60
+ opened_event = asyncio.Event()
61
+ con: MCPConnection = MCPConnection()
62
+ con.del_event = del_event
63
+
64
+ # That's a bit of a hack for closing the async context managers
65
+ async def lifecycle():
66
+ try:
67
+ logging.info(f"Connecting to MCP {url}...")
68
+ context_manager = streamablehttp_client(url)
69
+ (
70
+ read_stream,
71
+ write_stream,
72
+ connection
73
+ ) = await context_manager.__aenter__() # pylint: disable=E1101
74
+ con.url = url
75
+ con.read_stream = read_stream
76
+ con.write_stream = write_stream
77
+ con.connection = connection
78
+ con.context_manager = context_manager
79
+ await con.init_session()
80
+ if fetch_tools:
81
+ await con.fetch_tools(use_cache=use_cache)
82
+ opened_event.set()
83
+ await del_event.wait()
84
+ finally:
85
+ await con.close()
86
+
87
+ asyncio.create_task(lifecycle())
88
+ await opened_event.wait()
89
+ return con
90
+
91
+ async def close(self):
92
+ logging.info(f"Closing MCP session ({self.url})...")
93
+ try:
94
+ if self.session:
95
+ await self.session.__aexit__(None, None, None)
96
+ del self.session
97
+ self.session = None
98
+ finally:
99
+ logging.info(f"Closing MCP connection ({self.url})...")
100
+ try:
101
+ if self.context_manager:
102
+ await self.context_manager.__aexit__(None, None, None)
103
+ del self.context_manager
104
+ self.context_manager = None
105
+ finally:
106
+ logging.info(f"Closed CTX ({self.url})")
107
+
108
+ async def init_session(self):
109
+ logging.info(f"Initializing MCP session ({self.url})")
110
+ self.session = ClientSession(self.read_stream, self.write_stream)
111
+ await self.session.__aenter__() # pylint: disable=unnecessary-dunder-call
112
+ await self.session.initialize()
113
+ return self.session
114
+
115
+ async def fetch_tools(self, use_cache: bool = True) -> "Tools":
116
+ if self.tools is not None:
117
+ return self.tools
118
+
119
+ if use_cache and (cached_tools := ToolsCache.read(self.url)):
120
+ logging.info("Using MCP tools from cache for %s", ui.green(self.url))
121
+ self.tools = cached_tools
122
+ return self.tools
123
+
124
+ logging.info("Fetching tools from MCP %s", ui.green(self.url))
125
+ mcp_tools = await self.session.list_tools()
126
+ self.tools = Tools.from_list([Tool.from_mcp(tool) for tool in mcp_tools.tools])
127
+ if use_cache:
128
+ ToolsCache.write(self.url, self.tools)
129
+ return self.tools
130
+
131
+ def __del__(self):
132
+ self.del_event.set()
133
+
134
+ async def exec(self, params: dict | LLMResponse):
135
+ if isinstance(params, LLMResponse):
136
+ try:
137
+ params = params.parse_json(
138
+ raise_errors=True,
139
+ required_fields=[env().config.AI_SYNTAX_FUNCTION_NAME_FIELD],
140
+ )
141
+ except BadAIJsonAnswer as e:
142
+ raise WrongMcpUsage(str(e)) from e
143
+ params = dict(params)
144
+ name = params.pop(env().config.AI_SYNTAX_FUNCTION_NAME_FIELD)
145
+ if not name:
146
+ raise WrongMcpUsage(
147
+ f"Tool name should be passed in {env().config.AI_SYNTAX_FUNCTION_NAME_FIELD} field"
148
+ )
149
+ logging.info(f"Calling MCP tool {ui.green(name)} with {params}...")
150
+ result = await self.session.call_tool(name, params)
151
+ content = result.content
152
+ if content and len(content) == 1 and content[0].type == "text":
153
+ return ExtendedString(content[0].text, result.__dict__)
154
+ return result
155
+
156
+
157
+ @dataclass
158
+ class Tool:
159
+ @dataclass
160
+ class Arg:
161
+ name: str = field()
162
+ description: str = field(default="")
163
+ type: str = field(default="string")
164
+ required: bool = field(default=...)
165
+ default: any = field(default=...)
166
+
167
+ def __post_init__(self):
168
+ if self.required is ...:
169
+ self.required = self.default is ...
170
+ if self.default is ...:
171
+ self.default = None
172
+
173
+ name: str = field()
174
+ description: str = field(default="")
175
+ args: dict[str, Arg | dict] = field(default_factory=dict)
176
+
177
+ def __post_init__(self):
178
+ for key in self.args.keys(): # pylint: disable=C0201, C0206
179
+ if isinstance(self.args[key], dict):
180
+ self.args[key] = Tool.Arg(**self.args[key])
181
+
182
+ @staticmethod
183
+ def from_mcp(tool: types.Tool) -> "Tool":
184
+ t = Tool(
185
+ name=tool.name,
186
+ description=tool.description,
187
+ )
188
+ for param_name, data in tool.inputSchema.get("properties", {}).items():
189
+ param = Tool.Arg(
190
+ name=param_name,
191
+ description=data.get("title", ""),
192
+ type=data.get("type", "string"),
193
+ required=param_name in tool.inputSchema.get("required", []),
194
+ default="",
195
+ )
196
+ t.args[param_name] = param
197
+ return t
198
+
199
+ def describe(self, syntax: AiFuncSyntax = None) -> str:
200
+ syntax = syntax or AiFuncSyntax.DEFAULT
201
+ tpl_file = f"ai-func.{syntax}.j2" if syntax in AiFuncSyntax else syntax
202
+ metadata = self._get_metadata()
203
+ return env().tpl_function(tpl_file, **metadata)
204
+
205
+ def _get_metadata(self):
206
+ return dict(
207
+ name=self.name,
208
+ description=self.description,
209
+ args={
210
+ arg.name: dict(
211
+ default="NOT_SET" if arg.required else arg.default,
212
+ type=arg.type,
213
+ comment=arg.description, # @todo rename it to description
214
+ )
215
+ for arg in self.args.values()
216
+ }
217
+ )
218
+
219
+ def __str__(self):
220
+ return self.describe(syntax=AiFuncSyntax.DEFAULT)
221
+
222
+
223
+ class Tools(dict[str, Tool]):
224
+ def __str__(self):
225
+ return "\n".join([str(tool) for tool in self.values()])
226
+
227
+ @staticmethod
228
+ def from_list(tools: list[Tool]) -> "Tools":
229
+ return Tools({tool.name: tool for tool in tools})
230
+
231
+
232
+ @dataclass
233
+ class MCPServer:
234
+ name: str
235
+ url: str
236
+ tools: Tools = field(default_factory=Tools)
237
+
238
+ async def connect(
239
+ self,
240
+ fetch_tools: bool = True,
241
+ use_cache: bool = True,
242
+ ) -> MCPConnection:
243
+ return await MCPConnection.init(self.url, fetch_tools=fetch_tools, use_cache=use_cache)
244
+
245
+
246
+ class MCPRegistry(dict[str, MCPServer]):
247
+ def __init__(self, server_configs: list[dict]):
248
+ super().__init__()
249
+ for server_config in server_configs:
250
+ self[server_config["name"]] = MCPServer(**server_config)
251
+
252
+ def get(self, server_name: str) -> MCPServer:
253
+ if server_name not in self:
254
+ raise ValueError(f"MCP server '{server_name}' not found in registry")
255
+ return self[server_name]
256
+
257
+ async def connect_to(
258
+ self,
259
+ server_name: str,
260
+ fetch_tools: bool = True,
261
+ use_cache: bool = True,
262
+ ) -> MCPConnection:
263
+ mcp_server = self.get(server_name)
264
+ return await mcp_server.connect(fetch_tools=fetch_tools, use_cache=use_cache)
265
+
266
+
267
+ def server(name: str) -> MCPServer:
268
+ """
269
+ Returns MCP server by name from the registry.
270
+
271
+ Args:
272
+ name (str): The name of the MCP server.
273
+
274
+ Returns:
275
+ MCPServer: The MCP server instance.
276
+
277
+ Raises:
278
+ ValueError: If the server with the given name is not found in the registry.
279
+ """
280
+ return env().mcp_registry.get(name)
@@ -0,0 +1,28 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import jinja2
5
+ from ..types import TplFunctionType
6
+
7
+
8
+ def make_jinja2_env(env) -> jinja2.Environment:
9
+ j2 = jinja2.Environment(
10
+ autoescape=env.config.JINJA2_AUTO_ESCAPE,
11
+ loader=jinja2.ChoiceLoader([
12
+ jinja2.FileSystemLoader(env.config.PROMPT_TEMPLATES_PATH),
13
+ jinja2.FileSystemLoader(Path(__file__).parent.parent / "ai_func"),
14
+ ]),
15
+ )
16
+ j2.globals.update(
17
+ env=env,
18
+ config=env.config,
19
+ **env.config.JINJA2_GLOBALS,
20
+ )
21
+ return j2
22
+
23
+
24
+ def make_tpl_function(env) -> TplFunctionType:
25
+ def tpl(file: os.PathLike[str] | str, **kwargs) -> str:
26
+ return env.jinja_env.get_template(file).render(**kwargs)
27
+
28
+ return tpl
@@ -16,8 +16,9 @@ import tiktoken
16
16
  from colorama import Fore
17
17
 
18
18
  from .configuration import Config
19
- from .types import BadAIAnswer
19
+ from .types import BadAIAnswer, BadAIJsonAnswer
20
20
  from .message_types import UserMsg, SysMsg, AssistantMsg
21
+ from .json_parsing import parse_json
21
22
 
22
23
 
23
24
  def is_chat_model(model: str, config: Config = None) -> bool:
@@ -101,6 +102,18 @@ class ExtendedString(str):
101
102
  """
102
103
  return len(self.to_tokens(for_model=for_model, encoding=encoding))
103
104
 
105
+ def parse_json(
106
+ self, raise_errors: bool = True, required_fields: list[str] = None
107
+ ) -> list | dict | float | int | str:
108
+ return parse_json(self, raise_errors, required_fields)
109
+
110
+ def contains_valid_json(self) -> bool:
111
+ try:
112
+ self.parse_json(raise_errors=True)
113
+ return True
114
+ except BadAIJsonAnswer:
115
+ return False
116
+
104
117
 
105
118
  class DataclassEncoder(json.JSONEncoder):
106
119
  """@private"""
@@ -1,10 +1,13 @@
1
1
  from typing import Any
2
+ from typing import TYPE_CHECKING
2
3
 
3
4
  from ..types import BadAIAnswer, TPrompt
4
- from ..json_parsing import parse_json
5
5
  from ..utils import ExtendedString, ConvertableToMessage, extract_number
6
6
  from ..message_types import Role, AssistantMsg
7
7
 
8
+ if TYPE_CHECKING:
9
+ from ..mcp import MCPConnection
10
+
8
11
 
9
12
  class DictFromLLMResponse(dict):
10
13
  llm_response: "LLMResponse"
@@ -13,6 +16,9 @@ class DictFromLLMResponse(dict):
13
16
  self.llm_response = llm_response
14
17
  return self
15
18
 
19
+ async def to_mcp(self, mcp: "MCPConnection"):
20
+ return await mcp.exec(self)
21
+
16
22
 
17
23
  class LLMResponse(ExtendedString, ConvertableToMessage):
18
24
  """
@@ -52,7 +58,7 @@ class LLMResponse(ExtendedString, ConvertableToMessage):
52
58
  validator: callable = None,
53
59
  ) -> list | dict | float | int | str | DictFromLLMResponse:
54
60
  try:
55
- res = parse_json(self.content, True, required_fields)
61
+ res = super().parse_json(raise_errors=True, required_fields=required_fields)
56
62
  if validator:
57
63
  try:
58
64
  validator(res)
@@ -83,3 +89,6 @@ class LLMResponse(ExtendedString, ConvertableToMessage):
83
89
 
84
90
  def as_message(self) -> AssistantMsg:
85
91
  return self.as_assistant
92
+
93
+ async def to_mcp(self, mcp: "MCPConnection"):
94
+ return await mcp.exec(self)
@@ -5,11 +5,13 @@ build-backend = "flit_core.buildapi"
5
5
  [project]
6
6
  name = "ai-microcore"
7
7
  dynamic = ["description", "version"]
8
- keywords = ["llm", "large language models", "ai", "similarity search", "ai search", "gpt", "openai"]
8
+ keywords = ["llm", "large language models", "ai", "similarity search", "ai search", "gpt", "openai", "framework", "adapter"]
9
9
  readme = "README.md"
10
10
  classifiers = [
11
11
  "Programming Language :: Python :: 3",
12
12
  "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
13
15
  "License :: OSI Approved :: MIT License",
14
16
  "Operating System :: OS Independent",
15
17
  "Intended Audience :: Developers",
@@ -24,6 +26,8 @@ dependencies = [
24
26
  "PyYAML~=6.0",
25
27
  "chardet~=5.2.0",
26
28
  "tiktoken>=0.7.0,<1.0",
29
+ "mcp~=1.9.1",
30
+ "docstring_parser~=0.16.0",
27
31
  ]
28
32
  requires-python = ">=3.10"
29
33
 
@@ -1,19 +0,0 @@
1
- import os
2
- import jinja2
3
- from ..types import TplFunctionType
4
-
5
-
6
- def make_jinja2_env(env) -> jinja2.Environment:
7
- return jinja2.Environment(
8
- autoescape=env.config.JINJA2_AUTO_ESCAPE,
9
- loader=jinja2.ChoiceLoader(
10
- [jinja2.FileSystemLoader(env.config.PROMPT_TEMPLATES_PATH)]
11
- ),
12
- )
13
-
14
-
15
- def make_tpl_function(env) -> TplFunctionType:
16
- def tpl(file: os.PathLike[str] | str, **kwargs) -> str:
17
- return env.jinja_env.get_template(file).render(**kwargs)
18
-
19
- return tpl