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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from mcptoolforge.errors import ResourceRegistrationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_uri(uri: str) -> str:
|
|
10
|
+
"""Validate that the URI is a valid format and is non-empty."""
|
|
11
|
+
if not isinstance(uri, str):
|
|
12
|
+
raise ResourceRegistrationError("Resource URI must be a string.")
|
|
13
|
+
if not uri.strip():
|
|
14
|
+
raise ResourceRegistrationError("Resource URI cannot be empty or whitespace-only.")
|
|
15
|
+
parsed = urlparse(uri)
|
|
16
|
+
if not parsed.scheme or not (parsed.netloc or parsed.path):
|
|
17
|
+
raise ResourceRegistrationError(f"Invalid resource URI format: '{uri}'")
|
|
18
|
+
return uri.strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Resource:
|
|
22
|
+
"""Represents a registered resource in MCPToolForge."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
uri: str,
|
|
27
|
+
fn: Callable[..., Any],
|
|
28
|
+
name: str | None = None,
|
|
29
|
+
description: str | None = None,
|
|
30
|
+
mime_type: str | None = None,
|
|
31
|
+
):
|
|
32
|
+
if not callable(fn):
|
|
33
|
+
raise ResourceRegistrationError("Registered object must be a callable.")
|
|
34
|
+
|
|
35
|
+
self._uri = validate_uri(uri)
|
|
36
|
+
self._fn = fn
|
|
37
|
+
|
|
38
|
+
# Name validation
|
|
39
|
+
if name is not None:
|
|
40
|
+
if not isinstance(name, str):
|
|
41
|
+
raise ResourceRegistrationError("Resource name must be a string.")
|
|
42
|
+
if not name.strip():
|
|
43
|
+
raise ResourceRegistrationError("Resource name cannot be empty or whitespace-only.")
|
|
44
|
+
self._name = name.strip()
|
|
45
|
+
else:
|
|
46
|
+
self._name = fn.__name__
|
|
47
|
+
|
|
48
|
+
# Description validation
|
|
49
|
+
if description is not None:
|
|
50
|
+
if not isinstance(description, str):
|
|
51
|
+
raise ResourceRegistrationError("Resource description must be a string.")
|
|
52
|
+
if not description.strip():
|
|
53
|
+
raise ResourceRegistrationError(
|
|
54
|
+
"Resource description cannot be empty or whitespace-only."
|
|
55
|
+
)
|
|
56
|
+
self._description = description.strip()
|
|
57
|
+
else:
|
|
58
|
+
doc = inspect.getdoc(fn)
|
|
59
|
+
self._description = doc.strip() if doc else "No description provided."
|
|
60
|
+
|
|
61
|
+
# MIME type validation
|
|
62
|
+
if mime_type is not None:
|
|
63
|
+
if not isinstance(mime_type, str):
|
|
64
|
+
raise ResourceRegistrationError("Resource MIME type must be a string.")
|
|
65
|
+
if not mime_type.strip():
|
|
66
|
+
raise ResourceRegistrationError(
|
|
67
|
+
"Resource MIME type cannot be empty or whitespace-only."
|
|
68
|
+
)
|
|
69
|
+
self._mime_type = mime_type.strip()
|
|
70
|
+
else:
|
|
71
|
+
self._mime_type = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def uri(self) -> str:
|
|
75
|
+
return self._uri
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def fn(self) -> Callable[..., Any]:
|
|
79
|
+
return self._fn
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def name(self) -> str:
|
|
83
|
+
return self._name
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def description(self) -> str:
|
|
87
|
+
return self._description
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def mime_type(self) -> str | None:
|
|
91
|
+
return self._mime_type
|
|
92
|
+
|
|
93
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
94
|
+
return self._fn(*args, **kwargs)
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return f"Resource(uri={self.uri!r}, name={self.name!r}, mime_type={self.mime_type!r})"
|
mcptoolforge/schema.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import inspect
|
|
3
|
+
import types
|
|
4
|
+
import typing
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcptoolforge.errors import SchemaGenerationError
|
|
8
|
+
from mcptoolforge.registry import ToolParameter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _map_primitive_type(py_type: Any, param_name: str, tool_name: str) -> str:
|
|
12
|
+
"""Map standard Python primitive types to JSON Schema types."""
|
|
13
|
+
if py_type is int:
|
|
14
|
+
return "integer"
|
|
15
|
+
elif py_type is float:
|
|
16
|
+
return "number"
|
|
17
|
+
elif py_type is str:
|
|
18
|
+
return "string"
|
|
19
|
+
elif py_type is bool:
|
|
20
|
+
return "boolean"
|
|
21
|
+
else:
|
|
22
|
+
raise SchemaGenerationError(
|
|
23
|
+
f"Unsupported type '{py_type}' for parameter '{param_name}' in tool '{tool_name}'. "
|
|
24
|
+
"Currently supported types are: str, int, float, bool, list, dict, Enum, "
|
|
25
|
+
"and Union/Optional."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _map_type(ann: Any, param_name: str, tool_name: str) -> dict[str, Any]:
|
|
30
|
+
"""Recursively map Python type annotations to JSON Schema dictionaries."""
|
|
31
|
+
if ann is inspect.Parameter.empty or ann is Any:
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
# Handle None / NoneType
|
|
35
|
+
if ann is None or ann is type(None):
|
|
36
|
+
return {"type": "null"}
|
|
37
|
+
|
|
38
|
+
# Handle standard Unions and Python 3.10+ UnionTypes
|
|
39
|
+
origin = getattr(ann, "__origin__", None)
|
|
40
|
+
if origin is typing.Union or (hasattr(types, "UnionType") and isinstance(ann, types.UnionType)):
|
|
41
|
+
args = typing.get_args(ann)
|
|
42
|
+
is_nullable = type(None) in args
|
|
43
|
+
non_null_args = [arg for arg in args if arg is not type(None)]
|
|
44
|
+
|
|
45
|
+
mapped_schemas = [_map_type(arg, param_name, tool_name) for arg in non_null_args]
|
|
46
|
+
|
|
47
|
+
if len(mapped_schemas) == 1:
|
|
48
|
+
schema = mapped_schemas[0].copy()
|
|
49
|
+
if "type" in schema:
|
|
50
|
+
t = schema["type"]
|
|
51
|
+
if isinstance(t, list):
|
|
52
|
+
schema["type"] = [*t, "null"] if "null" not in t else t
|
|
53
|
+
else:
|
|
54
|
+
schema["type"] = [t, "null"]
|
|
55
|
+
else:
|
|
56
|
+
schema["type"] = "null"
|
|
57
|
+
return schema
|
|
58
|
+
else:
|
|
59
|
+
if is_nullable:
|
|
60
|
+
return {"anyOf": [*mapped_schemas, {"type": "null"}]}
|
|
61
|
+
else:
|
|
62
|
+
return {"anyOf": mapped_schemas}
|
|
63
|
+
|
|
64
|
+
# Handle List types (list[T], List[T])
|
|
65
|
+
if ann is list or origin is list or origin is list:
|
|
66
|
+
args = typing.get_args(ann)
|
|
67
|
+
if not args:
|
|
68
|
+
return {"type": "array"}
|
|
69
|
+
item_type = args[0]
|
|
70
|
+
items_schema = _map_type(item_type, param_name, tool_name)
|
|
71
|
+
return {"type": "array", "items": items_schema}
|
|
72
|
+
|
|
73
|
+
# Handle Dict types (dict[K, V], Dict[K, V])
|
|
74
|
+
if ann is dict or origin is dict or origin is dict:
|
|
75
|
+
args = typing.get_args(ann)
|
|
76
|
+
if not args:
|
|
77
|
+
return {"type": "object"}
|
|
78
|
+
key_type, val_type = args[0], args[1]
|
|
79
|
+
if key_type is not str:
|
|
80
|
+
raise SchemaGenerationError(
|
|
81
|
+
f"Dictionary keys must be 'str' type for parameter '{param_name}' "
|
|
82
|
+
f"in tool '{tool_name}'."
|
|
83
|
+
)
|
|
84
|
+
val_schema = _map_type(val_type, param_name, tool_name)
|
|
85
|
+
return {"type": "object", "additionalProperties": val_schema}
|
|
86
|
+
|
|
87
|
+
# Handle Enum types
|
|
88
|
+
if isinstance(ann, type) and issubclass(ann, enum.Enum):
|
|
89
|
+
values = [m.value for m in ann]
|
|
90
|
+
if not values:
|
|
91
|
+
raise SchemaGenerationError(
|
|
92
|
+
f"Empty enum class '{ann.__name__}' for parameter '{param_name}' "
|
|
93
|
+
f"in tool '{tool_name}'."
|
|
94
|
+
)
|
|
95
|
+
val_types = {type(v) for v in values}
|
|
96
|
+
if len(val_types) == 1:
|
|
97
|
+
val_type = next(iter(val_types))
|
|
98
|
+
json_type = _map_primitive_type(val_type, param_name, tool_name)
|
|
99
|
+
else:
|
|
100
|
+
json_type = "string" # Fallback for mixed types
|
|
101
|
+
return {"type": json_type, "enum": values}
|
|
102
|
+
|
|
103
|
+
# Standard primitive types
|
|
104
|
+
json_type = _map_primitive_type(ann, param_name, tool_name)
|
|
105
|
+
return {"type": json_type}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def generate_input_schema(parameters: dict[str, ToolParameter], tool_name: str) -> dict[str, Any]:
|
|
109
|
+
"""Generate the input JSON Schema from introspected Tool parameters."""
|
|
110
|
+
properties: dict[str, Any] = {}
|
|
111
|
+
required: list[str] = []
|
|
112
|
+
|
|
113
|
+
for param_name, param in parameters.items():
|
|
114
|
+
param_schema = _map_type(param.annotation, param_name, tool_name)
|
|
115
|
+
|
|
116
|
+
# Handle defaults
|
|
117
|
+
if param.default is not inspect.Parameter.empty:
|
|
118
|
+
param_schema["default"] = param.default
|
|
119
|
+
else:
|
|
120
|
+
required.append(param_name)
|
|
121
|
+
|
|
122
|
+
properties[param_name] = param_schema
|
|
123
|
+
|
|
124
|
+
input_schema = {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": properties,
|
|
127
|
+
}
|
|
128
|
+
if required:
|
|
129
|
+
input_schema["required"] = required
|
|
130
|
+
|
|
131
|
+
return input_schema
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def generate_tool_schema(func: Any, name: str, description: str) -> dict[str, Any]:
|
|
135
|
+
"""Generate a JSON Schema representation for a tool's parameters.
|
|
136
|
+
|
|
137
|
+
Preserves existing functionality by delegating to input schema generation.
|
|
138
|
+
"""
|
|
139
|
+
from mcptoolforge.registry import Tool
|
|
140
|
+
|
|
141
|
+
tool = Tool(func, name=name, description=description)
|
|
142
|
+
return {
|
|
143
|
+
"name": name,
|
|
144
|
+
"description": description,
|
|
145
|
+
"inputSchema": generate_input_schema(tool.parameters, name),
|
|
146
|
+
}
|
mcptoolforge/server.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from mcptoolforge.decorators import ToolDecorator
|
|
4
|
+
from mcptoolforge.prompts import Prompt, PromptDecorator, PromptRegistry
|
|
5
|
+
from mcptoolforge.registry import Tool, ToolRegistry
|
|
6
|
+
from mcptoolforge.resources import Resource, ResourceDecorator, ResourceRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MCPServer:
|
|
10
|
+
"""Core MCPServer abstraction class for registering and exposing tools."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, name: str) -> None:
|
|
13
|
+
self.name = name
|
|
14
|
+
self.registry = ToolRegistry()
|
|
15
|
+
self.tool = ToolDecorator(self.registry)
|
|
16
|
+
self.resource_registry = ResourceRegistry()
|
|
17
|
+
self.resource = ResourceDecorator(self.resource_registry)
|
|
18
|
+
self.prompt_registry = PromptRegistry()
|
|
19
|
+
self.prompt = PromptDecorator(self.prompt_registry)
|
|
20
|
+
self.middlewares: list[Callable] = []
|
|
21
|
+
self.startup_hooks: list[Callable] = []
|
|
22
|
+
self.shutdown_hooks: list[Callable] = []
|
|
23
|
+
|
|
24
|
+
def middleware(self, fn: Callable) -> Callable:
|
|
25
|
+
"""Decorator to register a middleware function."""
|
|
26
|
+
self.middlewares.append(fn)
|
|
27
|
+
return fn
|
|
28
|
+
|
|
29
|
+
def add_middleware(self, fn: Callable) -> None:
|
|
30
|
+
"""Programmatically register a middleware function."""
|
|
31
|
+
self.middlewares.append(fn)
|
|
32
|
+
|
|
33
|
+
def on_startup(self, fn: Callable) -> Callable:
|
|
34
|
+
"""Decorator to register a startup lifecycle hook."""
|
|
35
|
+
self.startup_hooks.append(fn)
|
|
36
|
+
return fn
|
|
37
|
+
|
|
38
|
+
def on_shutdown(self, fn: Callable) -> Callable:
|
|
39
|
+
"""Decorator to register a shutdown lifecycle hook."""
|
|
40
|
+
self.shutdown_hooks.append(fn)
|
|
41
|
+
return fn
|
|
42
|
+
|
|
43
|
+
def run(self) -> None:
|
|
44
|
+
"""Run the MCP server over STDIO transport."""
|
|
45
|
+
import anyio
|
|
46
|
+
|
|
47
|
+
from mcptoolforge.mcp.server import MCPServerRunner
|
|
48
|
+
|
|
49
|
+
runner = MCPServerRunner(self.name, self.registry, server=self)
|
|
50
|
+
anyio.run(runner.run_async)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def tools(self) -> list[Tool]:
|
|
54
|
+
"""Return a list of all registered tools on this server."""
|
|
55
|
+
return self.registry.list()
|
|
56
|
+
|
|
57
|
+
def get_tool(self, name: str) -> Tool:
|
|
58
|
+
"""Retrieve a registered tool by name."""
|
|
59
|
+
return self.registry.get(name)
|
|
60
|
+
|
|
61
|
+
def has_tool(self, name: str) -> bool:
|
|
62
|
+
"""Check if a tool is registered by name."""
|
|
63
|
+
return self.registry.contains(name)
|
|
64
|
+
|
|
65
|
+
def list_tools(self) -> list[Tool]:
|
|
66
|
+
"""Return a list of all registered tools."""
|
|
67
|
+
return self.registry.list()
|
|
68
|
+
|
|
69
|
+
def get_resource(self, uri: str) -> Resource:
|
|
70
|
+
"""Retrieve a registered resource by URI."""
|
|
71
|
+
return self.resource_registry.get(uri)
|
|
72
|
+
|
|
73
|
+
def has_resource(self, uri: str) -> bool:
|
|
74
|
+
"""Check if a resource is registered by URI."""
|
|
75
|
+
return self.resource_registry.contains(uri)
|
|
76
|
+
|
|
77
|
+
def list_resources(self) -> list[Resource]:
|
|
78
|
+
"""Return a list of all registered resources."""
|
|
79
|
+
return self.resource_registry.list()
|
|
80
|
+
|
|
81
|
+
def get_prompt(self, name: str) -> Prompt:
|
|
82
|
+
"""Retrieve a registered prompt by name."""
|
|
83
|
+
return self.prompt_registry.get(name)
|
|
84
|
+
|
|
85
|
+
def has_prompt(self, name: str) -> bool:
|
|
86
|
+
"""Check if a prompt is registered by name."""
|
|
87
|
+
return self.prompt_registry.contains(name)
|
|
88
|
+
|
|
89
|
+
def list_prompts(self) -> list[Prompt]:
|
|
90
|
+
"""Return a list of all registered prompts."""
|
|
91
|
+
return self.prompt_registry.list()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from mcptoolforge.testing.client import (
|
|
2
|
+
MCPTestClient,
|
|
3
|
+
MCPToolForgeTestingError,
|
|
4
|
+
PromptGetResult,
|
|
5
|
+
PromptMessageResult,
|
|
6
|
+
ResourceReadResult,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"MCPTestClient",
|
|
11
|
+
"MCPToolForgeTestingError",
|
|
12
|
+
"PromptGetResult",
|
|
13
|
+
"PromptMessageResult",
|
|
14
|
+
"ResourceReadResult",
|
|
15
|
+
]
|