chuk-mcp-runtime 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,11 @@
1
+ # chuk_mcp_runtime/__init__.py
2
+ """
3
+ CHUK MCP Runtime Package
4
+
5
+ This package provides a runtime for CHUK MCP (Messaging Control Protocol) servers.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ # Import main functions for convenience
11
+ from chuk_mcp_runtime.main import run_server, main
File without changes
@@ -0,0 +1,32 @@
1
+ # chuk_mcp_runtime/common/errors.py
2
+ """
3
+ Error classes for the CHUK MCP runtime.
4
+
5
+ This module defines standard error classes used across the CHUK MCP runtime.
6
+ """
7
+
8
+ class ChukMcpRuntimeError(Exception):
9
+ """Base exception class for all CHUK MCP runtime errors."""
10
+ def __init__(self, message: str):
11
+ self.message = message
12
+ super().__init__(message)
13
+
14
+ class ConfigurationError(ChukMcpRuntimeError):
15
+ """Exception raised for configuration errors."""
16
+ pass
17
+
18
+ class ImportError(ChukMcpRuntimeError):
19
+ """Exception raised for errors during module importing."""
20
+ pass
21
+
22
+ class ToolExecutionError(ChukMcpRuntimeError):
23
+ """Exception raised for errors during tool execution."""
24
+ pass
25
+
26
+ class ServerError(ChukMcpRuntimeError):
27
+ """Exception raised for server errors."""
28
+ pass
29
+
30
+ class ValidationError(ChukMcpRuntimeError):
31
+ """Exception raised for validation errors."""
32
+ pass
@@ -0,0 +1,186 @@
1
+ # chuk_mcp_runtime/common/mcp_tool_decorator.py
2
+ """
3
+ CHUK MCP Tool Decorator Module
4
+
5
+ This module provides decorators for registering functions as CHUK MCP tools
6
+ with automatic input schema generation based on function signatures.
7
+ """
8
+ import inspect
9
+ from functools import wraps
10
+ from typing import Dict, Any, Callable, Optional, Type, get_type_hints
11
+ import logging
12
+
13
+ # We'll try to import pydantic, but provide fallbacks if it's not available
14
+ try:
15
+ from pydantic import create_model
16
+ HAS_PYDANTIC = True
17
+ except ImportError:
18
+ HAS_PYDANTIC = False
19
+ logging.getLogger("chuk_mcp_runtime.tools").warning(
20
+ "Pydantic not available, using fallback schema generation"
21
+ )
22
+
23
+ # Import MCP types, with fallback if not available
24
+ try:
25
+ from mcp.types import Tool
26
+ HAS_MCP_TYPES = True
27
+ except ImportError:
28
+ HAS_MCP_TYPES = False
29
+ logging.getLogger("chuk_mcp_runtime.tools").warning(
30
+ "MCP types not available, using fallback Tool class"
31
+ )
32
+
33
+ # Fallback Tool class for when MCP types are not available
34
+ class Tool:
35
+ def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
36
+ self.name = name
37
+ self.description = description
38
+ self.inputSchema = inputSchema
39
+
40
+ # Global registry for MCP tool functions
41
+ TOOLS_REGISTRY = {}
42
+
43
+ def _get_type_schema(annotation: Type) -> Dict[str, Any]:
44
+ """
45
+ Convert a Python type annotation to a JSON Schema type.
46
+
47
+ Args:
48
+ annotation: Type annotation to convert.
49
+
50
+ Returns:
51
+ JSON Schema representation of the type.
52
+ """
53
+ # Map Python types to JSON Schema types
54
+ if annotation == str:
55
+ return {"type": "string"}
56
+ elif annotation == int:
57
+ return {"type": "integer"}
58
+ elif annotation == float:
59
+ return {"type": "number"}
60
+ elif annotation == bool:
61
+ return {"type": "boolean"}
62
+ elif annotation == list or getattr(annotation, "__origin__", None) == list:
63
+ # Handle List[...] type
64
+ return {"type": "array"}
65
+ elif annotation == dict or getattr(annotation, "__origin__", None) == dict:
66
+ # Handle Dict[...] type
67
+ return {"type": "object"}
68
+ # Add more type mappings as needed
69
+ return {"type": "string"} # Default to string
70
+
71
+ def create_input_schema(func: Callable) -> Dict[str, Any]:
72
+ """
73
+ Create a JSON Schema for the function's parameters.
74
+
75
+ Args:
76
+ func: The function to create a schema for.
77
+
78
+ Returns:
79
+ A JSON Schema object representing the function's parameters.
80
+ """
81
+ sig = inspect.signature(func)
82
+
83
+ if HAS_PYDANTIC:
84
+ # Use Pydantic for schema generation if available
85
+ fields = {}
86
+ for param in sig.parameters.values():
87
+ annotation = param.annotation if param.annotation != inspect.Parameter.empty else str
88
+ fields[param.name] = (annotation, ...)
89
+
90
+ InputModel = create_model(f"{func.__name__.capitalize()}Input", **fields)
91
+ return InputModel.model_json_schema()
92
+ else:
93
+ # Fallback schema generation without Pydantic
94
+ properties = {}
95
+ required = []
96
+
97
+ type_hints = get_type_hints(func)
98
+
99
+ for param in sig.parameters.values():
100
+ if param.name == 'self':
101
+ continue
102
+
103
+ # Get type annotation if available
104
+ annotation = type_hints.get(param.name, str)
105
+
106
+ # Add to properties
107
+ properties[param.name] = _get_type_schema(annotation)
108
+
109
+ # Add required if no default value
110
+ if param.default == inspect.Parameter.empty:
111
+ required.append(param.name)
112
+
113
+ return {
114
+ "type": "object",
115
+ "properties": properties,
116
+ "required": required
117
+ }
118
+
119
+ def mcp_tool(name: str = None, description: str = None):
120
+ """
121
+ Decorator to register an MCP tool function and auto-generate its input JSON schema.
122
+
123
+ Args:
124
+ name: Name of the tool. If None, uses the function name.
125
+ description: Description of the tool. If None, uses the function's docstring.
126
+
127
+ Returns:
128
+ Decorated function with _mcp_tool attribute.
129
+ """
130
+ def decorator(func):
131
+ # Use function name if name not provided
132
+ tool_name = name or func.__name__
133
+
134
+ # Use docstring if description not provided
135
+ tool_description = description
136
+ if tool_description is None and func.__doc__:
137
+ tool_description = func.__doc__.strip()
138
+ elif tool_description is None:
139
+ tool_description = f"Tool: {tool_name}"
140
+
141
+ # Create input schema based on function signature
142
+ input_schema = create_input_schema(func)
143
+
144
+ # Create Tool instance
145
+ tool = Tool(
146
+ name=tool_name,
147
+ description=tool_description,
148
+ inputSchema=input_schema
149
+ )
150
+
151
+ # Register the function in the global registry.
152
+ TOOLS_REGISTRY[tool_name] = func
153
+
154
+ # Attach the tool metadata to the function for introspection.
155
+ func._mcp_tool = tool
156
+
157
+ @wraps(func)
158
+ def wrapper(*args, **kwargs):
159
+ return func(*args, **kwargs)
160
+ return wrapper
161
+ return decorator
162
+
163
+ def register_tools_from_module(module):
164
+ """
165
+ Scan a module for functions decorated with @mcp_tool and register them.
166
+
167
+ Args:
168
+ module: The module to scan for tools.
169
+
170
+ Returns:
171
+ Dictionary of registered tools from the module.
172
+ """
173
+ module_tools = {}
174
+
175
+ for name in dir(module):
176
+ item = getattr(module, name)
177
+ if callable(item) and hasattr(item, '_mcp_tool'):
178
+ tool_name = item._mcp_tool.name
179
+ TOOLS_REGISTRY[tool_name] = item
180
+ module_tools[tool_name] = item
181
+
182
+ return module_tools
183
+
184
+ def clear_tools_registry():
185
+ """Clear the global tools registry."""
186
+ TOOLS_REGISTRY.clear()
@@ -0,0 +1,76 @@
1
+ # chuk_mcp_runtime/main.py
2
+ import os
3
+ import sys
4
+ import asyncio
5
+ from typing import Dict, Any, Optional, List
6
+
7
+ from chuk_mcp_runtime.server.config_loader import load_config, find_project_root
8
+ from chuk_mcp_runtime.server.logging_config import get_logger, configure_logging
9
+ from chuk_mcp_runtime.server.server_registry import ServerRegistry
10
+ from chuk_mcp_runtime.server.server import MCPServer
11
+ from chuk_mcp_runtime.common.errors import ChukMcpRuntimeError
12
+
13
+ async def run_server(config_paths: Optional[List[str]] = None,
14
+ default_config: Optional[Dict[str, Any]] = None) -> None:
15
+ """
16
+ Initialize and run an MCP server with the given configuration.
17
+
18
+ Args:
19
+ config_paths: List of paths to search for configuration files.
20
+ default_config: Default configuration to use if no config file is found.
21
+
22
+ Raises:
23
+ ConfigurationError: If there is an error in the configuration.
24
+ """
25
+ try:
26
+ # Load configuration
27
+ config = load_config(config_paths, default_config)
28
+
29
+ # Configure logging
30
+ configure_logging(config)
31
+ logger = get_logger("chuk_mcp_runtime")
32
+
33
+ # Find project root
34
+ project_root = find_project_root()
35
+
36
+ # Initialize server registry
37
+ logger.info("Initializing server registry")
38
+ server_registry = ServerRegistry(project_root, config)
39
+
40
+ # Load server components
41
+ logger.info("Loading server components")
42
+ loaded_modules = server_registry.load_server_components()
43
+
44
+ # Initialize MCP server
45
+ logger.info("Initializing MCP server")
46
+ mcp_server = MCPServer(config)
47
+
48
+ # Run server
49
+ logger.info("Starting MCP server")
50
+ await mcp_server.serve()
51
+
52
+ except Exception as e:
53
+ logger = get_logger("chuk_mcp_runtime")
54
+ logger.error(f"Error starting CHUK MCP server: {e}", exc_info=True)
55
+ raise ChukMcpRuntimeError(f"Error starting CHUK MCP server: {e}")
56
+
57
+ def main():
58
+ """
59
+ Entry point for the MCP runtime command-line script.
60
+ """
61
+ try:
62
+ # Get configuration path from environment or command line
63
+ config_path = os.environ.get("CHUK_MCP_CONFIG_PATH")
64
+ if len(sys.argv) > 1:
65
+ config_path = sys.argv[1]
66
+
67
+ config_paths = [config_path] if config_path else None
68
+
69
+ # Run the server
70
+ asyncio.run(run_server(config_paths))
71
+ except Exception as e:
72
+ print(f"Error starting CHUK MCP server: {e}", file=sys.stderr)
73
+ sys.exit(1)
74
+
75
+ if __name__ == "__main__":
76
+ main()
File without changes
@@ -0,0 +1,132 @@
1
+ # chuk_mcp_runtime/server/config_loader.py
2
+ """
3
+ Configuration Loader Module
4
+
5
+ This module provides functionality to load and manage
6
+ configuration files for CHUK MCP servers from multiple potential locations.
7
+ """
8
+ import os
9
+ import yaml
10
+ import logging
11
+ from typing import Dict, Any, List, Optional
12
+
13
+ # logger
14
+ logger = logging.getLogger("chuk_mcp_runtime.config")
15
+
16
+ def load_config(config_paths: Optional[List[str]] = None,
17
+ default_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18
+ """
19
+ Load server configuration from config files.
20
+
21
+ Args:
22
+ config_paths: List of paths to search for config files. If None, uses default search paths.
23
+ default_config: Default configuration to use if no config file is found.
24
+
25
+ Returns:
26
+ A dictionary containing the configuration settings.
27
+ """
28
+ if config_paths is None:
29
+ # Default search paths relative to current working directory
30
+ config_paths = [
31
+ os.path.join(os.getcwd(), "config.yaml"),
32
+ os.path.join(os.getcwd(), "config.yml"),
33
+ os.environ.get("CHUK_MCP_CONFIG_PATH", ""),
34
+ ]
35
+
36
+ # Add config in package directory if run from package
37
+ package_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
38
+ config_paths.append(os.path.join(package_dir, "config.yaml"))
39
+
40
+ # Filter out empty paths
41
+ config_paths = [p for p in config_paths if p]
42
+
43
+ # Try to load from each path
44
+ for path in config_paths:
45
+ if os.path.exists(path):
46
+ logger.info(f"Loading configuration from {path}")
47
+ try:
48
+ with open(path, 'r') as f:
49
+ config = yaml.safe_load(f)
50
+ if config is None: # Empty YAML file
51
+ continue
52
+ return config
53
+ except Exception as e:
54
+ logger.warning(f"Error loading config from {path}: {e}")
55
+
56
+ # Use default configuration if provided or create a minimal one
57
+ if default_config is None:
58
+ logger.warning("No configuration file found, using minimal default configuration")
59
+ default_config = {
60
+ "host": {
61
+ "name": "generic-mcp-server",
62
+ "log_level": "INFO"
63
+ },
64
+ "mcp_servers": {}
65
+ }
66
+ else:
67
+ logger.warning("No configuration file found, using provided default configuration")
68
+
69
+ return default_config
70
+
71
+ def find_project_root(start_dir: Optional[str] = None) -> str:
72
+ """
73
+ Find the project root directory by looking for markers like config.yaml,
74
+ pyproject.toml, etc.
75
+
76
+ Args:
77
+ start_dir: Directory to start the search from. If None, uses current directory.
78
+
79
+ Returns:
80
+ Absolute path to the project root directory.
81
+ """
82
+ if start_dir is None:
83
+ start_dir = os.getcwd()
84
+
85
+ current_dir = os.path.abspath(start_dir)
86
+
87
+ # Markers that indicate a project root
88
+ markers = ['config.yaml', 'config.yml', 'pyproject.toml', 'setup.py']
89
+
90
+ # Maximum depth to search up
91
+ max_depth = 10
92
+ depth = 0
93
+
94
+ while depth < max_depth:
95
+ # Check if any markers exist in current directory
96
+ if any(os.path.exists(os.path.join(current_dir, marker)) for marker in markers):
97
+ return current_dir
98
+
99
+ # Go up one directory
100
+ parent_dir = os.path.dirname(current_dir)
101
+ if parent_dir == current_dir: # Reached the filesystem root
102
+ break
103
+
104
+ current_dir = parent_dir
105
+ depth += 1
106
+
107
+ # If no project root found, return the starting directory
108
+ logger.warning(f"No project root markers found, using {start_dir} as project root")
109
+ return os.path.abspath(start_dir)
110
+
111
+ def get_config_value(config: Dict[str, Any], path: str, default: Any = None) -> Any:
112
+ """
113
+ Get a value from a nested configuration dictionary using a dot-separated path.
114
+
115
+ Args:
116
+ config: Configuration dictionary.
117
+ path: Dot-separated path to the value (e.g., "host.name").
118
+ default: Default value to return if the path is not found.
119
+
120
+ Returns:
121
+ The value at the specified path, or the default value if not found.
122
+ """
123
+ keys = path.split('.')
124
+ result = config
125
+
126
+ for key in keys:
127
+ if isinstance(result, dict) and key in result:
128
+ result = result[key]
129
+ else:
130
+ return default
131
+
132
+ return result
@@ -0,0 +1,86 @@
1
+ # chuk_mcp_runtime/server/logging_config.py
2
+ """
3
+ Logging configuration module for CHUK MCP servers.
4
+
5
+ This module sets up a shared logger with configurable logging levels,
6
+ formats, and output handlers based on configuration.
7
+ """
8
+ import os
9
+ import sys
10
+ import logging
11
+ from logging import Logger
12
+ from typing import Dict, Any, Optional
13
+
14
+ def configure_logging(config: Dict[str, Any] = None) -> None:
15
+ """
16
+ Configure the root logger based on the provided configuration.
17
+
18
+ Args:
19
+ config: Configuration dictionary containing logging settings.
20
+ """
21
+ # Extract logging configuration
22
+ log_config = config.get("logging", {}) if config else {}
23
+
24
+ # Determine log level from config or environment
25
+ log_level_name = log_config.get("level", os.getenv("CHUK_MCP_LOG_LEVEL", "INFO"))
26
+ log_level = getattr(logging, log_level_name.upper(), logging.INFO)
27
+
28
+ # Configure root logger
29
+ root_logger = logging.getLogger()
30
+ root_logger.setLevel(log_level)
31
+
32
+ # Clear existing handlers if requested
33
+ if log_config.get("reset_handlers", False):
34
+ for handler in root_logger.handlers[:]:
35
+ root_logger.removeHandler(handler)
36
+
37
+ # Add handlers if none exist
38
+ if not root_logger.handlers:
39
+ handler = logging.StreamHandler(sys.stdout)
40
+
41
+ # Set formatter
42
+ log_format = log_config.get("format",
43
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
44
+ formatter = logging.Formatter(log_format)
45
+ handler.setFormatter(formatter)
46
+
47
+ root_logger.addHandler(handler)
48
+
49
+ # Set library loggers to higher level to reduce noise
50
+ if log_config.get("quiet_libraries", True):
51
+ for lib in ["urllib3", "requests", "asyncio"]:
52
+ logging.getLogger(lib).setLevel(logging.WARNING)
53
+
54
+ def get_logger(name: str = None, config: Dict[str, Any] = None) -> Logger:
55
+ """
56
+ Get a configured logger with the specified name.
57
+
58
+ Args:
59
+ name: The name of the logger. If None, uses the calling module's name.
60
+ config: Optional configuration dictionary.
61
+
62
+ Returns:
63
+ A logger instance.
64
+ """
65
+ # If name is None, try to infer from caller's module
66
+ if name is None:
67
+ import inspect
68
+ frame = inspect.currentframe().f_back
69
+ module = inspect.getmodule(frame)
70
+ name = module.__name__ if module else "chuk_mcp_runtime"
71
+
72
+ # Ensure our base prefix is in the name
73
+ if not name.startswith("chuk_mcp_runtime"):
74
+ name = f"chuk_mcp_runtime.{name}"
75
+
76
+ # Configure global logging if config is provided
77
+ if config:
78
+ configure_logging(config)
79
+
80
+ # Get logger
81
+ logger = logging.getLogger(name)
82
+
83
+ return logger
84
+
85
+ # Create a default logger for the module
86
+ logger = get_logger("chuk_mcp_runtime.logging")
@@ -0,0 +1,194 @@
1
+ # chuk_mcp_runtime/server/server.py
2
+ """
3
+ CHUK MCP Server Module
4
+
5
+ This module provides the core CHUK MCP server functionality for
6
+ running tools and managing server operations.
7
+ """
8
+ import asyncio
9
+ import json
10
+ import importlib
11
+ from typing import Dict, Any, List, Optional, Union, Callable
12
+
13
+ # MCP imports (assuming these are from an external package)
14
+ from mcp.server import Server
15
+ from mcp.server.stdio import stdio_server
16
+ from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
17
+
18
+ # Local imports
19
+ from chuk_mcp_runtime.server.logging_config import get_logger
20
+
21
+ class MCPServer:
22
+ """
23
+ Manages the MCP (Messaging Control Protocol) server operations.
24
+
25
+ Handles tool discovery, registration, and execution.
26
+ """
27
+ def __init__(self, config: Dict[str, Any], tools_registry: Optional[Dict[str, Callable]] = None):
28
+ """
29
+ Initialize the MCP server.
30
+
31
+ Args:
32
+ config: Configuration dictionary for the server.
33
+ tools_registry: Optional registry of tools to use instead of importing.
34
+ """
35
+ self.config = config
36
+
37
+ # Initialize logger
38
+ self.logger = get_logger("chuk_mcp_runtime.server", config)
39
+
40
+ # Server name from configuration
41
+ self.server_name = config.get("host", {}).get("name", "generic-mcp")
42
+
43
+ # Tools registry
44
+ self.tools_registry = tools_registry or self._import_tools_registry()
45
+
46
+ def _import_tools_registry(self) -> Dict[str, Callable]:
47
+ """
48
+ Dynamically import the tools registry.
49
+
50
+ Returns:
51
+ Dictionary of available tools.
52
+ """
53
+ # Get registry module path from config
54
+ registry_module_path = self.config.get("tools", {}).get(
55
+ "registry_module", "chuk_mcp_runtime.common.mcp_tool_decorator"
56
+ )
57
+ registry_attr = self.config.get("tools", {}).get(
58
+ "registry_attr", "TOOLS_REGISTRY"
59
+ )
60
+
61
+ try:
62
+ tools_decorator_module = importlib.import_module(registry_module_path)
63
+ tools_registry = getattr(tools_decorator_module, registry_attr, {})
64
+ except (ImportError, AttributeError) as e:
65
+ self.logger.error(f"Failed to import TOOLS_REGISTRY from {registry_module_path}: {e}")
66
+ tools_registry = {}
67
+
68
+ if not tools_registry:
69
+ self.logger.warning("No tools available")
70
+ else:
71
+ self.logger.info(f"Loaded {len(tools_registry)} tools")
72
+ self.logger.debug(f"Available tools: {', '.join(tools_registry.keys())}")
73
+
74
+ return tools_registry
75
+
76
+ async def serve(self, custom_handlers: Optional[Dict[str, Callable]] = None) -> None:
77
+ """
78
+ Run the MCP server with stdio communication.
79
+
80
+ Sets up server, tool listing, and tool execution handlers.
81
+
82
+ Args:
83
+ custom_handlers: Optional dictionary of custom handlers to add to the server.
84
+ """
85
+ # Create MCP server instance
86
+ server = Server(self.server_name)
87
+
88
+ @server.list_tools()
89
+ async def list_tools() -> List[Tool]:
90
+ """
91
+ List available tools.
92
+
93
+ Returns:
94
+ List of tool descriptions.
95
+ """
96
+ if not self.tools_registry:
97
+ self.logger.warning("No tools available")
98
+ return []
99
+
100
+ tool_list = []
101
+ for func in self.tools_registry.values():
102
+ if hasattr(func, '_mcp_tool'):
103
+ tool_list.append(func._mcp_tool)
104
+
105
+ return tool_list
106
+
107
+ @server.call_tool()
108
+ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[Union[TextContent, ImageContent, EmbeddedResource]]:
109
+ """
110
+ Execute a specific tool with given arguments.
111
+
112
+ Args:
113
+ name: Name of the tool to execute.
114
+ arguments: Arguments for the tool.
115
+
116
+ Returns:
117
+ List of content resulting from tool execution.
118
+
119
+ Raises:
120
+ ValueError: If tool is not found or fails to execute.
121
+ """
122
+ if name not in self.tools_registry:
123
+ raise ValueError(f"Tool not found: {name}")
124
+
125
+ func = self.tools_registry[name]
126
+ try:
127
+ self.logger.info(f"Executing tool '{name}' with arguments: {arguments}")
128
+ result = func(**arguments)
129
+
130
+ # If result is already a list of content objects, return as is
131
+ if isinstance(result, list) and all(isinstance(item, (TextContent, ImageContent, EmbeddedResource))
132
+ for item in result):
133
+ return result
134
+
135
+ # For simple text results, wrap in TextContent
136
+ if isinstance(result, str):
137
+ return [TextContent(type="text", text=result)]
138
+
139
+ # Otherwise, serialize to JSON
140
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
141
+
142
+ except Exception as e:
143
+ self.logger.error(f"Error processing tool '{name}': {e}", exc_info=True)
144
+ raise ValueError(f"Error processing tool '{name}': {str(e)}")
145
+
146
+ # Add custom handlers if provided
147
+ if custom_handlers:
148
+ for handler_name, handler_func in custom_handlers.items():
149
+ self.logger.info(f"Adding custom handler: {handler_name}")
150
+ setattr(server, handler_name, handler_func)
151
+
152
+ # Create initialization options
153
+ options = server.create_initialization_options()
154
+
155
+ # Run server based on configuration
156
+ server_type = self.config.get("server", {}).get("type", "stdio")
157
+
158
+ if server_type == "stdio":
159
+ self.logger.info("Starting stdio server")
160
+ async with stdio_server() as (read_stream, write_stream):
161
+ await server.run(read_stream, write_stream, options)
162
+ elif server_type == "websocket":
163
+ # Example for websocket server - replace with actual implementation
164
+ ws_host = self.config.get("server", {}).get("host", "localhost")
165
+ ws_port = self.config.get("server", {}).get("port", 8080)
166
+ self.logger.info(f"Starting WebSocket server on {ws_host}:{ws_port}")
167
+ # Implement WebSocket server here
168
+ raise NotImplementedError("WebSocket server not implemented yet")
169
+ else:
170
+ raise ValueError(f"Unknown server type: {server_type}")
171
+
172
+ def register_tool(self, name: str, func: Callable) -> None:
173
+ """
174
+ Register a tool function with the server.
175
+
176
+ Args:
177
+ name: Name of the tool.
178
+ func: Function to register.
179
+ """
180
+ if not hasattr(func, '_mcp_tool'):
181
+ self.logger.warning(f"Function {func.__name__} does not have _mcp_tool attribute")
182
+ return
183
+
184
+ self.tools_registry[name] = func
185
+ self.logger.info(f"Registered tool: {name}")
186
+
187
+ def get_tool_names(self) -> List[str]:
188
+ """
189
+ Get names of all registered tools.
190
+
191
+ Returns:
192
+ List of tool names.
193
+ """
194
+ return list(self.tools_registry.keys())
@@ -0,0 +1,195 @@
1
+ # chuk_mcp_runtime/server/server_registry.py
2
+ """
3
+ Server Registry Module for CHUK MCP Tool Servers
4
+
5
+ This module provides a ServerRegistry class for managing
6
+ CHUK MCP tool servers and their components.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import importlib
12
+ from typing import List, Dict, Any, Tuple, Optional
13
+
14
+ from chuk_mcp_runtime.server.logging_config import get_logger
15
+
16
+ class ServerRegistry:
17
+ """Registry for managing MCP tool servers with components"""
18
+ def __init__(self, project_root: str, config: Dict[str, Any]):
19
+ """
20
+ Initialize the server registry.
21
+
22
+ Args:
23
+ project_root: The root directory of the project.
24
+ config: Configuration dictionary.
25
+ """
26
+ self.project_root = project_root
27
+ self.config = config
28
+
29
+ # Initialize logger
30
+ self.logger = get_logger("chuk_mcp_runtime.server_registry", config)
31
+
32
+ self.server_paths, self.components = self._setup_server_paths()
33
+ self._setup_python_paths()
34
+ self.loaded_modules = {}
35
+
36
+ def _setup_server_paths(self) -> Tuple[Dict[str, str], Dict[str, List[Dict[str, Any]]]]:
37
+ """
38
+ Process server configurations and resolve paths.
39
+
40
+ Returns:
41
+ Tuple of (server_paths, components) dictionaries.
42
+ """
43
+ server_paths = {}
44
+ components = {}
45
+
46
+ # Add core paths
47
+ core_paths = self.config.get("core", {})
48
+ for name, location in core_paths.items():
49
+ full_path = os.path.join(self.project_root, location)
50
+ # Always add path, even if it doesn't exist
51
+ server_paths[name] = full_path
52
+
53
+ if not os.path.exists(full_path):
54
+ self.logger.warning(f"Core path does not exist: {full_path}")
55
+
56
+ # Process MCP servers
57
+ mcp_servers = self.config.get("mcp_servers", {})
58
+ for server_name, server_config in mcp_servers.items():
59
+ if isinstance(server_config, dict) and server_config.get("enabled", True):
60
+ location = server_config.get("location")
61
+ if location:
62
+ # Handle both absolute and relative paths
63
+ if os.path.isabs(location):
64
+ full_path = location
65
+ else:
66
+ full_path = os.path.join(self.project_root, location)
67
+
68
+ # Always add server path
69
+ server_paths[server_name] = full_path
70
+ components[server_name] = []
71
+
72
+ # Process components (tools, resources, prompts)
73
+ self._add_component(server_name, server_config, "tools", components)
74
+ self._add_component(server_name, server_config, "resources", components)
75
+ self._add_component(server_name, server_config, "prompts", components)
76
+
77
+ if not os.path.exists(full_path):
78
+ self.logger.warning(f"MCP server location does not exist: {full_path}")
79
+
80
+ # Auto-discovery for testing
81
+ if self.config.get("auto_discover", False):
82
+ self._auto_discover_servers(server_paths, components)
83
+
84
+ core_servers = [name for name in server_paths.keys() if name in core_paths]
85
+ mcp_servers_list = [name for name in server_paths.keys() if name not in core_paths]
86
+
87
+ self.logger.info(f"Core paths: {', '.join(core_servers) if core_servers else 'None'}")
88
+ self.logger.info(f"MCP servers: {', '.join(mcp_servers_list) if mcp_servers_list else 'None'}")
89
+
90
+ return server_paths, components
91
+
92
+ def _auto_discover_servers(self, server_paths: Dict[str, str],
93
+ components: Dict[str, List[Dict[str, Any]]]) -> None:
94
+ """
95
+ Automatically discover test servers.
96
+
97
+ Args:
98
+ server_paths: Dictionary to add server paths to.
99
+ components: Dictionary to add components to.
100
+ """
101
+ # Add known test servers
102
+ test_servers = self.config.get("auto_discover_servers", [
103
+ "time_server",
104
+ "echo_server",
105
+ "test_server"
106
+ ])
107
+
108
+ for server_name in test_servers:
109
+ if server_name not in server_paths:
110
+ # Create a mock path
111
+ mock_path = os.path.join(self.project_root, "servers", server_name, "src")
112
+ server_paths[server_name] = mock_path
113
+ components[server_name] = []
114
+
115
+ # Add a mock tools component
116
+ components[server_name].append({
117
+ "type": "tools",
118
+ "module": f"{server_name}.tools",
119
+ "auto_discovered": True
120
+ })
121
+
122
+ def _add_component(self, server_name: str, server_config: Dict[str, Any],
123
+ component_type: str, components: Dict[str, List[Dict[str, Any]]]) -> None:
124
+ """
125
+ Add a component (tools, resources, prompts) to the components dictionary.
126
+
127
+ Args:
128
+ server_name: Name of the server.
129
+ server_config: Configuration for the server.
130
+ component_type: Type of component (tools, resources, prompts).
131
+ components: Dictionary to add the component to.
132
+ """
133
+ component_config = server_config.get(component_type, {})
134
+ if not isinstance(component_config, dict):
135
+ return
136
+
137
+ enabled = component_config.get("enabled", True)
138
+ module = component_config.get("module")
139
+
140
+ if enabled and module:
141
+ components[server_name].append({
142
+ "type": component_type,
143
+ "module": module,
144
+ "auto_discovered": False
145
+ })
146
+
147
+ def _setup_python_paths(self) -> None:
148
+ """Add server source directories to Python path"""
149
+ # Add paths to sys.path in reverse order for correct priority
150
+ paths = list(self.server_paths.values())
151
+ for path in reversed(paths):
152
+ if path not in sys.path:
153
+ self.logger.debug(f"Adding {path} to sys.path")
154
+ sys.path.insert(0, path)
155
+
156
+ def load_server_components(self) -> Dict[str, Any]:
157
+ """
158
+ Load all enabled components from configured servers.
159
+
160
+ Returns:
161
+ Dictionary of loaded modules.
162
+ """
163
+ for server_name, server_components in self.components.items():
164
+ for component in server_components:
165
+ module_name = component["module"]
166
+ component_type = component["type"]
167
+ auto_discovered = component.get("auto_discovered", False)
168
+
169
+ # Skip if already loaded
170
+ if module_name in self.loaded_modules:
171
+ self.logger.debug(f"Module {module_name} already loaded")
172
+ continue
173
+
174
+ # For testing, always try to import the module
175
+ try:
176
+ self.logger.info(f"Loading {component_type} from {module_name}" +
177
+ (" (auto-discovered)" if auto_discovered else ""))
178
+ self.loaded_modules[module_name] = importlib.import_module(module_name)
179
+ except ImportError as e:
180
+ # If it's for testing, we'll catch the import error but not raise it
181
+ if auto_discovered:
182
+ self.logger.debug(f"Auto-discovered module {module_name} not found: {e}")
183
+ else:
184
+ self.logger.warning(f"Failed to import {module_name}: {e}")
185
+
186
+ return self.loaded_modules
187
+
188
+ def get_loaded_modules(self) -> Dict[str, Any]:
189
+ """
190
+ Get all loaded modules.
191
+
192
+ Returns:
193
+ Dictionary of loaded modules.
194
+ """
195
+ return self.loaded_modules
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: chuk-mcp-runtime
3
+ Version: 0.1.0
4
+ Summary: Generic CHUK MCP Runtime for MCP Servers
5
+ Author-email: Chris Hay <your.email@example.com>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.md
13
+ Requires-Dist: mcp>=1.6.0
14
+ Requires-Dist: pydantic>=2.10.6
15
+ Requires-Dist: pyyaml>=6.0.2
16
+ Provides-Extra: websocket
17
+ Requires-Dist: websockets>=10.0; extra == "websocket"
18
+ Provides-Extra: dev
19
+ Dynamic: license-file
20
+
21
+ # CHUK MCP Runtime
22
+ A generic framework for creating MCP (Messaging Control Protocol) servers that can be installed as a package on any MCP server.
23
+
24
+ ## Features
25
+
26
+ - Flexible configuration system with YAML support
27
+ - Automatic tool discovery and registration
28
+ - Configurable logging
29
+ - Server registry for managing multiple MCP servers
30
+ - Runtime for hosting MCP tools
31
+ - Easy-to-use tool decorator for registering functions as MCP tools
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ # Install from PyPI
37
+ pip install chuk-mcp-runtime
38
+
39
+ # Install with optional dependencies
40
+ pip install chuk-mcp-runtime[websocket,dev]
41
+
42
+ # Install from source
43
+ git clone https://github.com/yourusername/chuk-mcp-runtime.git
44
+ cd chuk-mcp-runtime
45
+ pip install -e .
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ### 1. Create a configuration file
51
+
52
+ Create a `config.yaml` file:
53
+
54
+ ```yaml
55
+ host:
56
+ name: "my-chuk-mcp-server"
57
+ log_level: "INFO"
58
+
59
+ server:
60
+ type: "stdio"
61
+
62
+ tools:
63
+ registry_module: "chuk_mcp_runtime.common.mcp_tool_decorator"
64
+ registry_attr: "TOOLS_REGISTRY"
65
+
66
+ mcp_servers:
67
+ my_server:
68
+ location: "./my_server"
69
+ enabled: true
70
+ tools:
71
+ module: "my_server.tools"
72
+ enabled: true
73
+ ```
74
+
75
+ ### 2. Create a tools module
76
+
77
+ Create a file `my_server/tools.py`:
78
+
79
+ ```python
80
+ from chuk_mcp_runtime.common.mcp_tool_decorator import mcp_tool
81
+
82
+ @mcp_tool(name="greet", description="Generate a greeting")
83
+ def greet(name: str, formal: bool = False) -> str:
84
+ """
85
+ Generate a greeting message.
86
+
87
+ Args:
88
+ name: Name to greet.
89
+ formal: Whether to use a formal greeting.
90
+
91
+ Returns:
92
+ Greeting message.
93
+ """
94
+ if formal:
95
+ return f"Good day, {name}. It is a pleasure to meet you."
96
+ else:
97
+ return f"Hey {name}! How's it going?"
98
+ ```
99
+
100
+ ### 3. Run the server
101
+
102
+ You can run the server in several ways:
103
+
104
+ ```bash
105
+ # Using the command-line entry point
106
+ chuk-mcp-server
107
+
108
+ # Using the Python module
109
+ python -m chuk_mcp_runtime
110
+
111
+ # With a specific config file
112
+ CHUK_MCP_CONFIG_PATH=./my_config.yaml chuk-mcp-server
113
+ ```
114
+
115
+ ## Creating a Custom MCP Server
116
+
117
+ ### Basic Server
118
+
119
+ ```python
120
+ import asyncio
121
+ from chuk_mcp_runtime.server.server import MCPServer
122
+ from chuk_mcp_runtime.server.config_loader import load_config
123
+ from chuk_mcp_runtime.common.mcp_tool_decorator import mcp_tool
124
+
125
+ # Define tools
126
+ @mcp_tool(name="example", description="Example tool")
127
+ def example_tool(param: str) -> dict:
128
+ return {"result": f"Processed: {param}"}
129
+
130
+ async def main():
131
+ # Load config
132
+ config = load_config()
133
+
134
+ # Create tools registry
135
+ tools_registry = {"example": example_tool}
136
+
137
+ # Create and run server
138
+ server = MCPServer(config, tools_registry=tools_registry)
139
+ await server.serve()
140
+
141
+ if __name__ == "__main__":
142
+ asyncio.run(main())
143
+ ```
144
+
145
+ ### Using Server Registry
146
+
147
+ For more complex setups with multiple server components:
148
+
149
+ ```python
150
+ import asyncio
151
+ from chuk_mcp_runtime.server.config_loader import load_config, find_project_root
152
+ from chuk_mcp_runtime.server.server_registry import ServerRegistry
153
+ from chuk_mcp_runtime.server.server import MCPServer
154
+
155
+ async def main():
156
+ # Load config
157
+ config = load_config()
158
+
159
+ # Find project root
160
+ project_root = find_project_root()
161
+
162
+ # Initialize server registry
163
+ server_registry = ServerRegistry(project_root, config)
164
+
165
+ # Load server components
166
+ loaded_modules = server_registry.load_server_components()
167
+
168
+ # Create and run server
169
+ server = MCPServer(config)
170
+ await server.serve()
171
+
172
+ if __name__ == "__main__":
173
+ asyncio.run(main())
174
+ ```
175
+
176
+ ## Configuration Options
177
+
178
+ The configuration system supports the following settings:
179
+
180
+ ### Host Configuration
181
+
182
+ ```yaml
183
+ host:
184
+ name: "my-mcp-server" # Server name
185
+ log_level: "INFO" # Logging level (DEBUG, INFO, WARNING, ERROR)
186
+ ```
187
+
188
+ ### Server Configuration
189
+
190
+ ```yaml
191
+ server:
192
+ type: "stdio" # Server type (stdio, websocket)
193
+ host: "localhost" # For websocket server
194
+ port: 8080 # For websocket server
195
+ ```
196
+
197
+ ### Logging Configuration
198
+
199
+ ```yaml
200
+ logging:
201
+ level: "INFO" # DEBUG, INFO, WARNING, ERROR
202
+ format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
203
+ reset_handlers: false
204
+ quiet_libraries: true
205
+ ```
206
+
207
+ ### Tool Configuration
208
+
209
+ ```yaml
210
+ tools:
211
+ registry_module: "mcp_runtime.common.mcp_tool_decorator"
212
+ registry_attr: "TOOLS_REGISTRY"
213
+ ```
214
+
215
+ ### MCP Server Configurations
216
+
217
+ ```yaml
218
+ mcp_servers:
219
+ example_server:
220
+ location: "servers/example_server"
221
+ enabled: true
222
+ tools:
223
+ module: "example_server.tools"
224
+ enabled: true
225
+ resources:
226
+ module: "example_server.resources"
227
+ enabled: true
228
+ prompts:
229
+ module: "example_server.prompts"
230
+ enabled: true
231
+ ```
232
+
233
+ ## License
234
+
235
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,16 @@
1
+ chuk_mcp_runtime/__init__.py,sha256=Lls8OFNHJylYQcWMOS80QA6iK0O--Q6mQXT_TGhD15U,262
2
+ chuk_mcp_runtime/main.py,sha256=f8Mmb8aYVcbk7R7KUbenf7Yqg6myeDJoVX9LeLPDIrs,2621
3
+ chuk_mcp_runtime/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ chuk_mcp_runtime/common/errors.py,sha256=N4nuF-Zg5NSF-fmzrmCu4JpAl9qwN34vtj9amBznlAg,908
5
+ chuk_mcp_runtime/common/mcp_tool_decorator.py,sha256=NReZRebT7iqDopko5QiJDCINFI4F5vI2sErLjvVwAws,5978
6
+ chuk_mcp_runtime/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ chuk_mcp_runtime/server/config_loader.py,sha256=Fe1XHOqpXDMGiK-a6v9jHRvplPOayj9lJDeSouvtL04,4455
8
+ chuk_mcp_runtime/server/logging_config.py,sha256=AlAt4xxSOfMVqdIDveY-k1RyM4nqEAuUCWo0OIS4G8Y,2839
9
+ chuk_mcp_runtime/server/server.py,sha256=ce2X2CG4mnrf6j4qRlLjr7D1QbCQBKMh0vPPBRknWkU,7437
10
+ chuk_mcp_runtime/server/server_registry.py,sha256=MSY0Fu7414niECRxTr3gZ-ODhCXmYx7WzJUbHGrrtkI,8001
11
+ chuk_mcp_runtime-0.1.0.dist-info/licenses/LICENSE.md,sha256=HqgRrOv3qCAICv8XxRZLyNAHz1TJg1v9vb34MnE9mcY,1065
12
+ chuk_mcp_runtime-0.1.0.dist-info/METADATA,sha256=to6aM-RPBsm1L1TINwtxMUpqMK-9Eeduuou3Mnbueg4,5327
13
+ chuk_mcp_runtime-0.1.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
14
+ chuk_mcp_runtime-0.1.0.dist-info/entry_points.txt,sha256=oJatwTJgNhlcpCK17ohLAy3q154_Ui0jO76aEMIqTVQ,63
15
+ chuk_mcp_runtime-0.1.0.dist-info/top_level.txt,sha256=ljLxbagMOu5eOz634lTmgTFP8lNP5qfSMIdT6rwWQMg,17
16
+ chuk_mcp_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chuk-mcp-server = chuk_mcp_runtime.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Chris Hay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ chuk_mcp_runtime