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/__init__.py
ADDED
|
@@ -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)
|
mcptoolforge/cli/main.py
ADDED
|
@@ -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
|