mcptoolforge 0.1.0__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.
- mcptoolforge/__init__.py +64 -0
- mcptoolforge/cli/__init__.py +1 -0
- mcptoolforge/cli/commands.py +278 -0
- mcptoolforge/cli/main.py +101 -0
- mcptoolforge/config.py +45 -0
- mcptoolforge/decorators.py +49 -0
- mcptoolforge/errors.py +94 -0
- mcptoolforge/execution.py +25 -0
- mcptoolforge/mcp/__init__.py +4 -0
- mcptoolforge/mcp/adapter.py +405 -0
- mcptoolforge/mcp/server.py +111 -0
- mcptoolforge/middleware/__init__.py +11 -0
- mcptoolforge/middleware/context.py +15 -0
- mcptoolforge/middleware/logging.py +30 -0
- mcptoolforge/middleware/manager.py +68 -0
- mcptoolforge/middleware/timing.py +37 -0
- mcptoolforge/project.py +134 -0
- mcptoolforge/prompts/__init__.py +5 -0
- mcptoolforge/prompts/decorator.py +32 -0
- mcptoolforge/prompts/prompt.py +129 -0
- mcptoolforge/prompts/registry.py +37 -0
- mcptoolforge/registry.py +241 -0
- mcptoolforge/resources/__init__.py +5 -0
- mcptoolforge/resources/decorator.py +33 -0
- mcptoolforge/resources/registry.py +39 -0
- mcptoolforge/resources/resource.py +97 -0
- mcptoolforge/schema.py +146 -0
- mcptoolforge/server.py +91 -0
- mcptoolforge/testing/__init__.py +15 -0
- mcptoolforge/testing/client.py +364 -0
- mcptoolforge/transports/__init__.py +1 -0
- mcptoolforge/validation.py +229 -0
- mcptoolforge-0.1.0.dist-info/METADATA +655 -0
- mcptoolforge-0.1.0.dist-info/RECORD +37 -0
- mcptoolforge-0.1.0.dist-info/WHEEL +4 -0
- mcptoolforge-0.1.0.dist-info/entry_points.txt +2 -0
- mcptoolforge-0.1.0.dist-info/licenses/LICENSE +21 -0
mcptoolforge/project.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcptoolforge.config import MCPToolForgeConfig
|
|
7
|
+
from mcptoolforge.errors import (
|
|
8
|
+
EntrypointNotFoundError,
|
|
9
|
+
InvalidConfigurationError,
|
|
10
|
+
ProjectNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from mcptoolforge.server import MCPServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Project:
|
|
16
|
+
"""Represents a discovered MCPToolForge project and its configuration."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, root: Path, pyproject_path: Path, config: MCPToolForgeConfig):
|
|
19
|
+
self.root = root
|
|
20
|
+
self.pyproject_path = pyproject_path
|
|
21
|
+
self.config = config
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def discover(cls, start_dir: Path | None = None) -> "Project":
|
|
25
|
+
"""Search up directory tree starting from start_dir for pyproject.toml."""
|
|
26
|
+
current = Path(start_dir or Path.cwd()).resolve()
|
|
27
|
+
|
|
28
|
+
while True:
|
|
29
|
+
pyproject_file = current / "pyproject.toml"
|
|
30
|
+
if pyproject_file.is_file():
|
|
31
|
+
try:
|
|
32
|
+
with open(pyproject_file, "rb") as f:
|
|
33
|
+
data = tomllib.load(f)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise InvalidConfigurationError(
|
|
36
|
+
f"Failed to parse TOML in '{pyproject_file}': {e}"
|
|
37
|
+
) from e
|
|
38
|
+
|
|
39
|
+
# Extract [tool.mcptoolforge] section
|
|
40
|
+
tool_data = data.get("tool", {})
|
|
41
|
+
if "mcptoolforge" not in tool_data:
|
|
42
|
+
raise ProjectNotFoundError(
|
|
43
|
+
"MCPToolForge configuration '[tool.mcptoolforge]' not found "
|
|
44
|
+
f"in '{pyproject_file}'."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
tf_section = tool_data["mcptoolforge"]
|
|
48
|
+
if not isinstance(tf_section, dict):
|
|
49
|
+
raise InvalidConfigurationError(
|
|
50
|
+
"Section '[tool.mcptoolforge]' "
|
|
51
|
+
f"in '{pyproject_file}' must be a table/dictionary."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if "name" not in tf_section:
|
|
55
|
+
raise InvalidConfigurationError(
|
|
56
|
+
"Required configuration field 'name' is missing "
|
|
57
|
+
f"from '[tool.mcptoolforge]' in '{pyproject_file}'."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
config = MCPToolForgeConfig(
|
|
61
|
+
name=tf_section["name"],
|
|
62
|
+
entrypoint=tf_section.get("entrypoint", "server.py"),
|
|
63
|
+
transport=tf_section.get("transport", "stdio"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return cls(
|
|
67
|
+
root=current,
|
|
68
|
+
pyproject_path=pyproject_file,
|
|
69
|
+
config=config,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Move up to parent directory
|
|
73
|
+
parent = current.parent
|
|
74
|
+
if parent == current:
|
|
75
|
+
raise ProjectNotFoundError(
|
|
76
|
+
"Could not find a pyproject.toml file in parent directories."
|
|
77
|
+
)
|
|
78
|
+
current = parent
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_server_from_project(project: Project) -> MCPServer:
|
|
82
|
+
"""Safely load and import the MCPServer instance from the project configuration."""
|
|
83
|
+
entrypoint_path = (project.root / project.config.entrypoint).resolve()
|
|
84
|
+
return load_server_from_file(entrypoint_path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_server_from_file(file_path: Path) -> MCPServer:
|
|
88
|
+
"""Safely load and import the MCPServer instance from a specific file path."""
|
|
89
|
+
if not file_path.exists():
|
|
90
|
+
raise EntrypointNotFoundError(f"MCPToolForge entrypoint '{file_path.name}' was not found.")
|
|
91
|
+
|
|
92
|
+
if not file_path.is_file() or file_path.suffix != ".py":
|
|
93
|
+
raise InvalidConfigurationError(
|
|
94
|
+
f"MCPToolForge entrypoint '{file_path.name}' is not a Python file."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
module_name = "mcptoolforge_user_server"
|
|
99
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
100
|
+
if spec is None or spec.loader is None:
|
|
101
|
+
raise InvalidConfigurationError(
|
|
102
|
+
f"Failed to load spec for entrypoint '{file_path.name}'."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
module = importlib.util.module_from_spec(spec)
|
|
106
|
+
sys.modules[module_name] = module
|
|
107
|
+
spec.loader.exec_module(module)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
raise InvalidConfigurationError(
|
|
110
|
+
f"Failed to execute entrypoint module '{file_path.name}': {e}"
|
|
111
|
+
) from e
|
|
112
|
+
|
|
113
|
+
# Find the expected server object in the loaded module variables
|
|
114
|
+
server_vars = {}
|
|
115
|
+
for attr_name in dir(module):
|
|
116
|
+
attr = getattr(module, attr_name)
|
|
117
|
+
if isinstance(attr, MCPServer):
|
|
118
|
+
server_vars[attr_name] = attr
|
|
119
|
+
|
|
120
|
+
# 1. Enforce naming convention first
|
|
121
|
+
if "server" in server_vars:
|
|
122
|
+
return server_vars["server"]
|
|
123
|
+
|
|
124
|
+
# 2. Check for multiple instances (ambiguity)
|
|
125
|
+
if len(server_vars) > 1:
|
|
126
|
+
raise InvalidConfigurationError(
|
|
127
|
+
f"Multiple MCPServer instances found in '{file_path.name}', "
|
|
128
|
+
f"but none are named 'server'. Ambiguity detected: {list(server_vars.keys())}."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# 3. No instance or naming convention mismatch
|
|
132
|
+
raise InvalidConfigurationError(
|
|
133
|
+
f"Entry point '{file_path.name}' does not define a MCPToolForge MCPServer named 'server'."
|
|
134
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
|
+
|
|
4
|
+
from mcptoolforge.prompts.prompt import Prompt
|
|
5
|
+
|
|
6
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PromptDecorator:
|
|
10
|
+
"""Decorator to register functions as prompts on a PromptRegistry."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, registry: Any) -> None:
|
|
13
|
+
self._registry = registry
|
|
14
|
+
|
|
15
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
16
|
+
# Check if used as @prompt directly
|
|
17
|
+
if len(args) == 1 and callable(args[0]) and not kwargs:
|
|
18
|
+
func = args[0]
|
|
19
|
+
prompt = Prompt(func)
|
|
20
|
+
self._registry.register(prompt)
|
|
21
|
+
return func
|
|
22
|
+
|
|
23
|
+
# Used as @prompt(name=..., description=...)
|
|
24
|
+
name: str | None = kwargs.get("name")
|
|
25
|
+
description: str | None = kwargs.get("description")
|
|
26
|
+
|
|
27
|
+
def decorator(func: F) -> F:
|
|
28
|
+
prompt = Prompt(func, name=name, description=description)
|
|
29
|
+
self._registry.register(prompt)
|
|
30
|
+
return func
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, get_type_hints
|
|
5
|
+
|
|
6
|
+
from mcptoolforge.errors import ToolRegistrationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PromptParameter:
|
|
10
|
+
"""Represents an argument/parameter of a prompt."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
name: str,
|
|
15
|
+
annotation: Any,
|
|
16
|
+
default: Any,
|
|
17
|
+
required: bool,
|
|
18
|
+
kind: inspect._ParameterKind,
|
|
19
|
+
):
|
|
20
|
+
self.name = name
|
|
21
|
+
self.annotation = annotation
|
|
22
|
+
self.default = default
|
|
23
|
+
self.required = required
|
|
24
|
+
self.kind = kind
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return (
|
|
28
|
+
f"PromptParameter(name={self.name!r}, annotation={self.annotation!r}, "
|
|
29
|
+
f"default={self.default!r}, required={self.required!r})"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Prompt:
|
|
34
|
+
"""Represents a registered prompt template in MCPToolForge."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
fn: Callable[..., Any],
|
|
39
|
+
name: str | None = None,
|
|
40
|
+
description: str | None = None,
|
|
41
|
+
):
|
|
42
|
+
if not callable(fn):
|
|
43
|
+
raise ToolRegistrationError("Registered prompt object must be a callable.")
|
|
44
|
+
|
|
45
|
+
self._fn = fn
|
|
46
|
+
|
|
47
|
+
# Name validation
|
|
48
|
+
if name is not None:
|
|
49
|
+
if not isinstance(name, str):
|
|
50
|
+
raise ToolRegistrationError("Prompt name must be a string.")
|
|
51
|
+
if not name.strip():
|
|
52
|
+
raise ToolRegistrationError("Prompt name cannot be empty or whitespace-only.")
|
|
53
|
+
self._name = name.strip()
|
|
54
|
+
else:
|
|
55
|
+
self._name = fn.__name__
|
|
56
|
+
|
|
57
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*$", self._name):
|
|
58
|
+
raise ToolRegistrationError(
|
|
59
|
+
f"Invalid prompt name: '{self._name}'. "
|
|
60
|
+
"Must be alphanumeric, underscores, or hyphens."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Description validation
|
|
64
|
+
if description is not None:
|
|
65
|
+
if not isinstance(description, str):
|
|
66
|
+
raise ToolRegistrationError("Prompt description must be a string.")
|
|
67
|
+
if not description.strip():
|
|
68
|
+
raise ToolRegistrationError(
|
|
69
|
+
"Prompt description cannot be empty or whitespace-only."
|
|
70
|
+
)
|
|
71
|
+
self._description = description.strip()
|
|
72
|
+
else:
|
|
73
|
+
doc = inspect.getdoc(fn)
|
|
74
|
+
self._description = doc.strip() if doc else "No description provided."
|
|
75
|
+
|
|
76
|
+
# Introspect function parameters
|
|
77
|
+
self._parameters: dict[str, PromptParameter] = {}
|
|
78
|
+
try:
|
|
79
|
+
sig = inspect.signature(fn)
|
|
80
|
+
try:
|
|
81
|
+
type_hints = get_type_hints(fn)
|
|
82
|
+
except (TypeError, NameError):
|
|
83
|
+
type_hints = {}
|
|
84
|
+
except Exception as e:
|
|
85
|
+
raise ToolRegistrationError(f"Failed to inspect prompt function signature: {e}") from e
|
|
86
|
+
|
|
87
|
+
for param_name, param in sig.parameters.items():
|
|
88
|
+
if param_name in ("self", "cls"):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
annotation = type_hints.get(param_name, param.annotation)
|
|
92
|
+
required = param.default is inspect.Parameter.empty
|
|
93
|
+
default = inspect.Parameter.empty if required else param.default
|
|
94
|
+
|
|
95
|
+
self._parameters[param_name] = PromptParameter(
|
|
96
|
+
name=param_name,
|
|
97
|
+
annotation=annotation,
|
|
98
|
+
default=default,
|
|
99
|
+
required=required,
|
|
100
|
+
kind=param.kind,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self._return_type = type_hints.get("return", sig.return_annotation)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def fn(self) -> Callable[..., Any]:
|
|
107
|
+
return self._fn
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def name(self) -> str:
|
|
111
|
+
return self._name
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def description(self) -> str:
|
|
115
|
+
return self._description
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def parameters(self) -> dict[str, PromptParameter]:
|
|
119
|
+
return self._parameters
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def return_type(self) -> Any:
|
|
123
|
+
return self._return_type
|
|
124
|
+
|
|
125
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
126
|
+
return self._fn(*args, **kwargs)
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return f"Prompt(name={self.name!r}, description={self.description!r})"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from mcptoolforge.errors import ToolAlreadyRegisteredError, ToolNotFoundError
|
|
2
|
+
from mcptoolforge.prompts.prompt import Prompt
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PromptRegistry:
|
|
6
|
+
"""Manages prompt storage, lookup, and deletion."""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._prompts: dict[str, Prompt] = {}
|
|
10
|
+
|
|
11
|
+
def register(self, prompt: Prompt) -> None:
|
|
12
|
+
"""Register a prompt in the registry."""
|
|
13
|
+
if prompt.name in self._prompts:
|
|
14
|
+
raise ToolAlreadyRegisteredError(
|
|
15
|
+
f"Duplicate prompt name: '{prompt.name}' is already registered."
|
|
16
|
+
)
|
|
17
|
+
self._prompts[prompt.name] = prompt
|
|
18
|
+
|
|
19
|
+
def get(self, name: str) -> Prompt:
|
|
20
|
+
"""Retrieve a prompt by name."""
|
|
21
|
+
if name not in self._prompts:
|
|
22
|
+
raise ToolNotFoundError(f"Prompt '{name}' is not registered.")
|
|
23
|
+
return self._prompts[name]
|
|
24
|
+
|
|
25
|
+
def remove(self, name: str) -> None:
|
|
26
|
+
"""Remove a prompt by name."""
|
|
27
|
+
if name not in self._prompts:
|
|
28
|
+
raise ToolNotFoundError(f"Prompt '{name}' is not registered and cannot be removed.")
|
|
29
|
+
del self._prompts[name]
|
|
30
|
+
|
|
31
|
+
def list(self) -> list[Prompt]:
|
|
32
|
+
"""Return a list of all registered prompts."""
|
|
33
|
+
return list(self._prompts.values())
|
|
34
|
+
|
|
35
|
+
def contains(self, name: str) -> bool:
|
|
36
|
+
"""Check if a prompt is registered by name."""
|
|
37
|
+
return name in self._prompts
|
mcptoolforge/registry.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, get_type_hints
|
|
5
|
+
|
|
6
|
+
from mcptoolforge.errors import (
|
|
7
|
+
ToolAlreadyRegisteredError,
|
|
8
|
+
ToolNotFoundError,
|
|
9
|
+
ToolRegistrationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolParameter:
|
|
14
|
+
"""Represents an input parameter of a tool."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
name: str,
|
|
19
|
+
annotation: Any,
|
|
20
|
+
default: Any,
|
|
21
|
+
required: bool,
|
|
22
|
+
kind: inspect._ParameterKind,
|
|
23
|
+
):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.annotation = annotation
|
|
26
|
+
self.default = default
|
|
27
|
+
self.required = required
|
|
28
|
+
self.kind = kind
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return (
|
|
32
|
+
f"ToolParameter(name={self.name!r}, annotation={self.annotation!r}, "
|
|
33
|
+
f"default={self.default!r}, required={self.required!r}, kind={self.kind!r})"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Tool:
|
|
38
|
+
"""Represents a registered tool in MCPToolForge."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
fn: Callable[..., Any],
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
description: str | None = None,
|
|
45
|
+
tags: list[str] | None = None,
|
|
46
|
+
metadata: dict[str, Any] | None = None,
|
|
47
|
+
):
|
|
48
|
+
if not callable(fn):
|
|
49
|
+
raise ToolRegistrationError("Registered object must be a callable.")
|
|
50
|
+
|
|
51
|
+
self._fn = fn
|
|
52
|
+
|
|
53
|
+
# Validate name
|
|
54
|
+
if name is not None:
|
|
55
|
+
if not isinstance(name, str):
|
|
56
|
+
raise ToolRegistrationError("Tool name must be a string.")
|
|
57
|
+
if not name.strip():
|
|
58
|
+
raise ToolRegistrationError("Tool name cannot be empty or whitespace-only.")
|
|
59
|
+
self._name = name.strip()
|
|
60
|
+
else:
|
|
61
|
+
self._name = fn.__name__
|
|
62
|
+
|
|
63
|
+
# Validate name format (standard identifier conventions)
|
|
64
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*$", self._name):
|
|
65
|
+
raise ToolRegistrationError(
|
|
66
|
+
f"Invalid tool name: '{self._name}'. Must be alphanumeric, underscores, or hyphens."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Validate description
|
|
70
|
+
if description is not None:
|
|
71
|
+
if not isinstance(description, str):
|
|
72
|
+
raise ToolRegistrationError("Custom description must be a string.")
|
|
73
|
+
if not description.strip():
|
|
74
|
+
raise ToolRegistrationError(
|
|
75
|
+
"Custom description cannot be empty or whitespace-only."
|
|
76
|
+
)
|
|
77
|
+
self._description = description.strip()
|
|
78
|
+
else:
|
|
79
|
+
doc = inspect.getdoc(fn)
|
|
80
|
+
self._description = doc.strip() if doc else "No description provided."
|
|
81
|
+
|
|
82
|
+
# Validate and store tags
|
|
83
|
+
self._tags: list[str] = []
|
|
84
|
+
if tags is not None:
|
|
85
|
+
if not isinstance(tags, (list, tuple)):
|
|
86
|
+
raise ToolRegistrationError("Tags must be a list or tuple of strings.")
|
|
87
|
+
seen = set()
|
|
88
|
+
clean_tags = []
|
|
89
|
+
for tag in tags:
|
|
90
|
+
if not isinstance(tag, str) or not tag.strip():
|
|
91
|
+
raise ToolRegistrationError("Each tag must be a non-empty string.")
|
|
92
|
+
t_stripped = tag.strip()
|
|
93
|
+
if t_stripped not in seen:
|
|
94
|
+
seen.add(t_stripped)
|
|
95
|
+
clean_tags.append(t_stripped)
|
|
96
|
+
self._tags = clean_tags
|
|
97
|
+
|
|
98
|
+
# Validate and store metadata
|
|
99
|
+
self._metadata: dict[str, Any] = {}
|
|
100
|
+
if metadata is not None:
|
|
101
|
+
if not isinstance(metadata, dict):
|
|
102
|
+
raise ToolRegistrationError("Metadata must be a dictionary.")
|
|
103
|
+
for key in metadata:
|
|
104
|
+
if not isinstance(key, str):
|
|
105
|
+
raise ToolRegistrationError("Metadata keys must be strings.")
|
|
106
|
+
if key in {
|
|
107
|
+
"name",
|
|
108
|
+
"description",
|
|
109
|
+
"tags",
|
|
110
|
+
"parameters",
|
|
111
|
+
"return_type",
|
|
112
|
+
"input_schema",
|
|
113
|
+
"fn",
|
|
114
|
+
}:
|
|
115
|
+
raise ToolRegistrationError(
|
|
116
|
+
f"Metadata key '{key}' conflicts with a core Tool field."
|
|
117
|
+
)
|
|
118
|
+
import json
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
json.dumps(metadata)
|
|
122
|
+
except (TypeError, ValueError) as e:
|
|
123
|
+
raise ToolRegistrationError(
|
|
124
|
+
f"Metadata values must be JSON serializable: {e}"
|
|
125
|
+
) from e
|
|
126
|
+
self._metadata = metadata
|
|
127
|
+
|
|
128
|
+
# Introspect function parameters and return type
|
|
129
|
+
self._parameters: dict[str, ToolParameter] = {}
|
|
130
|
+
try:
|
|
131
|
+
sig = inspect.signature(fn)
|
|
132
|
+
try:
|
|
133
|
+
type_hints = get_type_hints(fn)
|
|
134
|
+
except (TypeError, NameError):
|
|
135
|
+
# Fallback if types cannot be resolved (e.g. forward refs not in scope)
|
|
136
|
+
type_hints = {}
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise ToolRegistrationError(f"Failed to inspect function signature: {e}") from e
|
|
139
|
+
|
|
140
|
+
for param_name, param in sig.parameters.items():
|
|
141
|
+
if param_name in ("self", "cls"):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
annotation = type_hints.get(param_name, param.annotation)
|
|
145
|
+
required = param.default is inspect.Parameter.empty
|
|
146
|
+
default = inspect.Parameter.empty if required else param.default
|
|
147
|
+
|
|
148
|
+
self._parameters[param_name] = ToolParameter(
|
|
149
|
+
name=param_name,
|
|
150
|
+
annotation=annotation,
|
|
151
|
+
default=default,
|
|
152
|
+
required=required,
|
|
153
|
+
kind=param.kind,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self._return_type = type_hints.get("return", sig.return_annotation)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def fn(self) -> Callable[..., Any]:
|
|
160
|
+
return self._fn
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def name(self) -> str:
|
|
164
|
+
return self._name
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def description(self) -> str:
|
|
168
|
+
return self._description
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def parameters(self) -> dict[str, ToolParameter]:
|
|
172
|
+
return self._parameters
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def return_type(self) -> Any:
|
|
176
|
+
return self._return_type
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def tags(self) -> list[str]:
|
|
180
|
+
return self._tags
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def metadata(self) -> dict[str, Any]:
|
|
184
|
+
return self._metadata
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def input_schema(self) -> dict[str, Any]:
|
|
188
|
+
"""Generate the input JSON Schema for this tool's parameters."""
|
|
189
|
+
from mcptoolforge.schema import generate_input_schema
|
|
190
|
+
|
|
191
|
+
return generate_input_schema(self.parameters, self.name)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def schema(self) -> dict[str, Any]:
|
|
195
|
+
"""Lazy schema generation to decouple schema structure from representation."""
|
|
196
|
+
return {
|
|
197
|
+
"name": self.name,
|
|
198
|
+
"description": self.description,
|
|
199
|
+
"inputSchema": self.input_schema,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
203
|
+
return self.fn(*args, **kwargs)
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
return f"Tool(name={self.name!r}, description={self.description!r})"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ToolRegistry:
|
|
210
|
+
"""Manages tool storage, lookup, and deletion."""
|
|
211
|
+
|
|
212
|
+
def __init__(self) -> None:
|
|
213
|
+
self._tools: dict[str, Tool] = {}
|
|
214
|
+
|
|
215
|
+
def register(self, tool: Tool) -> None:
|
|
216
|
+
"""Register a tool in the registry."""
|
|
217
|
+
if tool.name in self._tools:
|
|
218
|
+
raise ToolAlreadyRegisteredError(
|
|
219
|
+
f"Duplicate tool name: '{tool.name}' is already registered."
|
|
220
|
+
)
|
|
221
|
+
self._tools[tool.name] = tool
|
|
222
|
+
|
|
223
|
+
def get(self, name: str) -> Tool:
|
|
224
|
+
"""Retrieve a tool by name."""
|
|
225
|
+
if name not in self._tools:
|
|
226
|
+
raise ToolNotFoundError(f"Tool '{name}' is not registered.")
|
|
227
|
+
return self._tools[name]
|
|
228
|
+
|
|
229
|
+
def remove(self, name: str) -> None:
|
|
230
|
+
"""Remove a tool by name."""
|
|
231
|
+
if name not in self._tools:
|
|
232
|
+
raise ToolNotFoundError(f"Tool '{name}' is not registered and cannot be removed.")
|
|
233
|
+
del self._tools[name]
|
|
234
|
+
|
|
235
|
+
def list(self) -> list[Tool]:
|
|
236
|
+
"""Return a list of all registered tools."""
|
|
237
|
+
return list(self._tools.values())
|
|
238
|
+
|
|
239
|
+
def contains(self, name: str) -> bool:
|
|
240
|
+
"""Check if a tool is registered by name."""
|
|
241
|
+
return name in self._tools
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
|
+
|
|
4
|
+
from mcptoolforge.resources.resource import Resource
|
|
5
|
+
|
|
6
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResourceDecorator:
|
|
10
|
+
"""Decorator to register functions as resources on a ResourceRegistry."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, registry: Any) -> None:
|
|
13
|
+
self._registry = registry
|
|
14
|
+
|
|
15
|
+
def __call__(
|
|
16
|
+
self,
|
|
17
|
+
uri: str,
|
|
18
|
+
name: str | None = None,
|
|
19
|
+
description: str | None = None,
|
|
20
|
+
mime_type: str | None = None,
|
|
21
|
+
) -> Callable[[F], F]:
|
|
22
|
+
def decorator(func: F) -> F:
|
|
23
|
+
resource = Resource(
|
|
24
|
+
uri=uri,
|
|
25
|
+
fn=func,
|
|
26
|
+
name=name,
|
|
27
|
+
description=description,
|
|
28
|
+
mime_type=mime_type,
|
|
29
|
+
)
|
|
30
|
+
self._registry.register(resource)
|
|
31
|
+
return func
|
|
32
|
+
|
|
33
|
+
return decorator
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from mcptoolforge.errors import ResourceAlreadyRegisteredError, ResourceNotFoundError
|
|
2
|
+
from mcptoolforge.resources.resource import Resource
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResourceRegistry:
|
|
6
|
+
"""Manages resource storage, lookup, and deletion."""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._resources: dict[str, Resource] = {}
|
|
10
|
+
|
|
11
|
+
def register(self, resource: Resource) -> None:
|
|
12
|
+
"""Register a resource in the registry."""
|
|
13
|
+
if resource.uri in self._resources:
|
|
14
|
+
raise ResourceAlreadyRegisteredError(
|
|
15
|
+
f"Duplicate resource URI: '{resource.uri}' is already registered."
|
|
16
|
+
)
|
|
17
|
+
self._resources[resource.uri] = resource
|
|
18
|
+
|
|
19
|
+
def get(self, uri: str) -> Resource:
|
|
20
|
+
"""Retrieve a resource by its URI."""
|
|
21
|
+
if uri not in self._resources:
|
|
22
|
+
raise ResourceNotFoundError(f"Resource with URI '{uri}' is not registered.")
|
|
23
|
+
return self._resources[uri]
|
|
24
|
+
|
|
25
|
+
def remove(self, uri: str) -> None:
|
|
26
|
+
"""Remove a resource by its URI."""
|
|
27
|
+
if uri not in self._resources:
|
|
28
|
+
raise ResourceNotFoundError(
|
|
29
|
+
f"Resource with URI '{uri}' is not registered and cannot be removed."
|
|
30
|
+
)
|
|
31
|
+
del self._resources[uri]
|
|
32
|
+
|
|
33
|
+
def list(self) -> list[Resource]:
|
|
34
|
+
"""Return a list of all registered resources."""
|
|
35
|
+
return list(self._resources.values())
|
|
36
|
+
|
|
37
|
+
def contains(self, uri: str) -> bool:
|
|
38
|
+
"""Check if a resource is registered by its URI."""
|
|
39
|
+
return uri in self._resources
|