Skip to content

Commit

Permalink
chore: extension system
Browse files Browse the repository at this point in the history
  • Loading branch information
phil65 committed Nov 29, 2024
1 parent 2c31d16 commit 3b2196f
Show file tree
Hide file tree
Showing 11 changed files with 215 additions and 6 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,45 @@ resource_groups:
- lint_config
- style_guide
```

### Extension System

Libraries can expose their functionality to LLMling without having to create a full MCP server implementation. This is done via entry points:

```toml
# In your library's pyproject.toml
[project.entry-points.llmling]
tools = "your_library:get_mcp_tools" # Function returning list of callables
```

```python
# In your library
def get_mcp_tools() -> list[Callable[..., Any]]:
"""Expose functions as LLM tools."""
return [
analyze_code,
validate_json,
process_data,
]
```

Enable tools from a package in your LLMling configuration:

```yaml
# llmling.yml
toolsets:
- your_library # Use tools from your_library
```

> [!TIP]
> Libraries can expose their most useful functions as LLM tools without any LLMling-specific code. The entry point system uses Python's standard packaging features.

#### Discoverable Tools

Tools exposed through entry points:
- Are automatically discovered
- Get schemas generated from type hints and docstrings
- Can be used like any other LLMling tool
- Don't require the library to depend on LLMling

This allows for a rich ecosystem of tools that can be easily composed and used by LLMs.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ dependencies = [
[project.scripts]
mcp-server-llmling = "llmling.server.__main__:run"

[project.entry-points.llmling]
tools = "llmling.testing:get_mcp_tools"

[tool.uv]
default-groups = ["dev", "lint", "docs"]

Expand Down
55 changes: 54 additions & 1 deletion src/llmling/config/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@

from llmling.core import exceptions
from llmling.core.log import get_logger
from llmling.extensions.loaders import ToolsetLoader
from llmling.tools.base import LLMCallableTool
from llmling.utils import importing


if TYPE_CHECKING:
import os

from llmling.config.models import Config, Resource
from llmling.config.models import Config, Resource, ToolConfig


logger = get_logger(__name__)
Expand Down Expand Up @@ -93,3 +96,53 @@ def validate_references(self) -> list[str]:
for resource in resources
if resource not in self.config.resources
]

def _create_tool(self, tool_config: ToolConfig) -> LLMCallableTool:
"""Create tool instance from config.
Args:
tool_config: Tool configuration
Returns:
Configured tool instance
Raises:
ConfigError: If tool creation fails
"""
try:
callable_obj = importing.import_callable(tool_config.import_path)
return LLMCallableTool.from_callable(
callable_obj,
name_override=tool_config.name,
description_override=tool_config.description,
)
except Exception as exc:
msg = f"Failed to create tool from {tool_config.import_path}"
raise exceptions.ConfigError(msg) from exc

def get_tools(self) -> dict[str, LLMCallableTool]:
"""Get all tools from config and toolsets."""
tools = {}

# Load explicitly configured tools
for name, tool_config in self.config.tools.items():
try:
tools[name] = self._create_tool(tool_config)
except Exception:
logger.exception("Failed to create tool %s", name)

# Load tools from toolsets
if self.config.toolsets:
loader = ToolsetLoader()
toolset_tools = loader.load_items(self.config.toolsets)

# Handle potential name conflicts
for name, tool in toolset_tools.items():
if name in tools:
logger.warning(
"Tool %s from toolset overlaps with configured tool", name
)
continue
tools[name] = tool

return tools
1 change: 1 addition & 0 deletions src/llmling/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ class Config(BaseModel):
resources: dict[str, Resource] = Field(default_factory=dict)
resource_groups: dict[str, list[str]] = Field(default_factory=dict)
tools: dict[str, ToolConfig] = Field(default_factory=dict)
toolsets: list[str] = Field(default_factory=list)
# Add prompts support
prompts: dict[str, Prompt] = Field(default_factory=dict)

Expand Down
4 changes: 3 additions & 1 deletion src/llmling/config_resources/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ context_processors:
uppercase:
type: function
import_path: llmling.testing.processors.uppercase_text

add_metadata:
type: template
template: |
Expand All @@ -19,6 +18,9 @@ tools:
import_path: "llmling.testing.tools.analyze_ast"
description: "Analyze Python code structure"

toolsets:
- llmling

# Resource definitions
resources:
python_guidelines:
Expand Down
10 changes: 10 additions & 0 deletions src/llmling/extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Extension system for loading MCP components from entry points."""

from llmling.extensions.base import BaseExtensionLoader
from llmling.extensions.loaders import ToolsetLoader


__all__ = [
"BaseExtensionLoader",
"ToolsetLoader",
]
58 changes: 58 additions & 0 deletions src/llmling/extensions/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Base class for extension loaders."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, ClassVar, TypeVar

from epregistry import EntryPointRegistry

from llmling.core.log import get_logger


if TYPE_CHECKING:
from collections.abc import Callable


logger = get_logger(__name__)
T = TypeVar("T")


class BaseExtensionLoader[T]:
"""Base class for extension loaders.
Entry points are expected to be registered under 'llmling' with their type
as the entry point name, e.g.:
[project.entry-points.llmling]
tools = "my_module:get_mcp_tools"
prompts = "my_module:get_mcp_prompts"
"""

component_type: ClassVar[str]
converter: Callable[[Any], T]

def __init__(self) -> None:
"""Initialize loader."""
self.registry = EntryPointRegistry[Any]("llmling")

def load_items(self, module_names: list[str]) -> dict[str, T]:
"""Load items from specified modules."""
items = {}
for module in module_names:
try:
# Just look for the component type entry point
if entry_point := self.registry.get(self.component_type):
get_items = entry_point.load()
for item in get_items():
try:
converted = self.converter(item)
name = getattr(converted, "name", str(item))
items[name] = converted
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to load item from %s: %s",
module,
exc,
)
except Exception:
logger.exception("Failed to load module %s", module)
return items
19 changes: 19 additions & 0 deletions src/llmling/extensions/loaders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Extension loaders for different MCP component types."""

from __future__ import annotations

from llmling.extensions.base import BaseExtensionLoader
from llmling.tools.base import LLMCallableTool


class ToolsetLoader(BaseExtensionLoader[LLMCallableTool]):
"""Loads tools from entry points.
Entry points should return a list of callable objects:
def get_mcp_tools() -> list[Callable[..., Any]]:
return [function1, function2]
"""

component_type = "tools"
converter = LLMCallableTool.from_callable
10 changes: 6 additions & 4 deletions src/llmling/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pydantic import AnyUrl

from llmling import config_resources
from llmling.config.manager import ConfigManager
from llmling.config.models import PathResource, SourceResource
from llmling.core.log import get_logger
from llmling.processors.registry import ProcessorRegistry
Expand Down Expand Up @@ -72,6 +73,11 @@ def __init__(
loader_registry=self.loader_registry,
processor_registry=self.processor_registry,
)
self.config_manager = ConfigManager(config)

# Register tools from all sources
for tool_name, tool in self.config_manager.get_tools().items():
self.tool_registry[tool_name] = tool

# Register default resource loaders if using new registry
if loader_registry is None:
Expand Down Expand Up @@ -322,10 +328,6 @@ async def start(self, *, raise_exceptions: bool = False) -> None:
await self.resource_registry.startup()
await self.prompt_registry.startup()

# Register tools from config
for name, tool_config in self.config.tools.items():
self.tool_registry[name] = tool_config

# Register resources from config
for name, resource in self.config.resources.items():
self.resource_registry.register(name, resource)
Expand Down
8 changes: 8 additions & 0 deletions src/llmling/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
)
from llmling.testing.utils import TestStreamPair, create_test_server_session


def get_mcp_tools():
"""Entry point exposing test tools to LLMling."""
from llmling.testing.tools import example_tool, analyze_ast

return [example_tool, analyze_ast]


__all__ = [
# Test utilities
"TestStreamPair",
Expand Down
11 changes: 11 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

from llmling.extensions.loaders import ToolsetLoader


def test_toolset_loader():
"""Test loading tools from entry points."""
loader = ToolsetLoader()
tools = loader.load_items(["llmling"]) # just the package name
assert "example_tool" in tools
assert "analyze_ast" in tools

0 comments on commit 3b2196f

Please sign in to comment.