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,64 @@
1
+ from mcptoolforge.config import MCPToolForgeConfig
2
+ from mcptoolforge.errors import (
3
+ ConfigurationError,
4
+ EntrypointNotFoundError,
5
+ InvalidConfigurationError,
6
+ MCPToolForgeError,
7
+ MiddlewareError,
8
+ ProjectNotFoundError,
9
+ ResourceAlreadyRegisteredError,
10
+ ResourceExecutionError,
11
+ ResourceNotFoundError,
12
+ ResourceRegistrationError,
13
+ SchemaGenerationError,
14
+ ToolAlreadyRegisteredError,
15
+ ToolExecutionError,
16
+ ToolNotFoundError,
17
+ ToolRegistrationError,
18
+ ToolValidationError,
19
+ )
20
+ from mcptoolforge.middleware import (
21
+ MiddlewareContext,
22
+ logging_middleware,
23
+ sync_logging_middleware,
24
+ sync_timing_middleware,
25
+ timing_middleware,
26
+ )
27
+ from mcptoolforge.project import Project, load_server_from_file, load_server_from_project
28
+ from mcptoolforge.prompts import Prompt, PromptParameter, PromptRegistry
29
+ from mcptoolforge.resources import Resource, ResourceRegistry
30
+ from mcptoolforge.server import MCPServer
31
+
32
+ __all__ = [
33
+ "ConfigurationError",
34
+ "EntrypointNotFoundError",
35
+ "InvalidConfigurationError",
36
+ "MCPServer",
37
+ "MCPToolForgeConfig",
38
+ "MCPToolForgeError",
39
+ "MiddlewareContext",
40
+ "MiddlewareError",
41
+ "Project",
42
+ "ProjectNotFoundError",
43
+ "Prompt",
44
+ "PromptParameter",
45
+ "PromptRegistry",
46
+ "Resource",
47
+ "ResourceAlreadyRegisteredError",
48
+ "ResourceExecutionError",
49
+ "ResourceNotFoundError",
50
+ "ResourceRegistrationError",
51
+ "ResourceRegistry",
52
+ "SchemaGenerationError",
53
+ "ToolAlreadyRegisteredError",
54
+ "ToolExecutionError",
55
+ "ToolNotFoundError",
56
+ "ToolRegistrationError",
57
+ "ToolValidationError",
58
+ "load_server_from_file",
59
+ "load_server_from_project",
60
+ "logging_middleware",
61
+ "sync_logging_middleware",
62
+ "sync_timing_middleware",
63
+ "timing_middleware",
64
+ ]
@@ -0,0 +1 @@
1
+ """CLI module for MCPToolForge."""
@@ -0,0 +1,278 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from mcptoolforge.errors import ConfigurationError
8
+ from mcptoolforge.project import Project, load_server_from_file, load_server_from_project
9
+
10
+
11
+ def resolve_server_instance(file_path: str | None) -> tuple[Any, Project | None]:
12
+ """Resolve the MCPServer instance and the discovered Project config context."""
13
+ if file_path is not None:
14
+ server = load_server_from_file(Path(file_path))
15
+ try:
16
+ project = Project.discover()
17
+ except Exception:
18
+ project = None
19
+ return server, project
20
+
21
+ try:
22
+ project = Project.discover()
23
+ server = load_server_from_project(project)
24
+ return server, project
25
+ except ConfigurationError as e:
26
+ # If configuration error was raised (like invalid entrypoint or transport), bubble it up
27
+ raise e
28
+ except Exception:
29
+ # Fallback to default "server.py" in current working directory
30
+ default_path = Path.cwd() / "server.py"
31
+ server = load_server_from_file(default_path)
32
+ return server, None
33
+
34
+
35
+ def init_command(directory: str, force: bool = False) -> None:
36
+ """Initialize a new MCPToolForge project directory with a working MCPServer template."""
37
+ target_dir = os.path.abspath(directory)
38
+ project_name = os.path.basename(target_dir.rstrip(os.sep)) or "my-server"
39
+
40
+ if os.path.exists(target_dir) and os.listdir(target_dir):
41
+ conflicts = [
42
+ f
43
+ for f in ["server.py", "pyproject.toml", "README.md", ".gitignore"]
44
+ if os.path.exists(os.path.join(target_dir, f))
45
+ ]
46
+ if conflicts and not force:
47
+ print(
48
+ f"Error: Destination contains conflicts (e.g. '{conflicts[0]}' already exists). "
49
+ "Use --force to overwrite.",
50
+ file=sys.stderr,
51
+ )
52
+ sys.exit(1)
53
+
54
+ os.makedirs(target_dir, exist_ok=True)
55
+
56
+ server_content = f"""from mcptoolforge import MCPServer
57
+
58
+ server = MCPServer("{project_name}")
59
+
60
+ @server.tool
61
+ def add(a: int, b: int) -> int:
62
+ \"\"\"Add two numbers.\"\"\"
63
+ return a + b
64
+
65
+ if __name__ == "__main__":
66
+ server.run()
67
+ """
68
+
69
+ pyproject_content = f"""[build-system]
70
+ requires = ["hatchling"]
71
+ build-backend = "hatchling.build"
72
+
73
+ [project]
74
+ name = "{project_name}"
75
+ version = "0.1.0"
76
+ dependencies = [
77
+ "mcptoolforge",
78
+ ]
79
+
80
+ [tool.mcptoolforge]
81
+ name = "{project_name}"
82
+ entrypoint = "server.py"
83
+ transport = "stdio"
84
+ """
85
+
86
+ readme_content = f"""# {project_name}
87
+
88
+ A MCPToolForge MCP tool server.
89
+
90
+ ## Running
91
+
92
+ ```bash
93
+ python server.py
94
+ ```
95
+ """
96
+
97
+ gitignore_content = """__pycache__/
98
+ .venv/
99
+ *.pyc
100
+ """
101
+
102
+ with open(os.path.join(target_dir, "server.py"), "w") as f:
103
+ f.write(server_content)
104
+ with open(os.path.join(target_dir, "pyproject.toml"), "w") as f:
105
+ f.write(pyproject_content)
106
+ with open(os.path.join(target_dir, "README.md"), "w") as f:
107
+ f.write(readme_content)
108
+ with open(os.path.join(target_dir, ".gitignore"), "w") as f:
109
+ f.write(gitignore_content)
110
+
111
+ print(f"Initialized MCPToolForge project in '{target_dir}'", file=sys.stderr)
112
+
113
+
114
+ def run_command(file_path: str | None) -> None:
115
+ """Execute the discovered MCPServer instance loop."""
116
+ try:
117
+ server, _ = resolve_server_instance(file_path)
118
+ except ConfigurationError as e:
119
+ print(f"Error: {e}", file=sys.stderr)
120
+ sys.exit(1)
121
+ server.run()
122
+
123
+
124
+ def list_command(file_path: str | None) -> None:
125
+ """Print all registered tools, resources, and prompts in the project.
126
+
127
+ Does so without booting the server.
128
+ """
129
+ try:
130
+ server, _ = resolve_server_instance(file_path)
131
+ except ConfigurationError as e:
132
+ print(f"Error: {e}", file=sys.stderr)
133
+ sys.exit(1)
134
+
135
+ print(f"MCPToolForge Server: {server.name}")
136
+ print()
137
+ print("Tools")
138
+ print("─────────────────────────")
139
+ for tool in server.tools:
140
+ desc = tool.description or ""
141
+ first_line = desc.split("\n")[0] if desc else ""
142
+ print(f"{tool.name:<9} {first_line}")
143
+
144
+ resources = server.list_resources()
145
+ if resources:
146
+ print()
147
+ print("Resources")
148
+ print("─────────────────────────")
149
+ for res in resources:
150
+ desc = res.description or ""
151
+ first_line = desc.split("\n")[0] if desc else ""
152
+ print(f"{res.uri:<15} {first_line}")
153
+
154
+ prompts = server.list_prompts()
155
+ if prompts:
156
+ print()
157
+ print("Prompts")
158
+ print("─────────────────────────")
159
+ for pr in prompts:
160
+ desc = pr.description or ""
161
+ first_line = desc.split("\n")[0] if desc else ""
162
+ print(f"{pr.name:<15} {first_line}")
163
+
164
+
165
+ def inspect_command(file_path: str | None, tool_name: str | None = None) -> None:
166
+ """Inspect the server configuration, specific tool, resource, or prompt detail."""
167
+ try:
168
+ server, project = resolve_server_instance(file_path)
169
+ except ConfigurationError as e:
170
+ print(f"Error: {e}", file=sys.stderr)
171
+ sys.exit(1)
172
+
173
+ if tool_name is None:
174
+ print("MCPToolForge Project")
175
+ print("─────────────────────────")
176
+ if project is not None:
177
+ print(f"Name: {project.config.name}")
178
+ print(f"Root: {project.root}")
179
+ print(f"Entrypoint: {project.config.entrypoint}")
180
+ print(f"Transport: {project.config.transport}")
181
+ else:
182
+ print(f"Name: {server.name}")
183
+ print(f"Root: {Path.cwd()}")
184
+ print(f"Entrypoint: {Path(file_path or 'server.py').name}")
185
+ print("Transport: stdio")
186
+ print()
187
+ print("Tools")
188
+ print("─────────────────────────")
189
+ for tool in server.tools:
190
+ print(tool.name)
191
+
192
+ resources = server.list_resources()
193
+ if resources:
194
+ print()
195
+ print("Resources")
196
+ print("─────────────────────────")
197
+ for res in resources:
198
+ print(res.uri)
199
+
200
+ prompts = server.list_prompts()
201
+ if prompts:
202
+ print()
203
+ print("Prompts")
204
+ print("─────────────────────────")
205
+ for pr in prompts:
206
+ print(pr.name)
207
+ else:
208
+ # 1. Try to find a matching tool
209
+ target_tool = None
210
+ for tool in server.tools:
211
+ if tool.name == tool_name:
212
+ target_tool = tool
213
+ break
214
+
215
+ if target_tool is not None:
216
+ print(f"Tool: {target_tool.name}")
217
+ print()
218
+ print("Description:")
219
+ print(target_tool.description or "")
220
+ print()
221
+ print("Parameters:")
222
+ print()
223
+ for p_name, p in target_tool.parameters.items():
224
+ required_str = "yes" if p.required else "no"
225
+ type_name = getattr(p.annotation, "__name__", str(p.annotation))
226
+ print(f"{p_name}")
227
+ print(f" type: {type_name}")
228
+ print(f" required: {required_str}")
229
+ print()
230
+ print("Input Schema:")
231
+ print(json.dumps(target_tool.input_schema, indent=4))
232
+ return
233
+
234
+ # 2. Try to find a matching resource
235
+ target_resource = None
236
+ for res in server.list_resources():
237
+ if res.uri == tool_name:
238
+ target_resource = res
239
+ break
240
+
241
+ if target_resource is not None:
242
+ print(f"Resource: {target_resource.uri}")
243
+ print()
244
+ print(f" Name: {target_resource.name}")
245
+ print(f" MIME: {target_resource.mime_type or 'unspecified'}")
246
+ print(f" Description: {target_resource.description or ''}")
247
+ return
248
+
249
+ # 3. Try to find a matching prompt
250
+ target_prompt = None
251
+ for pr in server.list_prompts():
252
+ if pr.name == tool_name:
253
+ target_prompt = pr
254
+ break
255
+
256
+ if target_prompt is not None:
257
+ print(f"Prompt: {target_prompt.name}")
258
+ print()
259
+ print("Description:")
260
+ print(target_prompt.description or "")
261
+ print()
262
+ print("Arguments:")
263
+ for p_name, p in target_prompt.parameters.items():
264
+ required_str = "yes" if p.required else "no"
265
+ type_name = getattr(p.annotation, "__name__", str(p.annotation))
266
+ if type_name == "str":
267
+ type_name = "string"
268
+ print(f" {p_name}")
269
+ print(f" type: {type_name}")
270
+ print(f" required: {required_str}")
271
+ print()
272
+ return
273
+
274
+ print(
275
+ f"Error: tool, resource, or prompt '{tool_name}' is not registered.",
276
+ file=sys.stderr,
277
+ )
278
+ sys.exit(1)
@@ -0,0 +1,101 @@
1
+ import argparse
2
+ import importlib.metadata
3
+ import sys
4
+
5
+ from mcptoolforge.cli.commands import (
6
+ init_command,
7
+ inspect_command,
8
+ list_command,
9
+ run_command,
10
+ )
11
+
12
+
13
+ def get_version() -> str:
14
+ """Retrieve authoritative package version or fallback to 0.1.0."""
15
+ try:
16
+ return importlib.metadata.version("mcptoolforge")
17
+ except Exception:
18
+ return "0.1.0"
19
+
20
+
21
+ def main() -> None:
22
+ """Entrypoint parsing CLI commands."""
23
+ parser = argparse.ArgumentParser(
24
+ prog="mcptoolforge",
25
+ description="MCPToolForge\nBuild MCP-ready tools with minimal boilerplate.",
26
+ formatter_class=argparse.RawDescriptionHelpFormatter,
27
+ )
28
+
29
+ parser.add_argument(
30
+ "--version",
31
+ action="version",
32
+ version=f"mcptoolforge {get_version()}",
33
+ help="Show version information.",
34
+ )
35
+
36
+ subparsers = parser.add_subparsers(dest="command", title="Available commands")
37
+
38
+ # 1. init subcommand
39
+ init_parser = subparsers.add_parser("init", help="Initialize a new MCPToolForge project.")
40
+ init_parser.add_argument(
41
+ "directory",
42
+ nargs="?",
43
+ default=".",
44
+ help="Directory to initialize the project in. (default: current directory)",
45
+ )
46
+ init_parser.add_argument(
47
+ "--force",
48
+ action="store_true",
49
+ help="Force overwrite existing files in the destination directory.",
50
+ )
51
+
52
+ # 2. run subcommand
53
+ run_parser = subparsers.add_parser("run", help="Start the MCPToolForge MCP server.")
54
+ run_parser.add_argument(
55
+ "--file",
56
+ default=None,
57
+ help="Path to the python file containing the MCPServer instance. (default: server.py)",
58
+ )
59
+
60
+ # 3. list subcommand
61
+ list_parser = subparsers.add_parser("list", help="List registered tools locally.")
62
+ list_parser.add_argument(
63
+ "--file",
64
+ default=None,
65
+ help="Path to the python file containing the MCPServer instance. (default: server.py)",
66
+ )
67
+
68
+ # 4. inspect subcommand
69
+ inspect_parser = subparsers.add_parser(
70
+ "inspect", help="Inspect server or specific tool details."
71
+ )
72
+ inspect_parser.add_argument(
73
+ "tool_name",
74
+ nargs="?",
75
+ default=None,
76
+ help="Name of the specific tool to inspect.",
77
+ )
78
+ inspect_parser.add_argument(
79
+ "--file",
80
+ default=None,
81
+ help="Path to the python file containing the MCPServer instance. (default: server.py)",
82
+ )
83
+
84
+ args = parser.parse_args()
85
+
86
+ if args.command is None:
87
+ parser.print_help()
88
+ sys.exit(0)
89
+
90
+ if args.command == "init":
91
+ init_command(args.directory, args.force)
92
+ elif args.command == "run":
93
+ run_command(args.file)
94
+ elif args.command == "list":
95
+ list_command(args.file)
96
+ elif args.command == "inspect":
97
+ inspect_command(args.file, args.tool_name)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
mcptoolforge/config.py ADDED
@@ -0,0 +1,45 @@
1
+ from dataclasses import dataclass
2
+
3
+ from mcptoolforge.errors import InvalidConfigurationError
4
+
5
+
6
+ @dataclass
7
+ class MCPToolForgeConfig:
8
+ """Typed, validated representation of MCPToolForge project configuration."""
9
+
10
+ name: str
11
+ entrypoint: str = "server.py"
12
+ transport: str = "stdio"
13
+
14
+ def __post_init__(self) -> None:
15
+ # Validate name
16
+ if not isinstance(self.name, str) or not self.name.strip():
17
+ raise InvalidConfigurationError(
18
+ "MCPToolForge configuration 'name' must be a non-empty string."
19
+ )
20
+ self.name = self.name.strip()
21
+
22
+ # Validate entrypoint
23
+ if not isinstance(self.entrypoint, str) or not self.entrypoint.strip():
24
+ raise InvalidConfigurationError(
25
+ "MCPToolForge configuration 'entrypoint' must be a non-empty string."
26
+ )
27
+ self.entrypoint = self.entrypoint.strip()
28
+
29
+ if not self.entrypoint.endswith(".py"):
30
+ raise InvalidConfigurationError(
31
+ f"MCPToolForge entrypoint '{self.entrypoint}' is not a Python file."
32
+ )
33
+
34
+ # Validate transport
35
+ if not isinstance(self.transport, str) or not self.transport.strip():
36
+ raise InvalidConfigurationError(
37
+ "MCPToolForge configuration 'transport' must be a non-empty string."
38
+ )
39
+ self.transport = self.transport.strip()
40
+
41
+ if self.transport != "stdio":
42
+ raise InvalidConfigurationError(
43
+ f"Unsupported transport '{self.transport}' for MCPToolForge server. "
44
+ "Only 'stdio' is currently supported."
45
+ )
@@ -0,0 +1,49 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, TypeVar
3
+
4
+ from mcptoolforge.registry import Tool
5
+
6
+ F = TypeVar("F", bound=Callable[..., Any])
7
+
8
+
9
+ class ToolDecorator:
10
+ """Decorator to register functions as tools on a ToolRegistry.
11
+
12
+ Supports both direct decorator style:
13
+ @server.tool
14
+ def my_tool(): ...
15
+
16
+ And parameter style:
17
+ @server.tool(name="custom_name", description="custom_desc")
18
+ def my_tool(): ...
19
+ """
20
+
21
+ def __init__(self, registry: Any) -> None:
22
+ self._registry = registry
23
+
24
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
25
+ # Check if used as @tool directly
26
+ if len(args) == 1 and callable(args[0]) and not kwargs:
27
+ func = args[0]
28
+ tool = Tool(func)
29
+ self._registry.register(tool)
30
+ return func
31
+
32
+ # Used as @tool(name=..., description=...)
33
+ name: str | None = kwargs.get("name")
34
+ description: str | None = kwargs.get("description")
35
+ tags: list[str] | None = kwargs.get("tags")
36
+ metadata: dict[str, Any] | None = kwargs.get("metadata")
37
+
38
+ def decorator(func: F) -> F:
39
+ tool = Tool(
40
+ func,
41
+ name=name,
42
+ description=description,
43
+ tags=tags,
44
+ metadata=metadata,
45
+ )
46
+ self._registry.register(tool)
47
+ return func
48
+
49
+ return decorator
mcptoolforge/errors.py ADDED
@@ -0,0 +1,94 @@
1
+ class MCPToolForgeError(Exception):
2
+ """Base exception for all MCPToolForge errors."""
3
+
4
+ pass
5
+
6
+
7
+ class ToolRegistrationError(MCPToolForgeError):
8
+ """Raised when registering a tool fails."""
9
+
10
+ pass
11
+
12
+
13
+ class ToolAlreadyRegisteredError(ToolRegistrationError):
14
+ """Raised when trying to register a tool that is already registered."""
15
+
16
+ pass
17
+
18
+
19
+ class ToolNotFoundError(MCPToolForgeError):
20
+ """Raised when a tool is looked up but not found."""
21
+
22
+ pass
23
+
24
+
25
+ class ToolExecutionError(MCPToolForgeError):
26
+ """Raised when executing a tool fails."""
27
+
28
+ pass
29
+
30
+
31
+ class SchemaGenerationError(MCPToolForgeError):
32
+ """Raised when generating schema for a tool fails."""
33
+
34
+ pass
35
+
36
+
37
+ class ToolValidationError(MCPToolForgeError):
38
+ """Raised when validating tool arguments fails."""
39
+
40
+ pass
41
+
42
+
43
+ class ConfigurationError(MCPToolForgeError):
44
+ """Base exception for all configuration and project loading errors."""
45
+
46
+ pass
47
+
48
+
49
+ class ProjectNotFoundError(ConfigurationError):
50
+ """Raised when pyproject.toml is missing or does not contain a MCPToolForge configuration."""
51
+
52
+ pass
53
+
54
+
55
+ class InvalidConfigurationError(ConfigurationError):
56
+ """Raised when configuration values fail validation checks."""
57
+
58
+ pass
59
+
60
+
61
+ class EntrypointNotFoundError(ConfigurationError):
62
+ """Raised when the specified entrypoint cannot be located."""
63
+
64
+ pass
65
+
66
+
67
+ class MiddlewareError(MCPToolForgeError):
68
+ """Raised when middleware logic fails or raises an error."""
69
+
70
+ pass
71
+
72
+
73
+ class ResourceRegistrationError(MCPToolForgeError):
74
+ """Raised when registering a resource fails."""
75
+
76
+ pass
77
+
78
+
79
+ class ResourceAlreadyRegisteredError(ResourceRegistrationError):
80
+ """Raised when a resource with the same URI is already registered."""
81
+
82
+ pass
83
+
84
+
85
+ class ResourceNotFoundError(MCPToolForgeError):
86
+ """Raised when a resource is looked up but not found."""
87
+
88
+ pass
89
+
90
+
91
+ class ResourceExecutionError(MCPToolForgeError):
92
+ """Raised when executing/reading a resource fails."""
93
+
94
+ pass
@@ -0,0 +1,25 @@
1
+ import inspect
2
+ from typing import Any
3
+
4
+ from mcptoolforge.errors import ToolExecutionError
5
+ from mcptoolforge.registry import Tool
6
+
7
+
8
+ async def execute_tool(tool: Tool, arguments: dict[str, Any]) -> Any:
9
+ """Execute a tool asynchronously with the provided arguments.
10
+
11
+ If the underlying function is synchronous, it runs directly.
12
+ If it is a coroutine, it is awaited.
13
+ """
14
+ from mcptoolforge.validation import validate_tool_arguments
15
+
16
+ # Validate and normalize arguments
17
+ validated_args = validate_tool_arguments(tool, arguments)
18
+
19
+ try:
20
+ if inspect.iscoroutinefunction(tool.fn):
21
+ return await tool.fn(**validated_args)
22
+ else:
23
+ return tool.fn(**validated_args)
24
+ except Exception as e:
25
+ raise ToolExecutionError(f"Error executing tool '{tool.name}': {e}") from e
@@ -0,0 +1,4 @@
1
+ from mcptoolforge.mcp.adapter import MCPAdapter
2
+ from mcptoolforge.mcp.server import MCPServerRunner
3
+
4
+ __all__ = ["MCPAdapter", "MCPServerRunner"]