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.
@@ -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,5 @@
1
+ from mcptoolforge.prompts.decorator import PromptDecorator
2
+ from mcptoolforge.prompts.prompt import Prompt, PromptParameter
3
+ from mcptoolforge.prompts.registry import PromptRegistry
4
+
5
+ __all__ = ["Prompt", "PromptDecorator", "PromptParameter", "PromptRegistry"]
@@ -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
@@ -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,5 @@
1
+ from mcptoolforge.resources.decorator import ResourceDecorator
2
+ from mcptoolforge.resources.registry import ResourceRegistry
3
+ from mcptoolforge.resources.resource import Resource
4
+
5
+ __all__ = ["Resource", "ResourceDecorator", "ResourceRegistry"]
@@ -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