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,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
+ ]